Seeq Knowledge Base
Latest R65 R58 Cloud On-Premise
Latest R65 R58 Cloud On-Premise

Accessing Agents from Data Lab

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 .

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.

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

/genai/llm/chat

POST

Start (or continue) a chat

/genai/llm/chat/stream/{chat_id}

GET

Stream the assistant's answer text (async mode)

/genai/llm/chat/stream/thinking/{chat_id}

GET

Stream tool-call / progress updates (NDJSON)

/api/markdown/links

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:

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

Python
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 agentType

Additional Headers

Agent Q

aq

None

Optional: x-sq-origin: workbench + x-sq-origin-url (see Actions agent) will automatically load workbook/worksheet context for the agent

Actions (Workbench Action)

actions

x-sq-origin set to one of [workbench, organizer, action_board, homescreen, tree_builder]

x-sq-origin-url: /workbook/{workbook_id}/worksheet/{worksheet_id}

Vantage

actions

x-sq-origin: vantage + x-sq-origin-url (same as Actions)

General

general

None

Data Lab

code

None

Formula

formula

None

Using this table information to adapt the basic example above to call the Actions agent results in:

Python
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:

Python
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):

Python
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:

type

Meaning

thinking_section_start

Agent Q started a logical section of work - section_id, title

thinking_item_start

A step started - section_id, item_id, name, description, and item_type (one of tool_call, code_block, context_retrieval, reasoning_summary)

thinking_item_content_delta

Incremental content for a running item - delta

thinking_item_complete

The step finished - description, content, optional metadata

thinking_item_error

The step failed - includes an error field

thinking_section_complete

The section finished - optional error: true flag

stage

Pipeline stage/monitoring update - name, value

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:

Python
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:

Python
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:

Python
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 csvPreview when convenient.

  • PDFs are extracted as document text.

  • The GenAI service fetches the bytes with your credentials, so the id must be exactly the file id
    (with extension) returned by the upload - full URLs or paths are rejected.