Python AI Tools — Author Guide
How to write Script (Python) AI Tools for Netridium. This assumes you know
Python and understand Netridium (connectors, AI Tools, running a report). It covers the
runtime, the full script API, the output envelope types, the model (prompt() /
agent()), limits, and a library of worked examples.
Where this runs. A Python AI Tool is the Script (Python) type in the Edit AI Tool form's Type dropdown. (The type is gated per workspace — if you don't see it, a System Manager must enable Python in Settings.) Paste your script into the code editor, pick the connector(s) it may use, and Run.
1. The execution model in one minute
- Your script runs in a Pyodide sandbox (CPython compiled to WebAssembly) in a
throwaway subprocess that holds no secrets and has no network, no
filesystem, and no environment access. The only way out is the host bridge:
mcp(),prompt(),agent(). - The runner supports top-level
await— writerows = await mcp(...).call(...)directly, noasyncio.run(). - Your script produces its result by calling
emit({...})with a typed envelope. The lastemit()wins; anything not emitted is discarded. A script that never callsemit()is not an error — it returns a green{"type": "text", "text": "No data returned"}report. - Everything is read-only. No AI Tool of any type can write to a connector; write tools are hidden and rejected for the whole run (see §5).
What's available
- The Python standard library ships with Pyodide:
json,datetime,csv,re,math,statistics,collections,itertools,functools,io,base64,hashlib,decimal,textwrap,string,random, and the rest.import jsonworks fine — the sandbox just doesn't pre-bind a barejsonname (it imports it internally as_json), soimport jsonyourself if you want it. - The host builtins, always present (no import):
mcp,emit,list_connectors,prompt,agent, and the exception classesMcpError,PromptError,AgentError.
What's not available
- No third-party packages —
numpy,pandas,requests, etc. are not loaded. Imports of them fail. Use the standard library plus the connector data. - No network / sockets / files / env vars — reach external systems only through
connectors (
mcp()). - No input parameters (yet). Input parameters defined on an AI Tool are
delivered to Prompt (
ai_mcp) and LSPL types only. The Script (Python) path is not wired to inputs — your script receives no user-supplied values at runtime. If you need user input, use a Prompt/LSPL type, or hard-code/derive the values in the script. print()is not output. Tenantprint()goes to the worker logs (for your debugging during development), never to the report. The report is only what youemit(). To "see" a value,emit()it asjson/text.
2. Script API reference
mcp(name) → connector proxy
Returns a proxy for a connector linked to this AI Tool. Resolution: by the connector's
display name first (the name shown on /connectors), falling back to the
connector type only when exactly one connector of that type is linked. If
two connectors share a type (e.g. a sandbox and a production Maxio), mcp("maxio") is
ambiguous and errors — use the exact name.
conn = mcp("Maxio (sandbox)") # by display name (always unambiguous)
conn = mcp("salesforce") # by type — only if exactly one is linked
await conn.call(tool, arguments=None)
Calls one MCP tool and returns its payload. Async — await is required.
arguments is a dict (defaults to {}).
The payload is unwrapped for you with a three-tier rule: prefer the tool's
structuredContent; else JSON-parse its text content;
else, for the Markdown-report style some servers use (Maxio/NetSuite), parse it into record dicts;
else hand back the raw string. So you usually get a
dict/list, not a blob — but the exact shape is the tool's, so
probe an unfamiliar tool first (§7, Example 2).
rows = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 20})
await conn.list_tools()
Returns the connector's tools as a list of {"name", "description", "inputSchema"} dicts
— use it to discover exact tool names and argument schemas at runtime. (Counts against the MCP-call
cap, §6.)
list_connectors() → list of
{"name", "type"}
The connectors available to this run. Handy when you're unsure what's linked.
for c in list_connectors():
print(c["name"], c["type"]) # goes to logs
emit(envelope) → None
Records the output envelope (see §3 for every type). Last call wins. The envelope
must be JSON-serializable — only dict, list,
str, int, float, bool, None.
Convert anything else first (d.isoformat(), list(s),
float(decimal_val)). emit() round-trips through JSON immediately, so a
non-serializable value fails there, with your stack trace.
await prompt(instruction, input=None, schema=None, model=None)
Calls the model through the broker (the API key never enters the sandbox). See §4.
await agent(goal, tools=None, max_steps=8, model=None, allow_writes=False)
Runs a small, read-only, tool-using loop. See §4.
Errors — what to catch
| Call | On failure raises | Catch with |
|---|---|---|
await mcp(...).call(...) |
a generic exception (the tool's error text) | except Exception |
await prompt(...) |
PromptError |
except PromptError |
await agent(...) |
AgentError |
except AgentError |
Gotcha: the
McpErrorclass exists, but.call()does not raise it today — a failed/isErrortool call surfaces as a plain exception. CatchException(orBaseException) around.call(), notMcpError.prompt()/agent()do convert their failures toPromptError/AgentError.
3. emit() envelope reference —
every output type
The type field selects how the report is stored and rendered. Exactly one type per
emit(). Required fields are marked (*); the worker rejects a bad
envelope with a tenant-readable error naming the problem.
Excel → .xlsx (interactive sheet grid)
emit({
"type": "excel", # (*)
"sheets": [ # (*) non-empty list
{
"name": "Customers", # (*) tab name; sanitized to <=31 chars
"data": [ # (*) list of ROW DICTS (not a dict!)
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
],
},
# … more sheets …
],
})
- Columns come from the FIRST row's keys. Later rows may omit keys (blank cells); new keys in later rows do not add columns. Normalize your rows if they're ragged.
- Cell values:
str/int/float/bool/Nonerender natively; nesteddict/listvalues are JSON-stringified into the cell. - Empty
data→ a single(No data)row for that sheet. - Passing a dict where
dataexpects a list is the #1 error — unwrap the row list first (e.g.data=raw["customers"]).
HTML → .html (sandboxed iframe)
emit({
"type": "html", # (*)
"html": "<h1>Quarterly recap</h1><p>…</p>",# (*) fragment or full document, string
})
- Rendered in a
sandbox=""iframe: no script execution, no form submit, no navigation. Treat it as a static document. Inline SVG works (for a standalone chart preferimage).
Image → .svg / .png / .jpg
(inline)
emit({
"type": "image", # (*)
"format": "svg", # (*) "svg" | "png" | "jpeg" (alias "jpg"), case-insensitive
"data": "<svg xmlns=…> … </svg>", # (*) string; meaning depends on format
})
format="svg"→datais the raw SVG markup (a string starting with<svg).format="png"/"jpeg"→datais base64 of the raster bytes, with nodata:image/…;base64,prefix (a prefix corrupts the image).- Prefer SVG for charts/diagrams your script composes — crisp at any zoom and smaller than rasters.
Text → .txt (monospace
<pre>, wrapped)
emit({
"type": "text", # (*)
"text": "Line one\nLine two", # (*) string
})
- Newlines/tabs are preserved. Great for shape-probe runs (Example 2).
PDF → .pdf
(wrapped text; Letter, Helvetica, 1" margins, auto-paginated)
# Block form — preferred for structure:
emit({
"type": "pdf", # (*)
"title": "Q1 Summary", # optional cover heading
"blocks": [ # (*) one of: blocks | text
{"type": "heading", "text": "Section 1"},
{"type": "paragraph", "text": "A paragraph of prose…"},
# heading | paragraph are the ONLY block kinds
],
})
# Prose form — one big auto-wrapped block (blank lines separate paragraphs):
emit({"type": "pdf", "title": "Q1 Summary", "text": "Long prose…"})
- Wrapped text only — no tables, no images, no inline styling. Tabular data →
excel; a chart →image.
JSON → .json (read-only CodeMirror
viewer)
emit({
"type": "json", # (*)
"data": <any JSON-compatible value>, # (*) canonical field
# "json": <…> # accepted as an alias for "data"
})
datamay be adict,list,str,int,float,bool, orNone.- Do not
json.dumps(...)it yourself — pass the native value; the worker serializes. Pre-encoding stores one giant escaped string. - The worker normalizes a few shapes before storing (a JSON-encoded string is parsed once; a raw
MCP result object has its
structuredContent/text extracted; already-structured values are stored as-is) — so connector output rarely lands as an unreadable blob. (The Markdown→records promotion happens earlier, atmcp().call()time — see §2, not here.)
Type inference shortcut: if you omit
typebut includesheets, the worker treats it asexcel. Any other missing/unknowntypeis an error. Be explicit.
4. The model — prompt() and agent()
The model is a brokered capability: the API key lives only in the worker, which attaches it, enforces the model allowlist and the run's token budget, and audits every call. Reach for it when the value is in the model's judgment (extract, classify, summarize, normalize messy data) — not for work plain Python does deterministically.
await prompt(instruction, input=None, schema=None, model=None)
instructionis the trusted directive.inputis treated strictly as DATA, never as instructions — safe to pass raw, possibly-messy connector output.- Return value: with no
schema, a string (the model's text). With aschema(a JSON-Schema dict), a parsed object validated against it — prefer this for extract/classify/enrich/route, so you get adict/listyou can drop straight into an envelope. model(optional): a tier alias"fast","smart", or"default", or an allowed model id. Tiers map to:
| Tier | Model | Use for |
|---|---|---|
"fast" |
Claude Haiku 4.5 | high-volume extraction/classification, cheap passes |
"smart" |
Claude Opus 4.8 | hard reasoning, nuanced summaries |
"default" |
Claude Sonnet 4.6 | the balanced default when model is omitted |
- Raises
PromptErroron a disallowed model (model_not_allowed), budget exhaustion (budget_exceeded), a missing key (byok_required), or a provider error.
SCHEMA = {
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
}
out = await prompt("Summarize this in one sentence.", input=doc, schema=SCHEMA, model="fast")
# out == {"summary": "..."} (a dict, because schema was given)
await agent(goal, tools=None, max_steps=8, model=None, allow_writes=False)
A small in-sandbox loop: each step the model either calls one read-only tool from
the connectors you pass, or finishes with a result (which agent() returns). Every
reasoning step is a prompt() call (so it shares the run's budget and is audited) and
every tool call is brokered (tenant-scoped, read-only).
toolsmust be connector proxies, e.g.tools=[mcp("Maxio (sandbox)")].max_stepscaps tool-call iterations (default 8). Note each step costs ~1 model call against the per-run cap (§6).allow_writesis a dormant no-op — the loop is always read-only (only read-looking tools are even offered to the model). It's accepted for source compatibility; leave itFalse.- Returns whatever the model puts in its final result — any JSON value (usually a list or dict, but model-shaped, not guaranteed). Coerce/validate it before emitting.
- Raises
AgentErroron an empty goal, no tools, no read-only tools, ormax_stepsexhausted without finishing.
Use prompt() (with you driving the mcp() calls) when you know exactly which
tools to call; reach for agent() only when the choice of tool/arguments is
itself the work.
5. Connectors & the read-only guarantee
Connectors linked to the AI Tool are what your script can reach. Types:
| Type | mcp() examples |
Notes |
|---|---|---|
| Maxio | remote MCP — discover with list_tools() (e.g.
advancedBillingShowCustomers)
|
Advanced Billing + Core |
| NetSuite | remote MCP — discover with list_tools() |
Oracle NetSuite ERP |
| Salesforce | sf_soql_query, sf_list_objects,
sf_describe_object, sf_get_record
|
query with SOQL |
| QuickBooks Online | qbo_query, get_company_info, list_customers,
list_invoices, list_accounts, …
|
|
| Xero | list_invoices, list_contacts, list_accounts,
get_report_profit_and_loss, …
|
|
| Google Drive | gdrive_search, gdrive_list, gdrive_read_file
|
hidden in the UI; works if already linked |
For native connectors the read tool names above are stable; for Maxio/NetSuite
(vendor-hosted) always confirm with list_tools(). Write tools
(create_*/update_*, sf_create_record,
gdrive_create_file, …) exist in the registries but are hidden and
rejected for every AI Tool — the platform is read-only. A write call fails with a
clear "AI Tools are read-only" error, and agent(allow_writes=True) is ignored.
6. Limits & quotas
| Limit | Default | What happens at the edge |
|---|---|---|
| AI token budget / run | 300,000 tokens | shared by all prompt()/agent() calls →
PromptError (budget_exceeded)
|
| AI calls / run | 50 | → PromptError (ai_call_cap); an agent() step
≈ one call |
| Output tokens / call | 16,384 | model response capped; raise only via worker config if envelopes truncate |
| MCP calls / run | 500 | every .call() and .list_tools() counts →
error (call_cap) |
| Wall-clock / attempt | 8 minutes | overridable per AI Tool via Edit form → timeout override (minutes); applies to the Python path |
| Output size | 16 MB | the emitted envelope JSON; paginate/trim large pulls |
Practical implications: paginate large connector pulls (Example 4); keep model use
proportionate (a per-row prompt() over thousands of rows will blow the call cap/budget
— batch instead); and remember a script that makes no model call needs no model key
at all.
7. Examples
Each block is complete — paste it into a Script (Python) AI Tool with the named connector linked. Most use Maxio as the stand-in data source; swap in any connector + tool you have.
Example 1 — The minimal script: pull → Excel
raw = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 20})
# Maxio nests rows as {"customers": [{"customer": {...}}, ...]}; unwrap to flat dicts.
customers = [c["customer"] for c in raw.get("customers", [])]
emit({"type": "excel", "sheets": [{"name": "Customers", "data": customers}]})
Example 2 — Shape probe (run FIRST against an unfamiliar tool)
Emits the raw response as text so you can see the keys before building a real envelope. Cheap
(per_page=5) and avoids spreadsheet errors while exploring.
rows = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 5})
emit({
"type": "text",
"text": "type: " + type(rows).__name__
+ "\nkeys: " + str(list(rows.keys()) if isinstance(rows, dict) else "(not a dict)")
+ "\n\nraw (truncated):\n" + str(rows)[:2000],
})
Example 3 — Multi-sheet Excel with a derived summary
raw = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 20})
customers = [c["customer"] for c in raw.get("customers", [])]
def count_with(key):
return sum(1 for c in customers if c.get(key))
summary = [{
"total_customers": len(customers),
"with_email": count_with("email"),
"with_vat_number": count_with("vat_number"),
}]
emit({
"type": "excel",
"sheets": [
{"name": "Summary", "data": summary},
{"name": "Customers", "data": customers},
],
})
Example 4 — Paginate until a short page
PER_PAGE, MAX_PAGES = 50, 20 # MAX_PAGES is a hard cap — each page is a real MCP call
all_customers = []
for page in range(1, MAX_PAGES + 1):
resp = await mcp("Maxio (sandbox)").call(
"advancedBillingShowCustomers", {"per_page": PER_PAGE, "page": page}
)
rows = [c["customer"] for c in resp.get("customers", [])]
if not rows:
break
all_customers.extend(rows)
if len(rows) < PER_PAGE: # a short page means we've reached the end
break
emit({"type": "excel", "sheets": [{"name": "Customers", "data": all_customers}]})
Example 5 — JSON output (passthrough and reshaped)
import datetime as dt
raw = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 20})
customers = [c["customer"] for c in raw.get("customers", [])]
emit({
"type": "json",
"data": {
"customers": customers, # the native value — DON'T json.dumps it
"meta": {
"count": len(customers),
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"source": "Maxio (sandbox)/advancedBillingShowCustomers",
},
},
})
Example 6 — A native connector with SOQL (Salesforce)
Native connectors use stable tool names. Salesforce queries with SOQL via sf_soql_query;
the result's records is your row list.
res = await mcp("salesforce").call(
"sf_soql_query",
{"soql": "SELECT Id, Name, Industry, AnnualRevenue FROM Account ORDER BY Name LIMIT 50"},
)
rows = res.get("records", res if isinstance(res, list) else [])
emit({"type": "excel", "sheets": [{"name": "Accounts", "data": rows}]})
QuickBooks is similar:
await mcp("QuickBooks Online").call("list_customers", {}), or run a query withqbo_query. Xero:list_invoices,get_report_profit_and_loss, etc. Unsure of a tool's name or args? Callawait mcp(name).list_tools().
Example 7 — Discover what's linked, then pull
emit({
"type": "json",
"data": {
"connectors": list_connectors(),
# tools for the first connector, if any
"tools": [
{"name": t["name"], "description": t.get("description", "")}
for t in (await mcp(list_connectors()[0]["name"]).list_tools())
] if list_connectors() else [],
},
})
Example 8 — prompt() for
structured extraction → Excel
Hand messy connector output to the model and get back clean, typed rows. With a schema,
prompt() returns a parsed dict — no prose to clean up.
raw = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 10})
SCHEMA = {
"type": "object",
"properties": {
"rows": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": ["string", "integer"]},
"name": {"type": "string"},
"email": {"type": "string"},
"organization": {"type": "string"},
},
},
}
},
"required": ["rows"],
}
result = await prompt(
"Extract a 'rows' array of customers from the data, each with id, name, email, "
"and organization. Use empty strings for missing fields.",
input=raw,
schema=SCHEMA,
model="fast",
)
emit({"type": "excel", "sheets": [{"name": "Customers", "data": result["rows"]}]})
Example 9 — prompt() for
judgment (classify / enrich rows)
Where the value is the model's judgment, not a lookup. Classify each customer's likely segment, then ship Excel. (One batched call over all rows — not one call per row — to stay well under the call cap and budget.)
raw = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 25})
customers = [c["customer"] for c in raw.get("customers", [])]
slim = [{"id": c.get("id"), "organization": c.get("organization"), "email": c.get("email")}
for c in customers]
SCHEMA = {
"type": "object",
"properties": {
"rows": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": ["string", "integer"]},
"segment": {"type": "string", "enum": ["smb", "mid_market", "enterprise", "unknown"]},
"reason": {"type": "string"},
},
"required": ["id", "segment"],
},
}
},
"required": ["rows"],
}
labels = await prompt(
"Classify each customer's segment (smb, mid_market, enterprise, or unknown) "
"from the organization/email signals. Return one row per input id.",
input=slim,
schema=SCHEMA,
model="fast",
)
by_id = {str(r["id"]): r for r in labels["rows"]}
enriched = [{**c, "segment": by_id.get(str(c.get("id")), {}).get("segment", "unknown")}
for c in customers]
emit({"type": "excel", "sheets": [{"name": "Customers", "data": enriched}]})
Example 10 — agent() lets
the model drive the tool calls
result = await agent(
goal="List the first 10 Maxio customers as rows with id, organization, and email.",
tools=[mcp("Maxio (sandbox)")], # read-only tools only
max_steps=6,
model="fast",
)
# agent() returns whatever the model put in its final result — coerce to a row list.
rows = (result if isinstance(result, list)
else result.get("rows", []) if isinstance(result, dict)
else [])
emit({"type": "excel", "sheets": [{"name": "Customers", "data": rows}]})
Example 11 — HTML report (build a table yourself)
import html
raw = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 15})
customers = [c["customer"] for c in raw.get("customers", [])]
cols = ["id", "organization", "email"]
head = "".join(f"<th>{html.escape(c)}</th>" for c in cols)
body = "".join(
"<tr>" + "".join(f"<td>{html.escape(str(c.get(k, '')))}</td>" for k in cols) + "</tr>"
for c in customers
)
doc = f"""
<style>
body {{ font: 14px system-ui, sans-serif; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 6px 10px; text-align: left; }}
th {{ background: #f5f5f5; }}
</style>
<h1>Customers ({len(customers)})</h1>
<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>
"""
emit({"type": "html", "html": doc})
Example 12 — SVG chart (image, composed in pure Python)
raw = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 50})
customers = [c["customer"] for c in raw.get("customers", [])]
# Count customers per organization (top 8).
from collections import Counter
top = Counter((c.get("organization") or "—") for c in customers).most_common(8)
maxv = max((n for _, n in top), default=1)
bar_h, gap, pad, width = 24, 10, 160, 640
height = pad + len(top) * (bar_h + gap)
bars = []
for i, (label, n) in enumerate(top):
y = pad + i * (bar_h + gap)
w = int((width - pad - 60) * n / maxv)
bars.append(
f'<text x="8" y="{y + bar_h - 7}" font-size="12">{label[:22]}</text>'
f'<rect x="{pad}" y="{y}" width="{w}" height="{bar_h}" fill="#4f46e5"/>'
f'<text x="{pad + w + 6}" y="{y + bar_h - 7}" font-size="12">{n}</text>'
)
svg = (f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
f'font-family="system-ui, sans-serif">{"".join(bars)}</svg>')
emit({"type": "image", "format": "svg", "data": svg})
Example 13 — PDF summary (blocks)
import statistics as stats
raw = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 50})
customers = [c["customer"] for c in raw.get("customers", [])]
with_email = sum(1 for c in customers if c.get("email"))
emit({
"type": "pdf",
"title": "Customer Snapshot",
"blocks": [
{"type": "heading", "text": "Overview"},
{"type": "paragraph", "text": f"{len(customers)} customers sampled; "
f"{with_email} have an email on file "
f"({round(100 * with_email / max(len(customers), 1))}%)."},
{"type": "heading", "text": "Notes"},
{"type": "paragraph", "text": "Generated by a Python AI Tool. For the full "
"row-level data, use an Excel output instead."},
],
})
Example 14 — Robust error handling
# A failed MCP tool call raises a generic exception (NOT McpError) — catch broadly.
try:
raw = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 20})
except Exception as e:
emit({"type": "text", "text": f"Connector call failed: {e}"})
else:
customers = [c["customer"] for c in raw.get("customers", [])]
# The model path raises PromptError specifically.
try:
out = await prompt("One-line health note about this customer set.",
input={"count": len(customers)}, model="fast")
except PromptError as e:
out = f"(model unavailable: {e})"
emit({
"type": "excel",
"sheets": [
{"name": "Note", "data": [{"note": out}]},
{"name": "Customers", "data": customers},
],
})
Example 15 — Pure-Python value-add (no model, no extra calls)
The cheapest, most deterministic reports are plain Python over connector data.
import statistics as stats
raw = await mcp("Maxio (sandbox)").call("advancedBillingShowCustomers", {"per_page": 100})
customers = [c["customer"] for c in raw.get("customers", [])]
from collections import Counter
by_org = Counter((c.get("organization") or "—") for c in customers)
report = [{"organization": org, "customers": n} for org, n in by_org.most_common()]
emit({
"type": "excel",
"sheets": [
{"name": "By Organization", "data": report},
{"name": "Customers", "data": customers},
],
})
8. Debugging & best practices
- Probe unfamiliar tools with the Example 2 text dump before building an Excel envelope — the row list is almost always nested under a key.
awaiteverything —mcp(...).call(...),prompt(...),agent(...),list_tools(). A missingawaitleaves a coroutine object that breaksemit()with "not JSON serializable".- Inspect a
prompt()result by emitting it as JSON:emit({"type": "json", "data": result}). With noschemayou'll see a string; with aschema, the parsed object. - Normalize ragged rows before Excel — columns come from the first row's keys only.
- Don't
json.dumps()envelope data — pass native values; the worker serializes. - Batch model calls. One
prompt()over a list beats one call per row — the per-run call cap (50) and token budget (300k) are easy to exhaust otherwise. print()for dev only — it lands in worker logs, never the report. The report is exactly your lastemit().- No input parameters reach Python scripts yet — use a Prompt/LSPL AI Tool if you need user-supplied values at run time.
Common errors and what they mean
| Symptom | Cause / fix |
|---|---|
… is not JSON serializable (from emit) |
a datetime/set/coroutine/object in the envelope — convert it; check for a missing
await
|
Excel error suggesting data=rows["…"] |
you passed a dict where a list of row dicts was expected — unwrap the row list |
AI Tools are read-only … |
you called a write tool — only read tools are allowed |
PromptError: … budget_exceeded / ai_call_cap |
too much model use in one run — batch, or fewer/cheaper calls |
PromptError: … model_not_allowed |
the model= id isn't allowlisted — use
"fast"/"smart"/"default"
|
ambiguous_connector |
two connectors share the type you passed — use the exact display name |
report shows No data returned |
the script finished without calling emit() |