Automations
Satia's Python execution engine runs business logic, external API integrations, conversational channel bridges, and custom agent tools inside an isolated cloud sandbox that automatically enforces the active Workspace's security boundary (EffectiveBoundary).
1. Architecture & the 3 Script Modes in Satia
1.1 The Data Hub Rule (2-Node Flows)
Satia is the company's System of Record (Data Hub). Every automation must have Satia's business tables as one of its two endpoints:
- Inbound (
External System ──► Satia DB): An external webhook or scheduled cron job queries an external API and creates/updates records in a Satia business table. - Outbound (
Satia DB ──► External System): A record change in a Satia table (event) or a periodic sweep (cron) pushes data to an external system or notifies a customer. - Internal (
Satia DB ──► Satia DB): Scheduled processing over the business's own tables (closings, audits, reminders).
Golden Rule of Short Flows: Always design 2-node flows (
Source ──► Destination), never monolithic chains across multiple external platforms without passing through Satia. If the user asks to "connect Siigo with Slack", build two separate automations coupled through a Satia business table (Siigo ──► SatiaandSatia ──► Slack). This guarantees full traceability, isolated retries, and complete auditability.
1.2 The 3 Python Execution Modes
| Mode | Tool / Table | When to Use | Entrypoint (entrypoint) |
|---|---|---|---|
| 1. Ephemeral Script (One-Off) | python_executor__execute_script(code, description) | Live one-off calculations, complex data transformations, or ad-hoc API queries during a conversation. Not saved in the database. | Sequential top-level code or def main(): |
| 2. Persistent Automation | Table automation_scripts (test with automation__test_script and save with automation__save_script) | Permanent background processes triggered by table changes (event), schedules (cron), incoming HTTP requests (webhook), or manual execution (manual). | Top-level code, def run(event_data):, def main(event_data):, or on_inbound / on_outbound (for channel bridges) |
| 3. Agent Custom Tool | Table agent_tools (type = 'custom', column script_code) | When a transactional agent needs to read from or write to an external API in real time during a customer conversation. | Required: def run(event_data): and must return {...} (a JSON-serializable dictionary) |
1.3 Sandbox Execution Environment Rules
- Automatic Entrypoints: NEVER write
if __name__ == "__main__":at the end of a script. Satia's engine executes the file and automatically invokesrun(event_data),main(event_data),on_inbound(event_data), oron_outbound(event_data)if defined (and prevents double execution if you already called them at the bottom). - Pre-installed Allowed Libraries:
satia_sdk,requests,pandas,numpy,datetime,json,math,pytz,re,uuid,hashlib,hmac,base64,urllib.parse,google-api-python-client. - Isolation & Security:
- The sandbox blocks reading host filesystem files or internal host environment variables and operates exclusively through
satia_sdk. - If a script accidentally prints the value of a secret configured in
automation_secretsusingprint(), the runner automatically redacts it in the logs as[REDACTED_SECRET]. - Truncated placeholders are strictly forbidden: Never copy truncated text from conversation history (
[AGED_ARG...]or<truncated ... chars>) into thecodeparameter.
- The sandbox blocks reading host filesystem files or internal host environment variables and operates exclusively through
2. Mandatory Development Workflow (Sandbox TDD) & Secret Management
- Design and build a realistic
mock_context:- Prepare a JSON dictionary that accurately simulates the exact
event_datastructure the script will receive in production.
- Prepare a JSON dictionary that accurately simulates the exact
- Always run
automation__test_script(code=..., mock_context=...)before saving:- Strict rule: Never call
automation__save_scriptor insert/updateagent_tools.script_codewithout first runningautomation__test_scriptand verifyingsuccess: truein the results. - If the script is a Custom Channel Bridge (
on_inbound/on_outbound), test both directions:- Inbound:
mock_context={"_direction": "inbound", "payload": {...}} - Outbound:
mock_context={"_direction": "outbound", "message_to_send": "Hola", "contact": {"id": "...", "platform_identifiers": {...}}}
- Inbound:
- Strict rule: Never call
- Secret Management & Automations Panel (
/automations):- NEVER ask the user to paste API keys, tokens, or passwords in the chat.
- When saving with
automation__save_script, declare required credentials inrequired_secrets(or create them withautomations__request_secret_key):[ { "key": "SIIGO_ACCESS_KEY", "description": "API access key generated in Siigo Cloud > Alliances > API Credentials." } ] automation__save_scriptautomatically creates the empty secret slots inautomation_secrets.- Where the user manages automations and secrets (
/automations): Always tell the user that the Automations (/automations) page in the web platform is where they can:- Securely enter and save the secrets / API keys required by each automation.
- View general information for each automation, inspect its detailed execution history (
script_executions) and logs, and activate or deactivate (is_active) any automation with a single click.
- To check which secrets already exist in the Workspace without exposing their values, run:
(The
SELECT id, key, description, value FROM automation_secretsvaluecolumn never returns the plaintext secret; the engine returns[CONFIGURED]if already filled by the user or[EMPTY - PENDING USER INPUT]if still empty, and you must never filter or aliasvaluein SQL).
3. Complete satia_sdk Reference
Every script has the event_data dictionary and satia_sdk modules pre-injected globally:
import satia_sdk
from satia_sdk import db, http, ai, agents, channels, templates, tasks, browser
# Note: `satia_sdk.database` also exists as an alias for `satia_sdk.db`.
A. Database (satia_sdk.db)
Operates directly on business tables by their slug (e.g., 'pedidos', 'clientes', 'productos', 'citas') and on authorized system tables (knowledge_docs, knowledge_folders, schedules, schedule_rules, event_items, agent_tasks, etc.).
- Automatic flattening on reads: All business table columns are returned directly at the root of each returned dictionary (
row["estado"],row["total"],row["starts_at"]). - Automatic grouping on writes: In
db.insertanddb.update, pass column names directly as keyword arguments (**kwargs).
1. db.query(sql, params=None) -> list[dict]
Executes a standard SQL SELECT query against business table slugs or system tables.
- Supports positional parameters (
%spassingparams=[val1, val2]) or named parameters (:nombrepassingparams={"nombre": val}), automatically escaping single quotes:
pedidos = db.query(
"""
SELECT p.id, p.name, p.total, p.estado, p.starts_at, c.name AS cliente_nombre
FROM pedidos p
LEFT JOIN clientes c ON c.id = (p.cliente_id->>0)::uuid
WHERE p.estado = :estado AND p.starts_at >= :desde
ORDER BY p.starts_at DESC
LIMIT 20
""",
params={"estado": "pagado", "desde": "2026-10-01T00:00:00Z"}
)
2. db.get(table_slug, id) -> dict | None
Fetches a single record by its table slug and UUID (or None if not found):
cliente = db.get("clientes", "UUID-DEL-CLIENTE")
if cliente:
print(cliente.get("name"), cliente.get("phone"))
3. db.filter(table_slug, **kwargs) -> list[dict]
Filters records of a table by exact equality (=) on one or more columns:
ventas_pagadas = db.filter("pedidos", estado="pagado", metodo_pago="transferencia")
4. db.find_entity(table_slug, filters, case_insensitive=True) -> list[dict]
Searches records using a filter dictionary. When case_insensitive=True (default), compares text columns using ILIKE:
contactos = db.find_entity("clientes", {"email": "juan@empresa.com", "ciudad": "Bogotá"})
5. db.insert(table_slug, **kwargs) -> Response
Inserts a new record by passing its columns as **kwargs.
- Returns the RPC response object whose
.dataattribute contains the list with the created record dictionary (res.data[0]["id"]). - If an event or offering table has a
schedulecolumn,db.insertanddb.updateautomatically synchronize its value with the indexedstarts_at/ends_atcolumns.
res = db.insert(
"pagos",
name="Pago Factura #1042",
starts_at="2026-10-20T14:30:00Z",
monto=150000,
metodo_pago="tarjeta",
pedido_id=["UUID-DEL-PEDIDO"] # 'relation' columns always take a list of UUIDs
)
nuevo_pago_id = res.data[0]["id"] if getattr(res, "data", None) else None
6. db.update(table_slug, id, **kwargs) -> Response
Surgically updates only the columns passed in **kwargs (performing an automatic merge without losing other fields on the record).
- To clear/remove a column's value: Pass
campo=None(e.g.,db.update("clientes", cliente_id, nota_temporal=None)). - Optional full replacement: Pass
replace_attributes=Trueonly if you want to completely overwrite all dynamic attributes of the record. - Native anti-loop protection: If all values passed to
db.updateare already identical to the current values stored in the database, the SDK silently skips the write (UPDATE_SKIPPED) to avoid firing redundantupdateevents.
db.update("pedidos", "UUID-DEL-PEDIDO", estado="facturado", factura_id="FE-9012")
7. db.delete(table_slug, id, reason=None) -> Response
Deletes a record by its UUID (performing safe or cascading deletion as appropriate):
db.delete("citas", "UUID-DE-LA-CITA", reason="Cancelada desde sistema externo")
8. db.execute(table_name, action, data, match=None) -> Response
Direct DML method (action in 'insert', 'update', 'delete'), useful when working with dynamically constructed dictionaries or system tables (knowledge_docs, schedule_rules, event_items):
# Add a new log entry ('entry') to a logbook or medical record ('book') in Satia Docs:
db.execute("knowledge_docs", "insert", {
"parent_id": "UUID-DEL-LIBRO-PADRE",
"doc_type": "entry",
"title": "Control Post-Operatorio",
"content": "Paciente presenta evolución favorable..."
})
9. Defensive db Helpers (db.get_attr & db.normalize)
db.get_attr(obj, key, default=None): In Satia,relationcolumns are always stored as a list of UUIDs (["uuid-1"]), even for 1-to-1 relationships. Passing a list directly todb.get("clientes", event_data.get("cliente_id"))will fail. Always usedb.get_attr:# Automatically extracts the first UUID if 'cliente_id' comes as a list ['uuid-1']: cliente_id = db.get_attr(event_data, "cliente_id") if cliente_id: cliente = db.get("clientes", cliente_id)db.normalize(text): Converts any value to a lowercase, whitespace-trimmed string (""ifNone) for safe comparisons:if db.normalize(event_data.get("metodo_pago")) == "transferencia": ...
10. Handling Special Columns (media, line_items, schedule) in Scripts
mediaColumns:- For columns with
is_multiple: false, the value is aMediaItemdict:{"url": "https://...", "name": "recibo.jpg", "type": "image"|"video"|"audio"|"document", "caption": "..."}. - For columns with
is_multiple: true, it is a list of dicts[MediaItem, ...]. - To send the file to an external API (Outbound), extract
.get("url"). To store an external file in Satia (Inbound), pass a valid dictionary containing at least"url"and"type".
- For columns with
line_itemsColumns (event_itemsTable):- Cart/order line items live in the
event_itemstable (event_id,offering_id,attribute_key,name,unit_price,quantity,total_price,attributes), never as inline JSON inside the parent event row.
- Cart/order line items live in the
- Dates & Timezones (
starts_at/SATIA_TIMEZONE):- The database stores all timestamps in UTC (
starts_at,ends_at,created_at). When building time filters in SQL, usedatetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")(always with theZsuffix). - To display a human-readable date or time to the end user, always convert from UTC to the business's timezone using the pre-injected
SATIA_TIMEZONEenvironment variable withpytz(never use hardcodedtimedelta(hours=...)):import os, pytz from datetime import datetime tz = pytz.timezone(os.environ.get("SATIA_TIMEZONE", "UTC")) dt_utc = datetime.fromisoformat(row["starts_at"].replace("Z", "+00:00")) dt_local = dt_utc.astimezone(tz) hora_legible = dt_local.strftime("%Y-%m-%d %I:%M %p")
- The database stores all timestamps in UTC (
B. External HTTP Connections (satia_sdk.http)
Provides the 5 standard HTTP methods:
http.get(url, headers=None, params=None, connector_key=None, connection_id=None, contact_id=None)http.post(url, json=None, data=None, headers=None, params=None, connector_key=None, connection_id=None, contact_id=None)http.put(url, json=None, data=None, headers=None, params=None, connector_key=None, connection_id=None, contact_id=None)http.patch(url, json=None, data=None, headers=None, params=None, connector_key=None, connection_id=None, contact_id=None)http.delete(url, headers=None, params=None, connector_key=None, connection_id=None, contact_id=None)
All methods return a standard requests.Response object (.status_code, .json(), .text, .raise_for_status()).
Mode 1: APIs with Static Credentials (automation_secrets -> os.environ)
For services using static API Keys, Basic Auth, or Bearer Tokens (e.g., Stripe, Siigo, SendGrid, Alegra, Wompi):
import os
from satia_sdk import http
api_key = os.environ.get("STRIPE_SECRET_KEY")
if not api_key:
raise ValueError("Missing STRIPE_SECRET_KEY configuration on the Automations page (/automations).")
res = http.post(
"https://api.stripe.com/v1/payment_intents",
headers={"Authorization": f"Bearer {api_key}"},
data={"amount": 50000, "currency": "usd"}
)
res.raise_for_status()
data = res.json()
Mode 2: Dynamic OAuth 2.0 Connectors (connector_key / oauth_connections)
Satia automatically manages OAuth 2.0 tokens (refresh_token renewal and injection via Pipedream proxy or direct Bearer header) for active connectors in integration_connectors.
- Mandatory pre-check: Before coding against a connector, verify its existence, authentication type, and official documentation in the database:
And check whether the business or team member already has an active connected account:
SELECT key, name, auth_type, documentation FROM integration_connectors WHERE is_active = trueSELECT id, connector_key, contact_id, account_email, expires_at FROM oauth_connections - Authorization Magic Link (If not connected):
- If the connector has
auth_type = 'oauth2'and does not appear inoauth_connections, there is no "Integrations" screen in Settings. Provide the user with the direct Markdown link:[Connect <Connector Name>](/api/oauth/<connector_key>/authorize)
- If the connector has
- Rules when calling
http.*with OAuth:- NEVER look up the OAuth token in
os.environor manually build theAuthorizationheader. - Pass only
connector_key="<key>". - Per-Employee/Resource Connections (
contact_idorconnection_id): If the business has calendars or OAuth accounts linked to individual staff members (e.g., a specific doctor's calendar), passcontact_id="UUID-DEL-EMPLEADO"(orconnection_id="UUID-DE-LA-CONEXION"). If you omitcontact_id, the SDK uses the company-wide connection (contact_id IS NULL).
- NEVER look up the OAuth token in
from satia_sdk import http
res = http.post(
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
json={
"summary": "Consulta Odontológica",
"start": {"dateTime": "2026-10-21T15:00:00Z"},
"end": {"dateTime": "2026-10-21T15:30:00Z"}
},
connector_key="google_calendar",
contact_id="UUID-DEL-DOCTOR" # Optional: uses that specialist's OAuth account
)
C. Artificial Intelligence (satia_sdk.ai)
Runs native AI inference inside the script (summaries, structured extraction, text classification) while automatically tracking token usage in script_executions:
from satia_sdk import ai
clasificacion = ai.generate(
prompt=f"Classify the sentiment of this review as POSITIVO, NEUTRO, or NEGATIVO:\n{event_data.get('comentario')}"
)
D. Agent Orchestration (satia_sdk.agents)
Wakes up a transactional agent to start or continue a conversation with a specific contact.
- WhatsApp Restriction:
agents.trigger_conversationsends free-form text generated by the agent, so on WhatsApp channels it only works if the contact has messaged within the last 24 hours. On Telegram, Web, or open-window channels, it works at any time.
from satia_sdk import agents
agents.trigger_conversation(
contact_id="UUID-DEL-CONTACTO",
instruction="Confirm to the customer that their payment was approved and ask whether they prefer in-store pickup or home delivery.",
agent_id="UUID-DEL-AGENTE", # Optional if the script already has an assigned agent_id
context={"pedido_id": event_data.get("id"), "total": event_data.get("total")}
)
E. WhatsApp Templates (satia_sdk.templates)
To contact users over WhatsApp outside the 24-hour service window (first outreach, appointment reminders, payment collection, bulk campaigns), sending Meta-approved templates is mandatory.
channel_idmust always be the WhatsApp channelidfrom thechannelstable (SELECT id FROM channels WHERE channel_type = 'whatsapp'), never aconversationsid.
from satia_sdk import templates
# 1. List approved templates on the channel
aprobadas = templates.list(channel_id="UUID-CANAL-WHATSAPP", status="APPROVED")
# 2. Send a template to a contact (supports positional variables in a list or named variables in a dict, plus media headers)
templates.send(
contact_id="UUID-CONTACTO",
template_name="recordatorio_cita",
language="es",
variables=["María", "Mañana a las 10:00 AM"], # Or dict: {"nombre": "María", "hora": "10:00 AM"}
channel_id="UUID-CANAL-WHATSAPP",
media_url="https://dominio.com/orden.pdf", # Required if the template has an IMAGE, VIDEO, or DOCUMENT header
header_type="document", # Optional: 'image', 'video', 'document' (auto-detected if omitted)
header_filename="Orden.pdf" # Optional for document headers
)
# 3. Optimized bulk sending by batch (Outbox Batch with automatic rate-limit control)
resultado = templates.send_bulk(
contact_ids=["uuid-1", "uuid-2", "uuid-3"],
template_name="promocion_mensual",
language="es",
variables=["20%"],
channel_id="UUID-CANAL-WHATSAPP"
)
# resultado -> {"sent": 3, "failed": 0, "errors": [], "total": 3}
# 4. Create a new template in Meta (remains in PENDING status until approved by Meta)
templates.create(
name="confirmacion_despacho",
category="UTILITY",
body_text="Hola {{1}}, tu pedido {{2}} ha sido despachado.",
language="es",
examples=["Carlos", "PED-104"],
channel_id="UUID-CANAL-WHATSAPP"
)
F. Scheduled Tasks (satia_sdk.tasks)
Schedules a deferred task (agent_tasks) for an agent to execute at an exact future date/time:
from satia_sdk import tasks
tasks.schedule(
instruction="Check whether the customer completed payment for the order and follow up",
scheduled_at="2026-10-21T10:00:00-05:00", # Future ISO 8601 timestamp
contact_id="UUID-CONTACTO", # Optional
agent_id="UUID-AGENTE", # Optional (defaults to the current agent)
context={"pedido_id": event_data.get("id")}
)
G. Headless Browser (satia_sdk.browser)
Controls an AI-assisted Headless Chrome browser to interact with government portals, judicial systems, or legacy ERPs that lack a public API.
- Golden rules:
- Always define
schemato receive a clean, structured JSON dictionary. - Restrict
max_steps(e.g.,15to25) andallowed_domainsto prevent the browser agent from drifting to external search engines. - Wrap the call in
try/exceptand pass credentials fromos.environinsidecontext.
- Always define
import os
from satia_sdk import browser
res = browser.navigate(
instruction="Log in, search for the invoice matching the given NIT, and extract the CUFE and DIAN status.",
url="https://nube.siigo.com/login",
max_steps=15,
timeout=300,
allowed_domains=["nube.siigo.com", "siigo.com"],
context={
"user": os.environ.get("SIIGO_USER"),
"pass": os.environ.get("SIIGO_PASS"),
"nit": event_data.get("nit")
},
schema={
"cufe": {"type": "string", "description": "Unique electronic invoice code (CUFE)"},
"estado_dian": {"type": "string"}
}
)
H. Conversational Channel Bridges (satia_sdk.channels)
Connects any external CRM or messaging platform (e.g., Kommo CRM, Chatwoot, Respond.io, ManyChat, iMessage) as a bidirectional conversational channel (channels.channel_type = 'webhook').
- Strict Rule (
1 Agent = 1 Webhook Channel = 1 Bridge Script): Each agent has its own dedicated channel and its own script with a uniquewebhook_slug. - Do not use
agents.trigger_conversationfor incoming chat messages: Always usechannels.receive_message(...)so the message enters Satia's official pipeline with conversation memory, debouncing, multimodal support, and automatic propagation ofinbound_payloadandturn_metadata. - Engineering guide and complete templates: Consult
discovery__get_manual(topic='webhook_channels')(ortopic='agents_and_channels') before writing or modifying a channel bridge.
from satia_sdk import channels, http
def on_inbound(event_data: dict):
payload = event_data.get("payload", {})
# 1. If the message was sent by a human operator in the external CRM, record the echo and activate handover:
if payload.get("is_human_echo"):
return channels.record_echo(
external_id=payload.get("sender_id"),
message_content=payload.get("text", ""),
activate_handover=True
)
# 2. Ingest the customer message into the Satia agent pipeline:
return channels.receive_message(
external_id=payload.get("sender_id"),
message_content=payload.get("text", ""),
contact_name=payload.get("sender_name"),
phone=payload.get("phone"),
media_url=payload.get("media_url"),
media_mime_type=payload.get("media_mime_type"),
platform_identifiers={"crm_talk_id": payload.get("talk_id")},
turn_metadata={"pipeline_id": payload.get("pipeline_id")}
)
def on_outbound(event_data: dict):
# Automatically invoked when the agent replies
contact = event_data.get("contact") or {}
talk_id = (contact.get("platform_identifiers") or {}).get("crm_talk_id")
http.post(
"https://api.crm-externo.com/v1/messages",
json={
"talk_id": talk_id,
"text": event_data.get("message_to_send"),
"media_url": event_data.get("media_url")
},
connector_key="crm_externo"
)
4. Trigger Types, trigger_config, & Exact event_data Anatomy
When saving with automation__save_script(name, code, trigger_type, trigger_config, ...), configure the trigger according to this table:
trigger_type | Required trigger_config | What the Engine Injects into event_data |
|---|---|---|
event | {"event_table": "<table_slug>", "event_type": "create" | "update" | "delete" | "any"} (Always use the exact business table slug from <database_schema>). | The record dictionary already flattened at the root: event_data["id"], event_data["name"], event_data["starts_at"], and all table columns directly at the root (event_data.get("estado")). On create/update, it flattens the new record; on delete, it flattens the deleted record. Also injects _trigger_table (the slug) and _trigger_operation ("INSERT", "UPDATE", "DELETE"). Never access event_data.get("new_data"). |
cron | {"cron_expression": "0 9 * * 1-5"} (5-field cron expression in the business's local time; automation__save_script automatically converts it to UTC when saving). | {"timestamp": "2026-10-20T14:00:00Z"} |
webhook | {"webhook_slug": "my-unique-slug"} (If it is a conversational channel bridge, also include "agent_id": "<UUID>" and "is_channel_bridge": True). | The incoming JSON fields both at the root of event_data and inside event_data["payload"] and event_data["body"]. Also injects event_data["headers"] (case-insensitive dictionary, e.g., event_data["headers"].get("x-signature")), event_data["query"] (URL query parameters), and event_data["_direction"] ("inbound" or "outbound"). Generated public URL: https://satiawebhook.up.railway.app/webhooks/<webhook_slug>. |
manual | {} | The custom parameters passed when manually executing the automation. |
Anatomy of event_data in a Custom Tool (agent_tools with type = 'custom')
When an agent invokes a custom tool (agent_tools.script_code), the engine executes def run(event_data): and injects:
def run(event_data: dict) -> dict:
tool_args = event_data.get("tool_args", {}) # Arguments passed by the LLM matching input_schema
contact_id = event_data.get("contact_id") # UUID of the contact in the active conversation
conversation_id = event_data.get("conversation_id") # UUID of the active conversation
channel_id = event_data.get("channel_id") # UUID of the channel
agent_id = event_data.get("agent_id") # UUID of the agent that invoked the tool
inbound_payload = event_data.get("inbound_payload") # Original turn payload (on webhook channels)
turn_metadata = event_data.get("turn_metadata") # Metadata for the current turn
...
return {"ok": True, "resultado": "..."}
5. Critical Idempotency & Infinite-Loop Prevention Rules
-
In
eventtriggers (Infinite-Loop & Price Accumulation Protection):- If your script triggers on
event_type = "update"or"any"for a table (e.g.,pedidos) and callsdb.update("pedidos", ...)inside the script, that write will generate a newUPDATEevent in PostgreSQL. - Rule 1 — Mutually Exclusive State Transitions: The initial state that triggers the logic and the final state written by the script must be distinct and must never be combined in the same
ifcondition:estado_actual = event_data.get("estado") # ✅ Only acts when status is 'pagado' and no invoice has been issued yet: if estado_actual == "pagado" and not event_data.get("factura_emitida_id"): factura_id = emitir_factura(event_data) db.update("pedidos", event_data["id"], estado="facturado", factura_emitida_id=factura_id) else: print(f"Skipping execution: estado={estado_actual}") - Rule 2 — Immutable Base Prices vs. Calculated Totals: Never use an accumulated total column (
total) as a fallback for the base price (subtotal) when adding extra charges; always read the immutable base column so retries never duplicate charges (or better yet, delegate column calculations to the Data Modeler via reactive formulas). - Rule 3 — Guard before proactive messages: Before calling
agents.trigger_conversationortemplates.send, verify on the record whether the notification has already been sent.
- If your script triggers on
-
In
crontriggers (Diagnostic Logging + Filter → Process → Mark):- Every
cronscript must log the queried UTC window at the start and the count of processed records at the end, and immediately mark each processed record so the next run does not pick it up again:from datetime import datetime, timedelta now = datetime.utcnow() start_str = (now + timedelta(minutes=25)).strftime("%Y-%m-%dT%H:%M:%SZ") end_str = (now + timedelta(minutes=35)).strftime("%Y-%m-%dT%H:%M:%SZ") print(f"[CRON] UTC Now: {now.isoformat()} | Window: {start_str} -> {end_str}") citas = db.query( "SELECT id, cliente_id, starts_at FROM citas " "WHERE estado = 'programada' AND starts_at >= :ini AND starts_at <= :fin", params={"ini": start_str, "fin": end_str} ) print(f"[CRON] Appointments found: {len(citas)}") for cita in citas: cliente_id = db.get_attr(cita, "cliente_id") if cliente_id: agents.trigger_conversation(contact_id=cliente_id, instruction="Remind the customer about their appointment in 30 minutes.") db.update("citas", cita["id"], estado="recordatorio_enviado")
- Every
-
In
webhooktriggers (External ID Deduplication &[UNIQUE]Columns):- External providers (Stripe, MercadoPago, Typeform, Shopify) retry webhooks on network delays. Before calling
db.insert, always check withdb.filterwhether the external identifier has already been processed:ext_id = event_data.get("id") if ext_id and db.filter("pagos", referencia_externa=str(ext_id)): print(f"Webhook {ext_id} already processed. Ignoring.") else: db.insert("pagos", name=f"Pago {ext_id}", referencia_externa=str(ext_id), monto=event_data.get("amount"))
- External providers (Stripe, MercadoPago, Typeform, Shopify) retry webhooks on network delays. Before calling
6. Inspecting Past Executions & Surgical Code Editing
-
Auditing Execution History (
script_executions&messages.tool_calls):- To diagnose why an automation failed or what it printed, query
script_executionsfiltering byscript_id:SELECT id, status, error_message, execution_time_ms, entities_count, started_at FROM script_executions WHERE script_id = '<UUID-DEL-SCRIPT>' ORDER BY started_at DESC LIMIT 5 - If the
logsof an execution are large, inspect them withdatabase__inspect_text(table_name="script_executions", record_id="<EXEC_UUID>", column_name="logs", search_pattern="ERROR"). - To inspect past runs of ephemeral scripts (
python_executor__execute_script) in chat history, searchmessages.tool_calls(whenrole = 'model',contentisNULLand the code lives insidetool_calls) usingdatabase__inspect_text(table_name="messages", record_id="...", column_name="tool_calls").
- To diagnose why an automation failed or what it printed, query
-
Surgical Editing of Existing Scripts (
database__inspect_text+database__patch_text):- When you need to make a targeted adjustment to an existing script (
automation_scripts.codeoragent_tools.script_code), do not rewrite the entire file from scratch:- Inspect the relevant lines with
database__inspect_text(table_name="automation_scripts", record_id="<ID>", column_name="code", start_line=1, end_line=120). - Test the updated code with
automation__test_script. - Apply the surgical change with
database__patch_text(table_name="automation_scripts", record_id="<ID>", column_name="code", replacements=[{"target_text": "...", "replacement_text": "...", "start_line": ..., "end_line": ...}])(or useautomation__save_script(script_id="<ID>", ...)if you are also updatingtrigger_configorrequired_secrets).
- Inspect the relevant lines with
- When you need to make a targeted adjustment to an existing script (