Agents & channels
Transactional Agents are autonomous AI assistants that represent the business on external customer-facing channels (WhatsApp, Instagram, Facebook Messenger, TikTok, Telegram, Web Widget, and Custom Webhook Bridges). Each agent operates under strict data isolation, column-level security, and time-engine rules defined in system tables.
1. Anatomy of the agents Table & Golden Rules
agents Table Schema
| Column | Type | Required | Description |
|---|---|---|---|
id | uuid | Auto | Unique agent ID |
name | text | Yes | Display name of the agent (e.g., "Sales Advisor", "Technical Support") |
type | text | Yes | Always 'transactional' for customer-facing agents |
category | text | Optional | 'sales', 'customer_service', 'scheduling', 'operations', 'support' |
personality | text | Yes | Core role, mission, and conversational tone (Note: There is NO prompt or instructions column; always use personality) |
default_contact_entity_definition_id | uuid | Yes | Physical column on agents pointing to the entity_definition_id of the primary Contact business table (e.g., clientes, pacientes) this agent serves |
config | jsonb | Optional | Operational settings: {"is_paused": false} |
Golden Rules of Agent Architecture
- 1 Agent = 1 Contact Table (
default_contact_entity_definition_id):- Every transactional agent MUST be anchored to exactly one Contact business table via the physical column
default_contact_entity_definition_idonagents(retrieve the table UUID fromdatabase__describe_tables()). - NEVER store
default_contact_entity_definition_idinside theconfigJSONB column. - When a new person writes on any channel connected to the agent, Satia automatically looks up or creates that person in the contact business table defined by
default_contact_entity_definition_id. - If a business serves two separate interlocutor directories (e.g.,
clientesandproveedores), create 2 distinct agents, each anchored to its respective contact business table.
- Every transactional agent MUST be anchored to exactly one Contact business table via the physical column
- Cognitive Fat Rule (
personalityvsagent_docsvs Entity Manuals):agents.personality: Keep concise and focused strictly on the agent's identity, core mission, conversational style, and high-level workflow rules. When modifying an existingpersonality, always usedatabase__inspect_text+database__patch_text(never overwrite blind).agent_docs(Global Agent Playbooks): Extensive FAQs, return policies, objection-handling playbooks, and pricing rules belong inknowledge_docs(doc_type = 'document') linked to the agent viaagent_docs(agent_id,knowledge_doc_id). Documents inagent_docsare injected automatically into the agent's system prompt on every conversation.- Entity Manuals (
knowledgecolumn on Catalog Tables): Long technical datasheets or per-product manuals belong in aknowledge-type column on the catalog table itself (e.g.,productos). The agent sees a📎 [Attached Manual]indicator in<data_context>and reads the full document on demand viaconsult_entity_manual(record_id)only when a customer asks about that specific item.
2. Table Permissions, Column Security, Isolation & default_filters (agent_tables)
A transactional agent has zero access to business tables unless explicitly granted via a row in agent_tables.
agent_tables Schema
| Column | Type | Required | Description |
|---|---|---|---|
id | uuid | Auto | Unique permission record ID |
agent_id | uuid | Yes | FK -> agents.id |
entity_definition_id | uuid | Yes | Target business table ID (entity_definition_id from database__describe_tables()) |
table_name | text | Yes | Target business table category or slug (e.g., clientes, productos, pedidos, citas) |
permission | text | Yes | Table-level access: 'read', 'write', or 'full' ('write' and 'full' allow create_record and update_record) |
isolation | text | Yes | Row visibility scope: 'auto' (default for customer profile and transactional/appointment tables like clientes, pedidos, citas) or 'none' (default for catalog/inventory tables like productos, servicios) |
column_permissions | jsonb | Optional | Per-column access overrides: {"col_key": "hidden" | "read" | "write"} |
default_filters | jsonb | Optional | Server-side row filters, creation defaults, and pipeline hand-off rules |
agent_instruction | text | Optional | Custom contextual instructions injected right above this table in the agent's <data_context> |
Important: Always verify existing rows in
agent_tableswithdatabase__querybefore inserting to avoid duplicate(agent_id, entity_definition_id)entries.
2.1 Granular Column Security (column_permissions)
column_permissions is a JSONB map ({"attribute_key": "hidden" | "read" | "write"}) that overrides the table-level permission for specific columns:
"hidden": Completely invisible to the agent. The column is stripped from<data_context>, excluded fromquery_recordsandexpand_relationresults, omitted from the agent's schema definition, and blocked oncreate_record/update_record. Use"hidden"for internal costs, margins, supplier notes, or confidential fields."read": Visible to the agent in<data_context>andquery_recordsso it can answer customer questions, but marked(READ-ONLY — Do NOT pass in create_record or update_record)and strictly rejected if the agent attempts to write or modify it. Use"read"for official prices, account balances, membership tiers, or approved credit limits on writable tables."write": Full read and write access (allows modifying specific columns on a table or explicitly granting write access).
2.2 Row Isolation Modes (isolation: 'auto' vs 'none')
The isolation column controls whether the agent only sees data belonging to the current interlocutor (contact_id) or across the entire business table:
| Business Table Role | isolation = 'auto' (Strict Per-Customer Privacy) | isolation = 'none' (Global / Filtered Visibility) |
|---|---|---|
Customer Profile Tables (e.g., clientes, pacientes) | The agent only sees and updates the active interlocutor (id = contact_id) under --- CURRENT CONTACT INFO ---. create_record is strictly blocked on a customer profile table when isolation = 'auto' (the engine raises an error directing the agent to use update_record with record_id="me"). | The agent can read, query, create, and update multiple customer records matching default_filters (ideal for internal coordinator/supervisor agents or B2B referral registration). |
Transactional & Appointment Tables (e.g., pedidos, citas, reservas) | The engine automatically detects the relation column linking the transactional table to the agent's customer table, restricts <data_context> and query_records strictly to records owned by contact_id, auto-injects data[relation_key] = [contact_id] on create_record, and verifies ownership before update_record. If no relation column links the table to the customer table, preloading fails closed ([]). | The agent sees and manages all transactional records matching default_filters across all customers (e.g., an internal operations or dispatch agent). |
Catalog & Inventory Tables (e.g., productos, servicios) | Not used for standard catalogs (unless items are linked to a specific customer). | Standard for all catalogs/services/inventory: The agent sees all catalog items matching default_filters. |
2.3 The default_filters Engine (Operators, Dynamic Tokens & Pipeline Hand-Off)
default_filters is a JSONB dictionary ({"column_key": "operator.value"}) evaluated server-side in common_libs/helpers/filter_engine.py. It works on both physical columns (name, starts_at, ends_at, id, created_at) and dynamic JSONB attributes (attributes->>column_key).
Supported PostgREST Operators (10 Operators)
If no operator prefix is specified, equality (eq) is assumed automatically ({"etapa": "lead"} is identical to {"etapa": "eq.lead"}).
| Operator | Syntax Example | Meaning & Usage |
|---|---|---|
eq (or implicit) | {"estado": "activo"} or {"estado": "eq.activo"} | Exact equality (=). Also auto-injected as a default field value on create_record. |
neq | {"estado": "neq.cancelado"} | Not equal (!=). Excludes matching rows (including rows where the attribute is null/empty). |
gt | {"stock": "gt.0"} | Greater than (>). Supports numeric values and ISO timestamps. |
gte | {"starts_at": "gte.__NOW__"} | Greater than or equal (>=). |
lt | {"precio": "lt.100000"} | Less than (<). |
lte | {"descuento": "lte.20"} | Less than or equal (<=). |
in | {"categoria": "in.[\"calzado\",\"ropa\"]"} or "in.calzado,ropa" | Value is in list (accepts JSON array or comma-separated values). |
cs | {"etiquetas": "cs.[\"uuid-tag-vip\"]"} | JSONB array contains (@>). Required for filtering tags or relation columns (which store arrays of UUID strings in JSONB). |
like | {"telefono": "like.+57%"} | Case-sensitive pattern match (* is auto-normalized to %). |
ilike | {"ciudad": "ilike.%bogot%"} | Case-insensitive pattern match (* is auto-normalized to %). |
Dynamic Runtime Tokens in default_filters
{{contact_id}}: Replaced at runtime with the active interlocutor's UUID (contact_id). Essential whenisolation = 'none'and an internal agent (e.g., a sales rep or technician) should only see customers (clientes) or orders (pedidos) assigned to them via a relation column:{"vendedor_asignado": "cs.[\"{{contact_id}}\"]"}__NOW__: Replaced at runtime with the current UTC ISO timestamp (datetime.utcnow().isoformat()). Ideal for restricting an agent's view to upcoming appointments (citas):{"starts_at": "gte.__NOW__"}
Triple Runtime Effect of default_filters
- Read Security (
<data_context>&query_records): Only records matching all conditions indefault_filtersare preloaded into memory or returned byquery_records. - Auto-Injection on Creation (
create_record): Everyeqfilter indefault_filtersis automatically injected intodatawhenever the agent callscreate_record. For example, if a Lead Qualifier agent hasdefault_filters = {"etapa": "lead"}, every record it creates is guaranteed to haveetapa = "lead". - Pre-Update Check & Pipeline Hand-Off (
update_record): When the agent callsupdate_record, the engine verifies that the record currently satisfiesdefault_filtersbefore applying the update, but allows the new values indatato move the record outside the filter. For example, a Sales Agent withdefault_filters = {"etapa": "lead"}can update a customer'setapato"cliente_activo"— handing the record off to the Customer Success agent on subsequent turns.
3. How the Agent Sees Data (<data_context>) & Operational Modes
On every conversational turn, the processor worker builds a live <data_context> snapshot from the tables authorized in agent_tables:
- Current Contact (
--- CURRENT CONTACT INFO ---):- Preloads the active interlocutor's full profile (
Name,Phone, and all non-hidden attributes) so the agent immediately knows who is writing without querying.
- Preloads the active interlocutor's full profile (
- Catalogs & Global Tables (e.g.,
productos,servicioswithisolation = 'none'):- Up to 150 records (
MAX_GLOBAL_PRELOAD = 150): Preloaded in full compact pipe-delimited format ([Name|Price|Attribute...]), grouped bystatus(e.g.,Status: Activo), including📎 [Attached Manual]markers when a record has a populatedknowledgecolumn. - More than 150 records (
> 150up toMAX_NAMES_ONLY_PRELOAD = 2000): To protect context window latency and token cost, the preload automatically switches to a names-only directory (is_truncated = True) and automatically injects thequery_recordstool into the agent's toolkit so it can search, filter, and paginate records on demand.
- Up to 150 records (
- Customer Orders & Appointments (e.g.,
pedidos,citaswithisolation = 'auto'):- Preloads the customer's last 20 records (
MAX_EVENTS_PRELOAD = 20) ordered bystarts_at DESC, including formatted local start/end times (🕒 Starts/🏁 Ends), status, attributes, and summarizedline_items.
- Preloads the customer's last 20 records (
- Reactive vs. Proactive Execution Modes:
- Reactive Mode: Triggered when a customer sends a text message, voice note (auto-transcribed), or image/document (
[ATTACHED INBOUND MEDIA FILE]). - Proactive Mode (
[⏰ SCHEDULED REMINDER]/[🤖 ACTIVE AUTOMATION]): Triggered by a scheduled reminder (agent_tasks) or an automation script (agents.trigger_task/satia.trigger_agent). The agent initiates or continues the conversation naturally with the customer on WhatsApp/channel without mentioning internal system tags.
- Reactive Mode: Triggered when a customer sends a text message, voice note (auto-transcribed), or image/document (
4. Native Agent Tools Catalog
Transactional agents receive native tools automatically based on their agent_tables permissions, table schemas, and active schedules:
4.1 Data Operations (common_libs/skills/data_operations.py)
create_record(entity_slug, data, name?)(injected when at least one table haspermissionin['write', 'full']):- Supports single creation (
datais a dict) or batch creation (datais a list of up to 50 dicts). - Auto-Resolution of Relations & Tags: If the agent passes a plain-text name (e.g.,
"Dr. Pérez"or"VIP") in arelationortagscolumn, the engine automatically resolves it to the corresponding record/tag UUID (and auto-creates missing tags). - Timezone & Schedule Validation: Converts naive local datetimes (
YYYY-MM-DDTHH:MM:SS) from the business timezone to UTC, validates[UNIQUE]attributes before insert, and blocks appointments that collide with atype = 'block'rule inschedule_rules. - Line Items (
line_items): On transactional tables (pedidos,cotizaciones,citas) withhas_line_items = True, passingline_items: [{"offering_id": "<uuid or exact name>", "quantity": 2, "unit_price": 50000}]insidedataautomatically populatesevent_itemsand resolves catalog items by name ifoffering_idis omitted or passed as a product name.
- Supports single creation (
update_record(entity_slug, record_id, data)(injected when at least one table haspermissionin['write', 'full']):- Supports single update or batch updates (
dataas a list of dicts containingrecord_id). record_idaccepts the record's UUID, its exactname, or"me"/"self"/"current"when updating the active interlocutor on a customer table withisolation = 'auto'.- Smart Media Append: On multi-file
mediacolumns, passing new file URLs appends them to the existing array without overwriting prior attachments.
- Supports single update or batch updates (
query_records(entity_slug, filters?, search?, limit?, offset?, order_by?, order_direction?)(auto-injected when any accessible table has> 150records):- Performs type-aware queries across large tables, supporting comparison prefixes (
>=,<=,>,<,!=) on numeric/price columns, name-to-UUID resolution onrelation/tagsfilters, case-insensitive status matching, andiliketext search onname.
- Performs type-aware queries across large tables, supporting comparison prefixes (
expand_relation(record_id, column_key)(auto-injected when accessible tables containrelationcolumns):- Fetches the full details of related records referenced by a
relationcolumn.
- Fetches the full details of related records referenced by a
get_line_items(event_id)(auto-injected when accessible transactional tables haveline_items):- Retrieves the detailed breakdown of items (
event_items) for a specific order, quote, or appointment.
- Retrieves the detailed breakdown of items (
4.2 Time Engine (common_libs/skills/time_engine.py)
Injected automatically when the agent has access to time-bound appointment/reservation tables (e.g., citas, reservas) or active schedules:
check_availability(start_time, end_time, resource_id?): Mandatory before creating or rescheduling an appointment. Evaluatesschedule_rules(subtractive 24/7 model wheretype = 'block'closes hours) and existing appointments/capacities.find_open_slots(date_from, date_to, duration_minutes?, resource_id?): Returns available time windows to offer the customer when a requested slot is busy or when the customer asks for open times.
4.3 Knowledge, Media, Reminders & Escalation
consult_entity_manual(record_id): Reads the full Markdown manual attached to a catalog/entity record'sknowledgecolumn (only for records marked with📎 [Attached Manual]in<data_context>).send_media(file_url, caption?, media_type?)&preview_media(file_url): Sends images, PDFs, videos, or audio files from catalogmediacolumns to the customer, or visually inspects stored files. (Never callpreview_mediaon images the customer just sent in the chat, as those are already visible in the multimodal turn). When a customer sends an image/PDF in chat, it arrives with an[ATTACHED INBOUND MEDIA FILE]tag containing its permanent storage URL, which the agent can save directly into anymediacolumn viacreate_record/update_record.tasks__schedule_reminder(execute_at, instruction): Schedules a future proactive follow-up (agent_tasks) in the current conversation.tasks__escalate_to_sati(summary, category?): Opens an internal escalation ticket thread for Sati and human supervisors, and immediately activates human handover (conversations.is_human_controlled = true) on the customer's conversation so the agent stops replying until the issue is resolved.
5. Human Intervention Control (Global Pause vs. Conversation Handover)
Satia provides two independent mechanisms to pause AI responses:
- Global Agent Pause (
agents.config -> {"is_paused": true}):- Pauses the transactional agent across all conversations and channels simultaneously.
- Toggle via
database__update:database__update( table_name="agents", data={"config": {"is_paused": True}}, where_conditions="id = '<agent_uuid>'", )
- Per-Conversation Human Handover (
conversations.is_human_controlled = true):- Pauses the AI only for a specific customer conversation while the agent continues serving everyone else.
- Activated automatically when:
- A human operator replies manually from the Satia Inbox UI,
- A human operator replies from an external CRM/WhatsApp App via a webhook bridge calling
channels.record_echo(..., activate_handover=True), or - The agent calls
tasks__escalate_to_sati.
- Resume AI control on a conversation via
database__update:database__update( table_name="conversations", data={"is_human_controlled": False}, where_conditions="id = '<conversation_uuid>'", )
6. Dynamic Tools (store_tools & agent_tools)
6.1 ERP-First Principle: When to Use agent_tables vs. Dynamic Tools
- Internal Business Data (
agent_tables): Catalogs (productos), prices, appointments (citas), orders (pedidos), CRM leads, and customer profiles (clientes) MUST live in Satia business tables and be exposed viaagent_tables. Never create a Custom Tool to query Satia's own database. - Dynamic Tools (
agent_tools): Reserved strictly for peripheral actions and live external API integrations executed mid-conversation (e.g., generating a signedcustom_viewsmicro-app link, creating a live Wompi payment link, checking real-time shipping rates in an external carrier API, or updating a stage in an external CRM like Kommo).
6.2 Store Tools (type = 'store')
Store Tools are pre-built, platform-verified tools cataloged in store_tools (see Section 7.1 Active Store Tools Catalog appended dynamically at the end of this manual).
- How to Install a Store Tool: Always use
store__install_tool:store__install_tool( target_agent_id="<agent_uuid>", store_tool_id="<store_tool_uuid>", config={} ) - What
store__install_toolDoes Automatically:- Validates
configagainst the tool'sconfig_schema(ensuring no secret keys are placed inconfig). - Creates or updates the row in
agent_tools(type = 'store',is_enabled = True). - Checks
required_secretsonstore_toolsand automatically provisions any missing secret placeholders (value = "") inautomation_secretsso the user can fill them in Settings -> Secrets.
- Validates
- Contextual Micro-Apps (
custom_viewsStore Tool):- When a transactional agent needs to send a customer an interactive booking calendar, quote configurator, or onboarding form built in
custom_views, install thecustom_viewsStore Tool on the agent. - At runtime, the agent receives
store_custom_views__generate_link(view_slug="...", prefill_data={...}), which generates a cryptographically signed URL (?ctx=<token>) carryingcontact_id,conversation_id,agent_id, andprefill_dataso the customer never has to re-type their name or phone number.
- When a transactional agent needs to send a customer an interactive booking calendar, quote configurator, or onboarding form built in
6.3 Custom Tools (type = 'custom' in agent_tools)
When an agent needs to call an external REST API in real time during a conversation, create a Custom Tool in agent_tools.
agent_tools Schema for Custom Tools
| Column | Type | Required | Description |
|---|---|---|---|
agent_id | uuid | Yes | FK -> agents.id |
type | text | Yes | Must be 'custom' |
name | text | Yes | Function identifier in snake_case (e.g., 'check_shipping_guide'). Exposed to the LLM as custom_<name> |
display_name | text | Yes | Human-readable label shown in the UI (e.g., 'Check Shipping Guide') |
description | text | Yes | Detailed instructions telling the LLM when and how to invoke the tool |
input_schema | jsonb | Yes | OpenAI-compatible JSON Schema object ({"type": "object", "properties": {...}, "required": [...]}). Note: The column name is input_schema (there is NO parameters_schema column) |
script_code | text | Yes | Python code defining def run(event_data: dict) -> dict: |
requirements | text[] | Optional | Extra PyPI packages if needed (prefer from satia_sdk import http which needs zero extra packages) |
config | jsonb | Optional | Static non-secret configuration dictionary ({}) |
is_enabled | boolean | Yes | True |
Exact event_data Contract Inside a Custom Tool (def run(event_data: dict) -> dict:)
When the transactional agent invokes a Custom Tool during a conversation, dynamic_tools_loader.py executes run(event_data) in the sandbox and passes:
{
"tool_args": { ... }, # Dict of arguments passed by the LLM matching `input_schema`
"tool_name": "check_guide", # Tool name
"contact_id": "<uuid>", # Active customer UUID in Satia
"conversation_id": "<uuid>", # Active conversation UUID
"channel_id": "<uuid>", # Channel UUID where the turn originated
"channel_type": "webhook", # 'whatsapp', 'instagram', 'web', 'webhook', etc.
"agent_id": "<uuid>", # Transactional agent UUID
"inbound_payload": { ... }, # Full raw webhook payload from the current turn (if channel_type == 'webhook')
"turn_metadata": { ... } # Extra metadata extracted by on_inbound in a Webhook Channel Bridge
}
Note: For convenience, non-colliding keys from
tool_argsare also merged at the top level ofevent_data, though readingargs = event_data.get("tool_args") or {}is the recommended canonical pattern. All secrets stored inautomation_secretsare injected automatically intoos.environ(os.environ.get("MY_API_KEY")).
Mandatory TDD Workflow for Creating or Editing Custom Tools
- Write & Test First (
automation__test_script): Before inserting intoagent_tools, always test yourscript_codein the dry-run sandbox usingautomation__test_script(code=..., mock_context={"tool_args": {"guide_number": "12345"}, "contact_id": "test-uuid"}). - Insert into
agent_tools(database__insert): Onceautomation__test_scriptsucceeds, insert the record intoagent_toolswithtype="custom",input_schema={...}, andscript_code=.... - Surgical Edits (
database__inspect_text+database__patch_text): To modify an existing Custom Tool'sscript_code, always inspect line numbers withdatabase__inspect_text(table_name="agent_tools", record_id="<tool_uuid>", column_name="script_code")and apply changes withdatabase__patch_text.
7. Channels (channels) & Custom Webhook Channel Bridges
7.1 channels Table Schema & Native vs. Webhook Channels
| Column | Type | Description |
|---|---|---|
id | uuid | Unique channel ID |
agent_id | uuid | FK -> agents.id (the transactional agent answering on this channel) |
channel_type | text | 'whatsapp', 'instagram', 'facebook', 'tiktok', 'telegram', 'web', or 'webhook' |
custom_name | text | Display label for the channel (e.g., 'Sales WhatsApp', 'Kommo CRM') |
external_identifier | text | Phone number ID, page ID, or webhook:<webhook_slug> for webhook bridges |
is_active | boolean | Whether the channel is active (true / false) |
credentials | jsonb | Channel metadata / bridge reference |
- Native Channels (
'whatsapp','instagram','facebook','tiktok','telegram','web'): Connected by the user in the Satia UI (via Meta OAuth, QR, or widget snippet). You can reassign which agent answers an existing channel at any time by updatingchannels.agent_idviadatabase__update. - Custom Webhook Channel Bridges (
channel_type = 'webhook'): Used whenever an agent must converse through an external CRM or custom messaging platform (e.g., Kommo CRM, Chatwoot, Respond.io, ManyChat, Evolution API, Wati, Bird, Zendesk, or proprietary chat apps).
7.2 Architecture of a Custom Webhook Channel Bridge
A Custom Webhook Channel is powered by a single Bidirectional Bridge Script stored in automation_scripts (trigger_type = 'webhook', is_channel_bridge = True) that defines two functions:
def on_inbound(event_data: dict) -> dict:— Triggered when the external platform sends an HTTP POST tohttps://satiawebhook.up.railway.app/webhooks/<webhook_slug>. It parses the external payload and callschannels.receive_message(...)(orchannels.record_echo(...)for human echoes).def on_outbound(event_data: dict) -> dict:— Triggered automatically by Satia as soon as the Transactional Agent finishes generating its response (or when a human operator replies from the Satia UI). It sends the agent's reply back to the external platform's API viahttp.post(...).
Strict Invariant: 1 Agent = 1 Webhook Channel = 1 Bridge Script
Never share a single webhook bridge script across multiple agents. Each transactional agent that connects to an external webhook platform must have its own dedicated automation_scripts bridge (agent_id = "<AGENT_UUID>") and its own unique webhook_slug.
How to Provision or Update a Webhook Channel Bridge (automation__save_script)
You do not need to manually insert a row into channels first. Calling automation__save_script with trigger_type="webhook", agent_id="<AGENT_UUID>", and "is_channel_bridge": True inside trigger_config automatically creates (or links) the channels row (channel_type = 'webhook') and returns the live webhook_url:
automation__save_script(
name="Kommo CRM Channel Bridge - Sales Agent",
description="Bidirectional bridge between Kommo CRM and the Sales Agent",
code=bridge_code,
trigger_type="webhook",
agent_id="<AGENT_UUID>",
trigger_config={
"webhook_slug": "kommo-sales-mycompany",
"agent_id": "<AGENT_UUID>",
"is_channel_bridge": True,
"webhook_secret": "optional-verification-token"
}
)
# Returns: {"success": True, "script_id": "...", "channel_id": "...", "webhook_slug": "kommo-sales-mycompany", "webhook_url": "https://satiawebhook.up.railway.app/webhooks/kommo-sales-mycompany"}
7.3 The satia_sdk.channels API (receive_message & record_echo)
channels.receive_message(...)
Routes an inbound customer message into Satia's conversational pipeline, resolving/creating the customer in the agent's default_contact_entity_definition_id table and queueing the AI turn:
from satia_sdk import channels
result = channels.receive_message(
external_sender_id="lead_987654", # REQUIRED: Unique sender/chat/lead ID in the external platform
text="Hello, I would like a quote", # Text message (can be "" if media_url is provided)
sender_name="María Gómez", # Customer display name (used when creating a new customer record)
phone="+573001234567", # Optional phone (enables automatic phone deduplication)
media_url="https://...", # Optional URL of inbound image, audio, video, or PDF
media_mime_type="audio/ogg", # Optional MIME type ('image/jpeg', 'audio/ogg', 'application/pdf')
media_headers={"Authorization": "..."}, # Optional HTTP headers if downloading the media requires auth
platform_identifiers={ # Persisted in the customer's external_identifiers & passed to on_outbound
"talk_id": "554433",
"chat_id": "chat_9988"
},
turn_metadata={ # Turn context metadata: passed to Custom Tools and on_outbound in this turn
"pipeline_id": 82719,
"subdomain": "mycompany"
}
)
Automatic Multimodal Processing: When
media_urlis passed tochannels.receive_message, Satia automatically downloads the file, re-hosts it in permanent cloud storage, transcribes audio voice notes, and passes images/PDFs to the agent's multimodal vision context.
channels.record_echo(...)
When a human employee replies directly inside the external platform (e.g., from the Kommo UI or WhatsApp Business App), the external webhook often fires an outbound/echo event. Call channels.record_echo(...) so Satia logs the human message in the chat history and automatically activates Human Handover (is_human_controlled = True) without triggering an AI reply:
return channels.record_echo(
external_sender_id=str(sender_id),
text=text,
external_message_id=str(msg_id),
activate_handover=True # Pauses AI for this conversation so it doesn't interrupt the human
)
7.4 Turn Context Propagation (inbound_payload & turn_metadata) & on_inbound / on_outbound Payloads
- What
on_inbound(event_data)Receives:event_data["payload"]: Parsed webhook body (supports bothapplication/jsonand automatic nestedapplication/x-www-form-urlencodedparsing such as Kommo'smessage[add][0][text]->event_data["payload"]["message"]["add"][0]["text"]).event_data["headers"]: Case-insensitive dictionary of HTTP request headers (event_data["headers"].get("x-signature")works regardless of casing).event_data["query"]: Case-insensitive dictionary of URL query parameters (?token=...).event_data["channel_id"]andevent_data["agent_id"].
- How Turn Context (
inbound_payload&turn_metadata) Travels Through the Turn:- Everything in the raw webhook body (
inbound_payload) plus any dictionary you pass inturn_metadata={...}insidechannels.receive_message(...)is carried in memory throughout the entire turn:- Inside any Custom Tool (
agent_tools) invoked by the agent: Available atevent_data["inbound_payload"]andevent_data["turn_metadata"]. - Inside
on_outbound(event_data)when the agent replies:{ "direction": "outbound", "agent_id": "<uuid>", "channel_id": "<uuid>", "conversation_id": "<uuid>", "external_sender_id": "lead_987654", "message_to_send": "Hello María! Of course...", "media_url": None, # Present if the agent called send_media(...) "media_type": None, # 'image', 'document', 'audio', 'video' "contact": { "id": "<uuid>", "name": "María Gómez", "phone": "+573001234567", "attributes": { ... }, "platform_identifiers": {"talk_id": "554433", "chat_id": "chat_9988"} }, "inbound_payload": { ... }, # Original webhook payload that started the turn "metadata": { ... }, # The exact `turn_metadata` dict from on_inbound "tool_calls": [ # List of tools executed by the agent during this turn {"tool_name": "custom_move_stage", "args": {...}, "result": {...}} ] }
- Inside any Custom Tool (
- Everything in the raw webhook body (
7.5 Mandatory Bidirectional TDD Template (on_inbound + on_outbound)
Always test both directions with automation__test_script (_direction="inbound" and _direction="outbound") before calling automation__save_script:
import os
from satia_sdk import channels, http, logger
def on_inbound(event_data: dict) -> dict:
payload = event_data.get("payload") or {}
# 1. Ignore or record human echoes so the agent never loops on its own/human messages
if payload.get("is_echo") or payload.get("from_me"):
return channels.record_echo(
external_sender_id=str(payload.get("chat_id", "")),
text=payload.get("text", ""),
external_message_id=str(payload.get("message_id", "")),
activate_handover=True
)
sender_id = payload.get("chat_id") or payload.get("sender_id")
text = payload.get("text", "")
media_url = payload.get("media_url")
if not sender_id or (not text and not media_url):
return {"status": "ignored", "reason": "Empty message or missing sender_id"}
# 2. Inject message + turn_metadata into the agent pipeline
return channels.receive_message(
external_sender_id=str(sender_id),
text=text,
sender_name=payload.get("sender_name", "Customer"),
phone=payload.get("phone"),
media_url=media_url,
platform_identifiers={"chat_id": str(sender_id)},
turn_metadata={"ticket_id": payload.get("ticket_id")}
)
def on_outbound(event_data: dict) -> dict:
recipient_id = event_data.get("external_sender_id")
message_text = event_data.get("message_to_send", "")
media_url = event_data.get("media_url")
turn_meta = event_data.get("metadata") or {}
api_token = os.environ.get("EXTERNAL_CHANNEL_API_TOKEN", "")
if not api_token:
logger.warning("EXTERNAL_CHANNEL_API_TOKEN is not configured in automation_secrets")
return {"status": "error", "error": "Missing EXTERNAL_CHANNEL_API_TOKEN secret"}
resp = http.post(
"https://api.external-crm.com/v1/messages",
headers={"Authorization": f"Bearer {api_token}"},
json={
"to": recipient_id,
"text": message_text,
"media_url": media_url,
"ticket_id": turn_meta.get("ticket_id")
}
)
return {"status": "sent", "http_status": resp.get("status_code")}
8. The 4-Step Transactional Agent Audit Protocol
Whenever the user asks to review, debug, or modify a transactional agent, execute this 4-step diagnostic via database__query:
- Step 1 — Identity & Contact Anchor (
agents):VerifySELECT id, name, type, category, default_contact_entity_definition_id, config, LENGTH(personality) AS personality_len FROM agents;default_contact_entity_definition_idis a valid Contact business table UUID (not null and not hidden insideconfig), and check whetherconfig->>'is_paused'is'true'. Usedatabase__inspect_textto readpersonality. - Step 2 — Table Permissions, Filters & Isolation (
agent_tables):Verify that the agent has access to its Customer table (e.g.,SELECT id, agent_id, entity_definition_id, table_name, permission, isolation, column_permissions, default_filters, agent_instruction FROM agent_tables WHERE agent_id = '<agent_uuid>';clientes), Catalog table (e.g.,productos, withisolation = 'none'), and Transactional/Appointments table (e.g.,pedidosorcitas, withisolation = 'auto'), and inspectcolumn_permissionsanddefault_filters. - Step 3 — Assigned Playbooks (
agent_docs+knowledge_docs):(Note: The foreign key column inSELECT ad.id, ad.knowledge_doc_id, kd.title, kd.doc_type, LENGTH(kd.content) AS content_len FROM agent_docs ad JOIN knowledge_docs kd ON kd.id = ad.knowledge_doc_id WHERE ad.agent_id = '<agent_uuid>';agent_docsisknowledge_doc_id, neverdoc_id). - Step 4 — Installed Tools & Connected Channels (
agent_tools&channels):SELECT id, name, display_name, type, store_tool_id, is_enabled, input_schema FROM agent_tools WHERE agent_id = '<agent_uuid>'; SELECT id, channel_type, custom_name, external_identifier, is_active FROM channels WHERE agent_id = '<agent_uuid>';