Skip to content

LogicScript Guide

For IT, administrators, and consultants who build repeatable Apps that their clients and end users run in Netridium.

LogicScript is a governed Python dialect for AI. You write the process as code — the steps, the arithmetic, every change to a record — and mark the places where the AI is asked for judgement or a person is asked to decide. Netridium runs it, and governs it: every step is visible and logged, every AI answer is typed and checked, AI output cannot change a record until it has been validated or approved, and every run is bounded in cost, time and the number of changes it may make.

This guide is the language reference. LogicScript is one of the App types; the Admin Guide covers the others, and everything around an App — connectors, inputs, roles, limits.

Availability. LogicScript is sold per plan. If it isn’t in your workspace’s Type dropdown, your plan doesn’t include it — ask your service provider.

In one line: the script is in charge. Netridium runs your logic as code, calls the AI only where you wrote ask or agent, checks what comes back, and never lets AI output change a record until you have validated or approved it.


Every LogicScript is a valid Python file, so it reads like Python and your editor highlights it as Python. It is not run by Python. Netridium compiles it, refuses anything outside the dialect with an error that names what to write instead, and runs it in its own interpreter.

A script has three kinds of step.

Step Runs as Returns Can change data?
Code Code, run by Netridium. Same inputs and data give the same result. Any value Yes, when the write gates are open (§12)
ask One AI call with a typed result A Shape you declared (untrusted) No
agent A bounded AI loop over the read-only tools you list A Shape you declared (untrusted) No

Two more steps involve a person rather than the AI:

Step What it does
request_… Pauses the run and asks someone a question: yes/no, pick one, pick several, or a short form.
approve Pauses the run until someone approves, edits, or rejects a value.

How it compares with the other types:

  • A Prompt describes an outcome in plain English and lets the AI work out everything.
  • A LogicScript fixes the process, the arithmetic, and every write in code, and uses the AI only for judgement — with a typed answer each time.

The Admin Guide has the full type comparison.


  • Logic wraps language. Arithmetic, totals, routing and writes run as code, never inside a model. Arithmetic is exact decimal arithmetic.
  • Nothing is guessed. Adding text to a number, or comparing a missing value, is an error that names the line — never a silently wrong total.
  • Prose never runs as logic. Text for the AI is the prompt of an ask or agent. A sentence anywhere else is a compile error.
  • Typed boundaries. An AI step returns the Shape you named, with every constraint checked, or the step fails. There is no free-text return.
  • Untrusted by default. AI output is tainted. A tainted value cannot reach a connector write until it is cleared (§8).
  • Your data is data. A value you put in a prompt, with { } in an f-string or with data=, is never spliced into the instruction. It travels in a separate data block the AI is told to treat as content, not commands.
  • Steps are visible. A call that reaches outside the script — a connector, the AI, a person — is always a statement of its own. It cannot hide inside an expression.
  • Bounded. There is no while and no recursion, and every AI step runs under the app’s run limits and any tighter limit= you set.

"""You work for an accounts-receivable team."""
OUTPUT = "json"
class Urgency(Shape):
priority: Enum["low", "medium", "high"]
reason: str = field(max_len=200, required=False)
def run(as_of: Date):
# Code: runs the same way every time.
assert as_of, "Pick an as-of date"
invoices = query(
QBO.Invoice,
select=["Id", "DocNumber", "Balance", "DueDate"],
where=[QBO.Invoice.Balance > 0],
)
overdue = [i for i in invoices if i.DueDate < as_of]
# One AI call, which must return an Urgency.
u = ask(Urgency, "How urgent is this overdue balance?", data={"invoices": overdue}, model="fast")
return {"priority": u.priority, "total": sum(i.Balance for i in overdue), "invoices": len(overdue)}

A script is made of top-level declarations:

Declaration Purpose
A docstring on the first line Background sent with every ask and agent.
NAME = <literal> A constant: a number, text, True, False, None, or a list, tuple or dict of those. A function can read a constant but never change it.
OUTPUT = "excel" The result’s format (§10). Optional.
class Name(Shape): A typed record: what an AI step returns, or what a form collects.
class Params(Parameters): Named settings with defaults (§4).
def run(...): Where the run starts. Required.
def helper(...): A helper function.

Nothing else is allowed at the top level. Indent with spaces — four is the convention; tabs are an error.

Inputs are run’s parameters, matched by key. def run(as_of: Date) receives the value of the input whose key is as_of, so the app’s Inputs tab must have a field with exactly that key. A parameter with no matching input is an error when you save. A parameter named inputs receives every value as one dict (inputs.as_of). An optional input left blank arrives as None. The type annotation is optional.

Helpers are pure. A helper def computes and returns. It cannot call a connector, the AI, or a person, and it cannot call itself. Every step lives in run, so reading run shows you everything the script does.

Where your plan includes it, the script’s card on the edit page has three views: Code, Flow and Split. Flow draws the script as boxes, top to bottom: what it reads, where the AI decides, where it asks a person, what it checks and what it changes. Plain calculation is folded into one Calculate box per run of lines, a loop is a box around its body, and a branch shows each condition as written, side by side. A step that may change data is marked.

The flow is for reading, not editing: you change the script in the code, and the flow follows as you type. In Split, clicking a box marks its lines in the code, and moving the cursor in the code marks its box. While the script has an error, the last flow that worked stays on screen, dimmed.

A past run, on the flow. Pick one of the app’s recent runs from Run above the flow (workspace admins). Each step is marked Done, Failed, Handled (an except caught it), Waiting (for a person) or left faded if the run never reached it; a step in a loop shows how many times it ran. Clicking a step lists what the run log holds for it — when, how long, the cost, the error, an AI step’s answer. If the script has been edited since that run, entries for steps that no longer exist are counted separately rather than shown on the wrong box.

You don’t have to start from a blank page. Describe the process in the Prompt box above the script — “List overdue QuickBooks invoices, let me pick which ones get a reminder, and confirm the total” — select the app’s connectors, and click Generate LogicScript. Netridium writes the script against those connectors’ real tools and checks it the way a run would before its first step: it must compile, and every connector call must name a real tool with real argument names. If the check finds a problem, the script is fixed and checked again, a few times at most.

When the app already has a script, choose Update or Overwrite beside the button. With Update the button reads Generate changes: describe the change (“also skip credit notes”) and the script is edited, not rewritten. With Overwrite a new script is written from the Prompt alone. Either way, Undo in the editor brings the previous script back.

Under the box you see what the script does, in plain English, read from the script itself, and any inputs run(...) needs that were added to the Inputs tab. Nothing is saved. Read the script, save it, and try it with Dry run (§13): passing the check means it is valid LogicScript, not that it reads each connector’s answers the way you expect.

Use live read-only data. Tick this beside the button and Generate goes one step further: after writing the script it runs it once against your connected systems and fixes what that run finds — most often a wrong guess about what a connector hands back. The run reads for real and changes nothing: a step that would change data is checked and not sent, exactly as in a Dry run. It leaves no run in History and asks nobody anything: it stops at the first step that asks a person, at an agent step, or after a handful of AI calls or about a minute, and tells you how far it got. If the script takes inputs, you are asked for values first. It takes longer and costs more than a plain Generate.

A try can make a script run; it cannot tell a script that runs cleanly and says the wrong thing from one that is right. So when the try ran to the end, See what it produced opens the actual result under the box — the report, table or values, from live data. Read it before you save: check a total you know, a date range, a name. Nothing shown there was saved or sent anywhere.

Cancel stops a Generate for real: the work stops within a few seconds, and nothing more is spent on it.


count = 3
rate = 0.0725
name = "Acme"
flags = [True, False]
entry = {"debit": "1200", "credit": "4000", "amount": 125.50}
pair = ("USD", 100)
nothing = None
count += 1
code, amount = pair
You write You get
inv.Balance A field of a record. A field the record does not have reads as None, because source systems leave empty fields out.
rows[0], rows[-1], accounts[cls.category] An item or a key. An index out of range, or a key that is not there, is an error; use d.get(key) for a key that may be missing.
rows[0:10], rows[::-1], name[:3] A slice.
[r.Balance for r in rows] A field from every row. (rows.Balance is an error: a list has no fields.)

An f-string puts values into text: f"Invoice {inv.DocNumber}". A plain string is never a template, so "{3}" is those three characters.

A field may carry a number format, and only that:

You write You get
f"{n:,}" 1,234,567
f"{x:.2f}", f"{x:,.0f}" 1234.57, 1,235
f"{rate:.1%}" 45.7%
f"{n:04d}" 0007
f"{x:10.2f}" Right-aligned in ten characters.

It rounds half up, exactly as round() does. Alignment, other types and conversions ({x:>10}, {x:e}, {x!r}, {x=}) are not supported, and a format needs a number: use format_money(x) for money.

There is one number type and it is exact: 0.1 + 0.2 == 0.3, 7 / 2 is 3.5, 7 // 2 is 3, 2 ** 10 is 1024. int(x) drops the fraction; float(x) reads a number from text.

Operators never convert for you. This is the rule that matters most over connector data, because many systems send amounts as text:

Expression Result
"1" + "2" "12"
"1" + 2 An error. Write float("1") + 2, or "1" + str(2).
None + 1 An error. Check for None first.
"10" < 9 An error. Convert the text with float(…).
"1" == 1 False

So sum(r.Balance for r in rows) works when Balance arrives as a number, and stops with an error naming the line when it arrives as text. The fix is sum(float(r.Balance) for r in rows).

round() rounds half up. round(2.5) is 3 and round(0.125, 2) is 0.13 — what a spreadsheet and your accounting system do. This differs from Python itself, which rounds half to even.

money is an amount with its currency:

fee = money("12.50") # US dollars unless you say otherwise
vat = money("4.20", currency="EUR")
total = sum(money(r.Balance) for r in rows) + fee * 2
share = total / 4 # still money
ratio = total / fee # money / money is a plain number
label = format_money(total) # "$1,234.50"

Adding or comparing two currencies is an error, never a conversion, and so is multiplying money by money. x.amount and x.currency read the two parts. In a connector call or the result, money is sent as its amount.

Form Example
Comparison total > 10000, status == "open", a != b, 0 < n <= 10
Missing value x is None, x is not None
Membership id in ids, tier not in ("a", "b"), "refund" in memo, key in record
Logic and, or, not
Either-or "late" if days > 30 else "current"

As in Python, and and or give back one of their operands, so name or "unknown" is a default. Empty text, an empty list, 0 and None are false.

<, <=, > and >= compare two numbers, two texts, or two amounts of money. Dates are text in YYYY-MM-DD form, which compares correctly. Comparing with None is an error, so guard a field that may be missing: if inv.DueDate and inv.DueDate < today():.

A condition must be something the runtime can evaluate. Judgement such as “the invoice looks like a duplicate” belongs in an ask.

if total > 10000:
tier = "large"
elif total > 1000:
tier = "medium"
else:
tier = "small"
for inv in invoices:
if inv.Balance == 0:
continue
if inv.Balance > 1000000:
break
log(f"Open: {inv.DocNumber}", balance=inv.Balance)
for i, inv in enumerate(invoices, start=1):
log(f"{i}. {inv.DocNumber}")
Statement Effect
assert cond, "message" Stop the run with the message when the condition is false.
return value Finish the function with a value. From run, this is the deliverable.
continue / break Go to the next item of the enclosing for / leave the loop.
log("…", name=value) Add a line to the run’s log, with any named values after it.

Use assert to refuse a bad input or an impossible state: the run fails with your message, which is different from finishing with nothing. Unlike Python, an assert is never switched off.

A for walks a list, a tuple, the keys of a dict, or the characters of text. Walking None is an error; write for x in (rows or []): when a list may be missing.

overdue = [i for i in invoices if i.DueDate < today()]
names = [c.DisplayName.strip() for c in customers]
total = sum(i.Balance for i in invoices)
late_first = sorted(invoices, key=lambda i: i.DueDate)
largest = max(invoices, key=lambda i: i.Balance)
by_id = {i.Id: i for i in invoices}
pairs = [(c.Id, i.Id) for c in customers for i in invoices if i.CustomerRef == c.Id]
out = []
for inv in overdue:
out.append({"invoice": inv.DocNumber, "days": days_between(inv.DueDate, today())})

lambda is allowed only as key=, on sorted, min, max, group_by, index_by, count_by and a list’s sort.

Group Functions
Python’s len, sum, min, max, sorted, range, enumerate, zip, any, all, str, int, float, bool, list, round, abs
Numbers floor, ceil, to_number(x)
Lists and dicts unique(items); group_by(items, "field"), index_by(items, "field"), count_by(items, "field") — each of these three also takes key=lambda x: …; and merge(a, b, …)
Text re_search(pattern, text), re_sub(pattern, replacement, text), similarity(a, b), html_escape(text)
Dates today(), run_started_at(), date(text), date_add(d, days=, months=, years=), days_between(a, b), months_between(a, b), month_start(d), month_end(d), year(d), month(d), format_date(d, "%b %Y")
Money money(amount, currency=), format_money(x)
Types isinstance(x, dict) — dict, list, str, int, float, bool, or several: isinstance(x, (int, float))
Connector answers extract_rows(response) (§5)

A few of these need a sentence each:

  • to_number(x) reads a number from what a system exported — "1,200", "$1,200.50", "(45.00)" — and gives None for anything that is not one. It is what you write where Python would use try: float(x).
  • count_by gives a dict of how many items share each key. merge gives a new dict with every key of each, the later ones winning; it changes none of them.
  • re_search gives the first match as text — the part in (…) when the pattern has a group — or None. re_sub replaces every match with plain text. The pattern is written out as a plain string (§8, Patterns); it can never be built from a value.
  • similarity(a, b) is how alike two texts are, from 0 to 1 — the same number as Python’s difflib.SequenceMatcher(None, a, b).ratio().
  • isinstance has one number type behind it: int is true of a whole number, float of any number. As in Python, True counts as an int.
  • format_date takes %Y %y %m %d %b %B. A date is always text, YYYY-MM-DD; months_between counts calendar months and ignores the day. month_start() and month_end() with no argument mean this month.
  • The usual optional arguments work: sorted(xs, reverse=True), min(xs, default=0), max(xs, default=0), sum(xs, start), d.get(key, default), text.strip(chars) and format_money(x, currency=).

Text, lists and dicts have Python’s everyday methods, and no others:

On Methods
Text lower, upper, strip, lstrip, rstrip, startswith, endswith, split, join, replace, find, isdigit
A list append, extend, index, count, sort
A dict get, keys, values, items, setdefault

split takes Python’s second argument (email.split("@", 1)). sort takes key= and reverse= and sorts the list where it stands; setdefault gives back what the dict holds under the key, so groups.setdefault(k, []).append(x) builds a dict of lists.

A method that is not in the table is an error when you save, with what to write instead: xs = xs[:-1] for xs.pop(), an f-string for .format().

today() and run_started_at() are read once, when the run starts. A run that pauses for a person and continues the next day still sees the date it started on, so a comparison made before the pause and one made after it agree.

Parameters are named settings, each with a default:

class Params(Parameters):
min_confidence: float = field(default=0.85, ge=0, le=1, label="Auto-post threshold")
approver: Role = field(default="ar_manager", label="Approver")
def run(params: Params, invoice_id: str):
return {"threshold": params.min_confidence, "invoice": invoice_id}

params is not an input: run reads it as params.min_confidence.

A parameter’s value is its default unless your plan includes LogicScript parameters (below). Declaring them is worth it either way: every tunable value sits in one place at the top of the script, with its range checked when you save.

Availability. Changing a parameter without editing the script is sold per plan. Without it, the Parameters button is not shown and the defaults apply.

A parameter can be changed without touching the script. On the app’s edit page, Parameters shows a form built from this class. A value set there is used from the next run; a field left blank uses the default written here. Use a parameter for anything an admin should be able to tune — a threshold, an approver, an account code — and a constant for anything that should need a reviewed change to the script.

  • Every parameter needs a default.
  • A stored value is checked against the field, exactly like one written in the script: a threshold outside ge/le, or a choice that is not in the Enum, is refused when it is saved.
  • The types a form can set are str, int, float, bool, Enum, Date, DateTime, Role and Money. A list, dict or Shape parameter keeps the value written in the script.
  • When the script changes — and, on a plan with approval, when a new version is approved (§13) — the stored values carry over by name. One that no longer fits — the field was removed, or its range narrowed — is dropped and the default applies. The run says so, and on a plan with approval the approval screen lists it first.
  • A run that is waiting for a person keeps the values it started with.

These are valid Python and are refused, each with an error that says what to write instead:

Not allowed Use instead
import, from The built-in functions (§4). There are no libraries.
while for x in items: or for i in range(n):
raise assert condition, "message"
Methods outside the table above (xs.pop(), s.format()) xs[:-1], an f-string
Changing a module constant inside a function Copy it first: mine = list(ALLOWED)
with, async, await, yield, global, del, decorators —
Classes other than Shape and Parameters, and methods Functions
Recursion, *args, **kwargs, {**d}, sets A for loop, named arguments, merge(d, …), unique(items)
eval, exec, open, getattr, setattr, type, vars, dir, globals, names beginning __ —
A field whose name begins with _ (row._id) row["_id"]
Default values on a def’s parameters, a def inside a def Pass every argument; top-level helpers
a = b = 0, x: int = 3 in a function, :=, for … else, try … else / finally One plain assignment per line; an if after the loop
print, map, filter, reversed, dict(), tuple(), set() log, a comprehension, xs[::-1], a literal
Bitwise operators, is with anything but None, text times a number ("-" * 3) and / or, ==, the text written out

Call a connector’s tool by the connector’s name or type, with named arguments. A connector call is a statement of its own:

customers = QBO.list_customers()
opps = Salesforce.sf_soql_query(soql="SELECT Id, Amount FROM Opportunity")
rows = Maxio_sandbox.advancedBillingListInvoices({"per_page": 50})

A connector is named by an identifier. For a connector whose name has spaces or punctuation, write each run of them as one underscore: Maxio (sandbox) is Maxio_sandbox.

What comes back is the connector’s answer as data: its structured result when it sends one, otherwise JSON, otherwise — for a connector that answers with a written report, as Maxio’s list tools do — the records in that report. extract_rows(response) gives you the rows whatever they were wrapped in: a list as it is; from a dict, its longest list (Maxio’s detail, or {"customers": […]}); and each row that is wrapped in a single key ([{"customer": {…}}, …]) unwrapped. Use it instead of guessing at a key.

A field of a missing record reads as None too, so inv.CustomerRef.name is safe when the invoice has no CustomerRef.

Every connector is called the same way — its own tool, by name. That is true of QuickBooks and Salesforce, which Netridium connects to directly, and of NetSuite, Maxio and HubSpot, which publish their own tools. There is no separate “create a record” syntax: QBO.create(QBO.Invoice, …) is an error that tells you to call the tool.

The call is checked against the connector before anything is sent. When a run starts, Netridium asks each connector what tools it has and what arguments each takes — so the check is never out of date, and it knows a tool the connector added yesterday. Then:

  • A tool the connector does not have stops the run before the first step.

  • So does an argument the tool does not take, or a required one that is missing:

    QBO.create_journal_entry has no argument credit_acount. It takes: debit_account, credit_account, amount, date, memo, doc_number, customer_id, vendor_id.

  • A value of the wrong kind (text where the tool wants a number) is refused when the call is made, as a ConnectorError (§11).

The check goes as deep as the connector’s description does. A tool that takes one whole object — QBO.create_invoice(invoice={…}) — is checked only for being given an object; what is inside it is for the connected system to accept or refuse, and some systems ignore a field they do not recognise instead of refusing it. Prefer a tool with named arguments where there is one, and try a writing script with a dry run first (§13).

def run(invoice_id: str):
inv = QBO.get_invoice(id=invoice_id)
QBO.create_journal_entry(
debit_account="1200",
credit_account="4000",
amount=inv.Invoice.TotalAmt,
memo=inv.Invoice.DocNumber,
customer_id=inv.Invoice.CustomerRef.value,
)

Each account is named by its account number, its exact name, or its Id — never a partial match, and a name that fits two accounts is an error rather than a guess. A line on Accounts Receivable needs customer_id; one on Accounts Payable needs vendor_id. date is YYYY-MM-DD and defaults to today.

query builds a query for QuickBooks Online and Salesforce connectors and gives you the rows:

rows = query(
QBO.Invoice,
select=["Id", "DocNumber", "Balance", "DueDate"],
where=[QBO.Invoice.Balance > 0, QBO.Invoice.CustomerRef == customer_id],
limit=200,
)
  • Field and object names are the connected system’s own.
  • where is a list of comparisons that must all hold. Each compares a field, written Connector.Object.Field, with a value, using ==, !=, <, <=, >, >= or in. in needs a list with at least one item.
  • Name the fields you need in select. Without it QuickBooks returns every field, and Salesforce returns only Id.
  • A value from your script is always quoted and escaped for you, so a value can never change the query’s meaning.
  • You get every row, not the first page. Netridium keeps fetching until the rows run out or limit= is reached. Without a limit, a query that would return more than 50,000 rows fails and asks you to narrow it, rather than handing back part of the data as if it were all of it. A limit can be at most 50,000.
  • QuickBooks has no !=; Netridium applies it after fetching, to the same result.
  • Sort the rows with sorted after the query.
  • For other connectors, call their tools directly.

class RevClass(Shape):
category: Enum["subscription", "usage", "one_time", "credit"]
confidence: float = field(ge=0, le=1)
reason: str = field(max_len=300, required=False)
abstain: bool = False
def run(invoice_id: str):
got = QBO.get_invoice(id=invoice_id)
inv = got.Invoice
try:
cls = ask(
RevClass,
"Classify this invoice by revenue type.",
data={"lines": [line.Description for line in inv.Line]},
model="fast",
min_confidence=0.85,
)
except LowConfidence:
cls = RevClass(category="one_time", confidence=0)
return cls.category
  • x = ask(Shape, prompt, …) binds the result to x. The result must be assigned.
  • The prompt is written out: a string or an f-string, never a variable. That is what lets Netridium keep every value out of the instruction.
  • The AI sees only the docstring, the prompt and the values you pass. It cannot call tools, read other variables, or change what runs next.
  • The answer is validated against the Shape. On a mismatch Netridium sends the errors back and retries, up to retries times.
Argument Meaning Default
data={"name": value} Values for the AI to work with, each under its name. none
model="fast" / "standard" / "deep" Model tier. A script never names a vendor’s model. "standard"
min_confidence=0.85 Fail with LowConfidence below this. The Shape needs a confidence field. none
limit=0.05 A spending cap for this step, in US dollars. It is checked before each attempt or round: once the step has spent this much, it does not start another. The app’s run limits
retries=2 Retries when the answer does not match the Shape (0–5). 2
examples=EXAMPLES A table of examples (below). none

Each of these settings — and steps=, by= and timeout= on the steps that take them — is written out as a literal or read from a parameter (min_confidence=params.min_confidence). It cannot be a variable, so what a step may do is fixed when the script is saved.

Type Notes
str min_len=/max_len= are lengths. pattern=r"…" is a regular expression.
int, float ge=/le= are the lowest and highest values allowed.
bool
Enum["a", "b", "c"] One of the listed values.
Date, DateTime Checked for format.
list[T], dict[str, T] min_len=/max_len= on a list are item counts.
Another Shape’s name A nested record.

A field is required unless it has a default or says required=False:

class Line(Shape):
description: str # required
amount: float = field(ge=0) # required, with a constraint
memo: str = field(max_len=200, required=False) # may be missing
status: Enum["open", "paid"] = "open" # has a default

Two field names have meaning to the runtime:

Field Meaning
confidence Compared with min_confidence=. A missing value counts as 0.
abstain When the AI sets it to true, the step fails with Abstain.

The whole answer is kept in the run log, so a reason field is a good habit: use it for audit, not for logic. Build a record of a Shape yourself by calling it with its fields: RevClass(category="one_time", confidence=0).

confidence is the AI’s own estimate. It is useful for sending doubtful answers to a person. It is not a measured probability, so do not treat 0.9 as “right nine times in ten”.

Numbers. An AI may extract a number, but must not compute one. Netridium warns when an ask Shape has a numeric field named like an amount (total, balance, tax…). Do the arithmetic in code. It also warns about an ask inside a loop that has no limit=.

EXAMPLES = [
("Annual platform license 2026", {"category": "subscription"}),
("API overage, March", {"category": "usage"}),
]

Declare the table at the top of the script and pass examples=EXAMPLES. Each example is checked against the Shape when you save.


Use agent when the path can’t be written in advance: research, a multi-step lookup, drafting from several sources.

class CollectionPlan(Shape):
approach: Enum["reminder", "firm", "escalate", "hold"]
subject: str = field(max_len=120)
body: str = field(max_len=2000)
def run(customer_id: str):
cust = HubSpot.get_contact(id=customer_id)
try:
plan = agent(
CollectionPlan,
"""
Review this customer's overdue invoices and contact history.
Recommend an approach and draft a first email.
""",
data={"customer": cust.name},
tools=[Maxio.get_invoices, HubSpot.get_contact, HubSpot.get_notes],
steps=8,
limit=0.25,
)
except LimitExceeded:
return None
return plan.approach
  • tools= and steps= are required. The agent can call only the tools listed, for at most steps rounds (1 to 40). The list is written out; it cannot be a variable.
  • An agent cannot change data. Every tool in tools= must be a read. Have the agent return what should change, then make the change in code after a validate_in, validate_pattern or approve.
  • The agent finishes by returning its Shape, validated the same way as an ask.
  • The agent can ask the person who started the run a question when it cannot continue without them. The run pauses until they answer (§9). If nobody answers within seven days, the agent carries on without one.

Anything an ask or agent produces is tainted, and so is anything computed from it. Tainted values may be used in conditions, logs, the result, further prompts and connector reads. They may not be passed to a connector write. Netridium rejects such a script when you save it, and checks again while it runs.

Each of these gives back the value, cleared. Assign the result to a plain name (x = …, not row["x"] = …) and use that name:

To clear a value Use
An Enum field of the Shape Nothing. It is bounded by the Shape, so it is clear on arrival.
A value that should be one of a known list x = validate_in(x, allowed)
An id or code with a known format x = validate_pattern(x, r"^INV-[0-9]+$")
Free text a person should read first x = approve(x)
Anything else, on your own authority x = trust(x, reason="…") — logged, and flagged as a warning
class MatchResult(Shape):
account_id: str
def run(lead_id: str):
lead = Salesforce.sf_get_record(sobject="Lead", id=lead_id)
candidates = query(Salesforce.Account, select=["Id", "Name"])
m = ask(MatchResult, "Which existing account does this lead belong to?",
data={"lead": lead, "candidates": candidates})
account = validate_in(m.account_id, [c.Id for c in candidates])
Salesforce.sf_update_record(sobject="Lead", id=lead_id, record={"Account__c": account})

Write m.account_id straight into the update and the script does not save: the error names the line and says a tainted value reaches the write Salesforce.sf_update_record.

Four things to know:

  • Taint follows the value wherever it goes. Into a list you append it to, out of a tuple you unpack, through a helper that changes its argument, and into a dict it was used as a key of. A list that has had AI output added to it is AI output.
  • Clearing is by name. account = validate_in(m.account_id, ids) clears account. m.account_id is exactly as untrusted as it was.
  • validate_in only clears against a trusted list. Checking one AI answer against another proves nothing.
  • An assert is a check, not a clearance. assert x in ids stops the run when it fails, but x stays tainted. Use validate_in.

A failed validate_in or validate_pattern stops the run.

A pattern — in validate_pattern, in a Shape field’s pattern=, and in re_search / re_sub — is a regular expression written out as a plain string, and it matches anywhere in the text unless you anchor it with ^ and $. It is a subset of Python’s re, chosen so that no pattern can run for long, whatever text it meets:

You can use
Literals, . and escaped punctuation INV-, a.c, \$, \.
Classes [A-Z0-9], [^a-z ], \d \w \s and \D \W \S
Anchors ^, $, \b
Groups and alternatives (sq|pp), (?:ab)+
Repeats * + ? {3} {3,8} {2,}, and the lazy forms *? +?

Not available: backreferences (\1), lookahead and lookbehind, named groups, and flags. A pattern is at most 200 characters, a repeat count at most 200, and the text it is matched against at most 10,000 characters — longer text fails the check (in re_search and re_sub it stops the run); it is never matched on its first part.

Three things are stricter than Python, on purpose, because a pattern is how AI output is cleared: $ matches only at the very end (not before a final line break), \d and \w mean the ASCII characters only, and matching is always case-sensitive.


A run can pause and wait for someone. While it waits, nothing is running and nothing is being spent. The run continues from exactly where it stopped as soon as the answer arrives; earlier steps are not repeated.

go = request_confirm(f"Post {len(entries)} accruals totalling {total}?")
vendor = request_choose(vendors, prompt="Which vendor is this bill from?", label="{DisplayName}")
picked = request_choose_many(
overdue,
prompt="Which invoices should get a reminder?",
label="{DocNumber} — {CustomerName} — {Balance}",
by="ar_manager",
timeout="48h",
)
Function The person sees The script gets
request_confirm(prompt) Yes / No True or False
request_choose(items, prompt=) Radio buttons The chosen item
request_choose_many(items, prompt=) Checkboxes The chosen items
request_input(Shape, prompt=) A short form built from the Shape’s fields A record

A form Shape may use bool, Enum, list[Enum[…]], str, int, float and Date fields:

class CloseSignoff(Shape):
period: Date
approved: bool
basis: Enum["accrual", "cash"]
note: str = field(max_len=200, required=False)
def run():
signoff = request_input(CloseSignoff, prompt="Confirm the close for this period.", by="admin")
return signoff

label= is how each option reads. It is a plain string, not an f-string: the names in braces are read from each item, and {item} is the item itself. Without a label, Netridium uses the item’s name, number or id.

Asking someone to choose from an empty list asks nobody: request_choose gives None and request_choose_many gives an empty list.

try:
plan = approve(plan, f"Send this email to {cust.name}?", by="ar_manager", timeout="48h")
except (Rejected, TimedOut):
return None

The approver sees the whole value, can edit its text fields, and approves or rejects. A list of records (or of strings) is shown one card per entry, each with its own editable text, so one approval can cover a batch of drafts. An edit only ever replaces text: it cannot add a field or change a number. The approved value is cleared of taint. Without an except Rejected, a rejection ends the run.

Argument Meaning
by="runner" The person who started the run (the default). For a scheduled run, the schedule’s owner.
by="admin" Any workspace admin.
by="ar_manager" Any member holding the role of that name.
timeout="48h" How long to wait: "90s", "30m", "48h" or "7d". Default 7 days, maximum 30.

A workspace admin can always answer. The question appears in the person’s Inbox and beside the run. A question for the runner is also emailed to them; one for an admin or a role waits in the Inbox. When the time runs out the run follows except TimedOut, or fails saying nobody answered.

What a person types or picks is not tainted. Items they pick from a list keep whatever trust those items already had.


What run returns is the deliverable. It is built by Netridium, not by the AI.

You return You get
A list of records An Excel workbook with one sheet
A dict whose values are lists of records A workbook with one sheet per list
Text, a number, True or False A text result
Any other dict, or an empty list JSON
None, or nothing A short text summary with the run’s log

Set OUTPUT = "excel", "json", "text", "html" or "pdf" at the top of the script to choose explicitly. "excel" needs run to return records (a list, or a dict of lists); "html" and "pdf" need it to return text, which you build in the script. Anything else fails the run and says so. log lines are kept with the result.

A cell holds a value, never an instruction. In a workbook each cell is text, a number, or true/false. A list or dict inside a row is written into its cell as JSON text, and text that begins with = is shown as that text — it is never run as a formula.

An HTML result stands on its own. Style it with CSS in the HTML itself, and draw with inline <svg>. Nothing is loaded from another site when the result is viewed or saved as a PDF — no linked image, stylesheet or font — so a remote logo does not appear and a web font falls back to a system one.

A dict or any other JSON result is shown as a readable view — records as label/value tables, lists of records as tables — with a JSON tab beside it for the exact payload.

Return what a person needs to read, not what a system sent back. A write’s result is the connected system’s whole response: ids, sync tokens, metadata. Pick out the parts that matter and give them plain names; a list of records becomes a grid:

def run(invoice_number: str):
rows = query(QBO.Invoice, where=[QBO.Invoice.DocNumber == invoice_number])
inv = rows[0]
posted = QBO.create_journal_entry(
debit_account="Accounts Receivable (A/R)",
credit_account="Subscription Revenue",
amount=inv.TotalAmt,
memo=inv.DocNumber,
customer_id=inv.CustomerRef.value,
)
entry = posted.JournalEntry
return [{
"Invoice": inv.DocNumber,
"Customer": inv.CustomerRef.name,
"Amount": inv.TotalAmt,
"Journal entry #": entry.Id,
"Date": entry.TxnDate,
}]

In a dry run the write sends nothing, so posted is None and the entry’s number and date read as empty.

Rows returned as a list get a plain layout: a header row, columns sized to fit, every value as it is. When the person asks for more — amounts as currency, a total at the bottom, a fixed column order, more than one table — build the workbook with workbook and sheet:

OUTPUT = "excel"
def run():
rows = query(QBO.Invoice, where=[QBO.Invoice.Balance > 0])
table = []
for inv in rows:
table.append({"Customer": inv.CustomerRef.name, "Due": inv.DueDate, "Balance": inv.Balance})
return workbook(
sheet("Open invoices", table,
formats={"Balance": "currency", "Due": "date"},
totals={"Balance": "sum"},
widths={"Customer": 40}),
)

sheet(name, rows, …) is one sheet: its name, its rows (a list of dicts), and any of these settings, each naming columns by the rows’ own keys:

Setting What it does
columns=["Customer", "Balance"] The columns shown, in this order. Others are left out. Without it: every column, in the rows’ order.
formats={"Balance": "currency"} How a column’s values are shown. The formats are "integer", "number", "currency", "percent", "date", "datetime" and "text"; "currency:EUR" names the currency (three capital letters; "currency" alone is US dollars).
totals={"Balance": "sum"} A totals row under the data. Each total is "sum", "average", "count" (rows with a value), "min" or "max"; the first column without a total says “Total”.
widths={"Customer": 40} A column’s width, from 4 to 100 characters. Without it the column fits its values.
highlight=[{"column": "Balance", "op": ">", "value": 10000, "style": "red"}] Colour the cells that meet a test. Up to 20 rules; below.

workbook(sheet, sheet, …) is the result: up to 50 sheets, each named once. Two sheets — a summary and the detail behind it:

OUTPUT = "excel"
def run():
bills = query(QBO.Bill, where=[QBO.Bill.Balance > 0])
owed = {}
detail = []
for b in bills:
name = b.VendorRef.name
owed[name] = owed.get(name, 0) + b.Balance
detail.append({"Vendor": name, "Bill": b.DocNumber, "Due": b.DueDate, "Balance": b.Balance})
summary = []
for name, amount in owed.items():
summary.append({"Vendor": name, "Owed": amount})
return workbook(
sheet("By vendor", summary, formats={"Owed": "currency"}, totals={"Owed": "sum"}),
sheet("Bills", detail, formats={"Balance": "currency", "Due": "date"}, totals={"Bill": "count", "Balance": "sum"}),
)
  • A column name must match the rows’ key exactly. A misspelt one stops the run at that line and lists the columns the rows have.
  • A money() column is shown in its own currency without a format. A column mixing currencies cannot have a sum, average, min or max.
  • Dates are text like "2026-10-31". In a "date" column they become real dates in Excel; text that is not a date is shown as it is.
  • "percent" takes a fraction: 0.125 is shown as 12.5%.
  • The totals are the only formulas, and Netridium writes them. A script cannot write a formula: text beginning with = is still shown as text. The totals row is not a data row — it is not counted, stays at the bottom when the result is sorted or filtered in Netridium, and sits below Excel’s filter.
  • A highlight is a test on one column’s cells: "column", "op" (">", ">=", "<", "<=", "==", "!=" or "between", which also takes "value2"), a "value", and a "style": "red", "amber", "green" or "bold". Compare a number with a number and a date with a date ("value": "2026-11-15"); text can only be matched with "==" or "!=", and is matched ignoring case. A blank cell never matches, and the totals row is never highlighted. Number and date highlights stay live in Excel — edit a value and its colour follows; a text match is coloured when the file is made.
OUTPUT = "excel"
def run():
rows = query(QBO.Invoice, where=[QBO.Invoice.Balance > 0])
table = []
for inv in rows:
table.append({"Customer": inv.CustomerRef.name, "Due": inv.DueDate, "Balance": inv.Balance})
return workbook(
sheet("Open invoices", table,
formats={"Balance": "currency", "Due": "date"},
highlight=[
{"column": "Balance", "op": ">", "value": 10000, "style": "red"},
{"column": "Due", "op": "<", "value": today(), "style": "amber"},
]),
)
  • A workbook result holds at most 100,000 cells (rows × columns, all sheets together) — formatted or not; Netridium may set a different limit. A larger result fails the run and says so. Return fewer rows or columns: a person rarely reads past the first few thousand rows.

Wrap a step in try to say what to do for each way it can fail:

try:
cls = ask(RevClass, "Classify this invoice.", data={"lines": lines}, min_confidence=0.8)
except LowConfidence:
return "Needs review"
except (SchemaError, ModelError):
cls = RevClass(category="one_time", confidence=0)
Failure Raised by Cause
SchemaError ask, agent The answer still did not match the Shape after the retries.
LimitExceeded ask, agent A limit= or steps= was reached, or the run has used all the AI calls it is allowed (§15).
ModelError ask, agent The AI provider returned an error or timed out.
Abstain ask, agent The AI set abstain to true.
LowConfidence ask, agent confidence is below min_confidence=.
ConnectorError A connector call, query The connected system returned an error, or the call’s arguments did not fit the tool.
TimedOut request_…, approve Nobody answered in time.
Rejected approve The approver rejected the value.

The rules for a try:

  • It wraps exactly one step. Everything else goes outside it.
  • Each except names one or more of the failures above. A bare except: is not allowed, and neither is except LowConfidence on a step with no min_confidence=.
  • An except body is ordinary code: it can log, assign a fallback, return, continue, break, or take another step.
  • except ConnectorError as e: gives you the failure’s message as text. It may quote what the AI or the other system said, so it is tainted (§8): log it or return it, but it cannot be written to a connector.
  • A fallback value applies to an AI step or a connector call, not to a person’s answer: after TimedOut or Rejected, leave with return, continue or break.
try:
rows = Books.list_invoices(status="open")
except ConnectorError as e:
log(f"Books is unavailable: {e}")
rows = []

With no matching except, the run fails and says which step.

Run limits are not a failure you can handle. When the app’s max cost, max duration or max updates is reached, the run stops. No except can catch it.

Neither is an interrupted write. except ConnectorError catches a system that answered “no”. It does not catch a write that was cut off before any answer came back: nobody knows whether that change was made, so the run stops and tells you to check (§12).


  • Read-only by default. A LogicScript can only read until writes are deliberately enabled.
  • Writes take three gates, all off by default: Allow updates on the App, Allow Updates on the connector, and — for Xero, QuickBooks and Salesforce — a read-write connection.
  • Taint is a fourth gate, in the language. Even with the three open, AI output cannot reach a write until it is cleared (§8).
  • A script that writes must have Allow updates on. Saving a LogicScript that calls a tool the connected system describes as changing data, with Allow updates off, is refused, and the message names the line.
  • An updating run is always bounded. It is held to a max cost, a max duration and a max updates — the most changes one run may make. A script cannot catch the error when it reaches one, and changes already made stay made. The workspace does not need its own Anthropic key to write.
  • A write is never sent twice. Netridium records each write before sending it. If a run is interrupted while a write is in flight, it does not resend: the run stops and tells you to check the connected system. QuickBooks writes also carry a key that is the same for every attempt at one step, so a request QuickBooks receives twice is posted once.
  • Every write-enabled run confirms before it starts, and every write is audited.

13. Trying a script without changing anything

Section titled “13. Trying a script without changing anything”

Run runs the saved script for real. Dry run, beside it on the app’s edit page, runs the same script read-only so you can see what it would do first. It is for workspace admins. (On a plan with versions and approval it is named Test draft — see below.)

A dry run reads for real — connectors, the AI, questions to people — and does not make any change. When it reaches a step that would change data it:

  1. checks the value is allowed to be written (§8) and that the arguments fit the tool, exactly as a real run would;
  2. records what it would have sent, and sends nothing;
  3. carries on, with None where the step’s result would have been.

The result of a dry run lists each of those steps with its arguments, above whatever the script returned. If a real run would be refused the write — Allow updates is off on the app or the connector — it says that too. History marks the run “Dry run”.

Because an unsent step gives the script nothing back, anything after it that depends on its result (if posted.Id:) may go differently in a real run. The list shows what would have been sent up to that point.

A dry run is the only way to try a script that writes without changing a record. Use it before the first real run, and after any edit to a write.

Availability. Sold per plan. Without it, none of this applies: the saved script is what runs, and the edit page shows Run and Dry run.

The text you save is a draft. What your users and your schedules run is the latest version a workspace admin approved: a copy of the script, frozen at the moment of approval and never changed afterwards.

You do What happens
Save an edit The draft changes. Users and schedules keep running the approved version.
Test draft The dry run above, under the name it has on this plan. It takes the place of Run while the saved draft is not the approved version, and runs that draft read-only.
Approve… Shows what the script reads, what it can change, and how many AI and human steps it has. Approving freezes it as the next version, which runs from the next run on.
Versions Lists every version, who approved it and when. Open one to read it, or restore it to the draft.

Things to know:

  • Nothing runs until it is approved. A new LogicScript — one you wrote, imported, or received from your service provider’s catalog — cannot be run by anyone until an admin approves it. Its Run button says so.
  • A provider’s update is a draft too. When your service provider publishes a new version of a managed app, you keep running the one you approved until an admin approves the new one.
  • Any workspace admin can approve, including the person who wrote the script. Each approval is recorded in the audit log.
  • Turning on Allow updates needs a new approval. A version approved while Allow updates was off was approved as a report. Before it may change records, an admin approves it again, and the approval screen says it can write.
  • A run finishes on the version it started with. Approving a new version while a run waits for someone’s answer does not change that run.
  • Approval covers the script. Connectors, Inputs, Run limits and Parameters (§4) are the app’s settings and take effect when saved.
  • Turning the plan option on stops unapproved scripts. A LogicScript that was created before your plan had approval has no approved version, and will not run for users or schedules until an admin approves it.

A — Revenue classification with a proposed journal entry

Section titled “A — Revenue classification with a proposed journal entry”

The AI classifies; code computes the entry. The amount never passes through the AI, and the only AI value used to build the entry is an Enum.

"""You classify subscription billing invoices under US GAAP."""
AR = "1200"
REV_ACCOUNTS = {"subscription": "4000", "usage": "4010", "one_time": "4100", "credit": "4900"}
class RevClass(Shape):
category: Enum["subscription", "usage", "one_time", "credit"]
confidence: float = field(ge=0, le=1)
reason: str = field(max_len=300, required=False)
def run(invoice_id: str):
got = QBO.get_invoice(id=invoice_id)
inv = got.Invoice
total = sum(line.Amount for line in inv.Line)
assert total > 0, f"Invoice {inv.DocNumber} has no lines"
cls = ask(
RevClass,
"Classify this invoice by revenue type.",
data={"lines": [line.Description for line in inv.Line]},
model="fast",
min_confidence=0.85,
)
entry = {"debit": AR, "credit": REV_ACCOUNTS[cls.category], "amount": total, "memo": inv.DocNumber}
# To post it, call your accounting connector's journal-entry tool here. The
# entry is built only from code and an Enum, so it may be written.
return entry

With no except LowConfidence, an answer below the threshold fails the run, and the invoice is classified by hand.

B — Collections, with a person choosing and approving

Section titled “B — Collections, with a person choosing and approving”
class Reminder(Shape):
subject: str = field(max_len=120)
body: str = field(max_len=1500)
def run(min_balance: float):
overdue = query(
QBO.Invoice,
select=["Id", "DocNumber", "Balance", "DueDate", "CustomerRef"],
where=[QBO.Invoice.Balance > 0],
)
late = [i for i in overdue if i.DueDate < today() and i.Balance >= min_balance]
if not late:
return "Nothing is overdue."
picked = request_choose_many(
late,
prompt="Which invoices should get a reminder?",
label="{DocNumber} — {Balance} — due {DueDate}",
)
drafts = []
for inv in picked:
try:
r = ask(Reminder, "Write a short, polite payment reminder.", data={"invoice": inv},
model="fast", limit=0.05)
except ModelError:
continue
drafts.append({"invoice": inv.DocNumber, "subject": r.subject, "body": r.body})
try:
drafts = approve(drafts, "Approve these reminders before they are returned for sending.",
by="ar_manager", timeout="48h")
except (Rejected, TimedOut):
return None
return drafts
TASKS = [
{"name": "Bank accounts reconciled"},
{"name": "Accruals posted"},
{"name": "Intercompany eliminated"},
]
def run(period: str):
done = request_choose_many(
TASKS,
prompt=f"Close for {period}: tick what is complete.",
label="{name}",
by="controller",
)
outstanding = [t for t in TASKS if t not in done]
if not outstanding:
return f"Close for {period} is complete."
return {"Complete": done, "Outstanding": outstanding}

D — Coding uncategorized expenses, end to end

Section titled “D — Coding uncategorized expenses, end to end”

The whole governed loop in one script: code reads the ledger, the AI suggests an account, code checks the suggestion against the chart of accounts, a person approves the batch, and only then does code post each entry. The amount and the date always come from QuickBooks; the AI supplies an account name and nothing else. It needs Allow updates, so try it with a Dry run first.

"""You code business expenses to a chart of accounts. Answer with the name of
ONE account, copied exactly from the list you are given. If nothing clearly
fits, abstain rather than guess."""
OUTPUT = "excel"
UNCATEGORIZED = "Uncategorized Expense"
class Coding(Shape):
account: str = field(max_len=80)
confidence: float = field(ge=0, le=1)
reason: str = field(max_len=160, required=False)
abstain: bool = False
class Params(Parameters):
min_confidence: float = field(default=0.8, ge=0, le=1, label="Lowest confidence that is proposed")
approver: Role = field(default="controller", label="Who approves the batch")
def run(params: Params):
accounts = query(QBO.Account, select=["Id", "Name", "AccountType"], limit=1000)
codable = sorted([a.Name for a in accounts if a.AccountType == "Expense" and a.Name != UNCATEGORIZED])
name_by_id = {str(a.Id): a.Name for a in accounts}
start = date_add(month_start(today()), months=-1)
purchases = query(
QBO.Purchase,
where=[QBO.Purchase.TxnDate >= start, QBO.Purchase.TxnDate <= month_end(start)],
limit=5000,
)
# The expense account is on each line, named by its id.
todo = []
for p in purchases:
for line in (p.Line or []):
detail = line.AccountBasedExpenseLineDetail
if not detail or not detail.AccountRef:
continue
if name_by_id.get(str(detail.AccountRef.value)) != UNCATEGORIZED:
continue
todo.append({"Date": p.TxnDate, "Memo": line.Description or "", "Amount": to_number(line.Amount) or 0})
proposals = []
needs_person = []
for row in todo:
try:
c = ask(
Coding,
"Which account should this expense be coded to?",
data={"memo": row["Memo"], "amount": row["Amount"], "accounts": codable},
model="fast",
min_confidence=params.min_confidence,
limit=0.05,
)
except (LowConfidence, Abstain, SchemaError, ModelError, LimitExceeded):
needs_person.append(row)
continue
if c.account not in codable:
needs_person.append(row)
continue
# The name came from the AI. It is accepted only as a member of the chart of accounts.
account = validate_in(c.account, codable)
row["Account"] = account
proposals.append(row)
if not proposals:
return {"Reclassified": [], "Needs a person": needs_person}
try:
approved = approve(
proposals,
f"Reclassify {len(proposals)} uncategorized expense(s)?",
by=params.approver,
timeout="72h",
)
except (Rejected, TimedOut):
return {"Reclassified": [], "Needs a person": needs_person}
done = []
for a in approved:
if a["Account"] not in codable:
a["Status"] = "Not posted: that account is not in the chart of accounts"
done.append(a)
continue
try:
posted = QBO.create_journal_entry(
debit_account=a["Account"],
credit_account=UNCATEGORIZED,
amount=a["Amount"],
date=a["Date"],
memo=f"Reclass {a['Memo']}",
)
except ConnectorError as e:
a["Status"] = f"Not posted: {e}"
done.append(a)
continue
a["Status"] = "Dry run — would be posted" if posted is None else "Posted"
done.append(a)
return {"Reclassified": done, "Needs a person": needs_person}

Three things in it are worth copying:

  • Every way an AI step can fail has somewhere to go. A doubtful or unusable answer puts the expense on the Needs a person sheet; it is never guessed.
  • The approver’s edit is checked again. An approver may change an account, so the script confirms it is still on the chart of accounts before posting.
  • A dry run is reported as one. A write gives back None in a dry run, so posted is None is how a script says “would be posted” instead of “posted”.

  • A run can make at most 50 AI calls and 500 connector calls, in addition to the app’s own run limits. Each page a query fetches is one connector call, and each round of an agent is one AI call.
  • A run can pause for a person at most 200 times.
  • A request can offer at most 500 options. Narrow the list first.
  • A run can hold about 25 MB of fetched data. Ask connectors for the fields and rows you need.
  • A list can hold at most 1,000,000 items and a text 5,000,000 characters. A for walks at most 200,000 items, and range() gives at most 100,000.
  • A script is at most 200,000 characters, and a run keeps 500 log lines.
  • A step may sit inside at most two nested loops.
  • A script may run about 5,000,000 operations and spend about 60 seconds computing. Time spent waiting for a connector, the AI or a person is not counted. Fetch less, or filter at the connector, rather than in a loop.
  • similarity compares texts of at most 1,000 characters each. Longer text stops the run; shorten it first (name[:1000]).
  • Time spent waiting for a person does not count against the app’s max duration. Max cost and max updates cover the whole run, across pauses.
  • A script with errors cannot be saved. Warnings do not block a save.

These are part of the LogicScript language but not of this runtime yet. Each is refused or flagged with a clear message, never silently ignored. (Connector.create( Connector.Object, …) is not on this list: it is not part of the language. Call the connector’s tool.)

Construct Use instead
route_to(queue, item=) (review queues) request_… or approve so a person decides, or continue
compute("name", …) Do the calculation in the script
cache= Ignored with a warning; every ask calls the AI
Agent write tools Refused when the run starts. Return what should change; write in code

Construct Purpose
"""docstring""", NAME = value, OUTPUT = "excel" Background for the AI, constants, the result’s format.
class X(Shape):, class Params(Parameters): Typed records; settings an admin can change without editing the script, where the plan includes it.
def run(args):, def helper(args): Where the run starts; pure helpers.
x = value, a, b = pair, if / elif / else, for … in, continue, break, return Logic.
[x for x in xs if …], {k: v for …}, a if c else b Building values.
text.strip(), xs.append(x), d.get(key) The methods on text, lists and dicts (§4).
money("12.50"), float(text), int(x), to_number(text) Money; converting on purpose; a number from exported text, or None.
f"{n:,}", f"{x:,.2f}", f"{rate:.1%}" A number written out.
rows = extract_rows(response) The rows in a connector’s answer, however it wrapped them.
re_search(r"…", text), re_sub(r"…", "", text), similarity(a, b) Find, replace and compare text.
assert cond, "message", log("…", name=value) Stop on a bad state; write to the run log.
Connector.tool(field=value) Call a connector. Checked against the connector’s own tool list before it is sent.
rows = query(Connector.Object, select=, where=, limit=) Query QuickBooks or Salesforce; every row, not one page.
x = ask(Shape, "prompt", data=, model=, min_confidence=, limit=) One typed AI call.
x = agent(Shape, "prompt", tools=[…], steps=N) A bounded AI loop over read-only tools.
x = request_confirm(…), request_choose(…), request_choose_many(…), request_input(…) Ask a person.
x = approve(x, by=, timeout=) A person approves, edits or rejects.
x = validate_in(x, allowed), x = validate_pattern(x, r"…") Clear taint by checking the value.
x = trust(x, reason="…") Clear taint by assertion (logged).
try: … except Failure as e: Handle a failed AI step, a connector error or an unanswered question.

  • Admin Guide — the App types, connector setup, the pre-run input form, roles, run limits and budgets.
  • User Guide — for the people who run what you build, including the Inbox.