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

ColumnTypeRequiredDescription
iduuidAutoUnique agent ID
nametextYesDisplay name of the agent (e.g., "Sales Advisor", "Technical Support")
typetextYesAlways 'transactional' for customer-facing agents
categorytextOptional'sales', 'customer_service', 'scheduling', 'operations', 'support'
personalitytextYesCore role, mission, and conversational tone (Note: There is NO prompt or instructions column; always use personality)
default_contact_entity_definition_iduuidYesPhysical column on agents pointing to the entity_definition_id of the primary Contact business table (e.g., clientes, pacientes) this agent serves
configjsonbOptionalOperational settings: {"is_paused": false}

Golden Rules of Agent Architecture

  1. 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_id on agents (retrieve the table UUID from database__describe_tables()).
    • NEVER store default_contact_entity_definition_id inside the config JSONB 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., clientes and proveedores), create 2 distinct agents, each anchored to its respective contact business table.
  2. Cognitive Fat Rule (personality vs agent_docs vs 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 existing personality, always use database__inspect_text + database__patch_text (never overwrite blind).
    • agent_docs (Global Agent Playbooks): Extensive FAQs, return policies, objection-handling playbooks, and pricing rules belong in knowledge_docs (doc_type = 'document') linked to the agent via agent_docs (agent_id, knowledge_doc_id). Documents in agent_docs are injected automatically into the agent's system prompt on every conversation.
    • Entity Manuals (knowledge column on Catalog Tables): Long technical datasheets or per-product manuals belong in a knowledge-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 via consult_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

ColumnTypeRequiredDescription
iduuidAutoUnique permission record ID
agent_iduuidYesFK -> agents.id
entity_definition_iduuidYesTarget business table ID (entity_definition_id from database__describe_tables())
table_nametextYesTarget business table category or slug (e.g., clientes, productos, pedidos, citas)
permissiontextYesTable-level access: 'read', 'write', or 'full' ('write' and 'full' allow create_record and update_record)
isolationtextYesRow 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_permissionsjsonbOptionalPer-column access overrides: {"col_key": "hidden" | "read" | "write"}
default_filtersjsonbOptionalServer-side row filters, creation defaults, and pipeline hand-off rules
agent_instructiontextOptionalCustom contextual instructions injected right above this table in the agent's <data_context>

Important: Always verify existing rows in agent_tables with database__query before 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 from query_records and expand_relation results, omitted from the agent's schema definition, and blocked on create_record / update_record. Use "hidden" for internal costs, margins, supplier notes, or confidential fields.
  • "read": Visible to the agent in <data_context> and query_records so 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 Roleisolation = '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"}).

OperatorSyntax ExampleMeaning & 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 when isolation = '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

  1. Read Security (<data_context> & query_records): Only records matching all conditions in default_filters are preloaded into memory or returned by query_records.
  2. Auto-Injection on Creation (create_record): Every eq filter in default_filters is automatically injected into data whenever the agent calls create_record. For example, if a Lead Qualifier agent has default_filters = {"etapa": "lead"}, every record it creates is guaranteed to have etapa = "lead".
  3. Pre-Update Check & Pipeline Hand-Off (update_record): When the agent calls update_record, the engine verifies that the record currently satisfies default_filters before applying the update, but allows the new values in data to move the record outside the filter. For example, a Sales Agent with default_filters = {"etapa": "lead"} can update a customer's etapa to "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:

  1. 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.
  2. Catalogs & Global Tables (e.g., productos, servicios with isolation = 'none'):
    • Up to 150 records (MAX_GLOBAL_PRELOAD = 150): Preloaded in full compact pipe-delimited format ([Name|Price|Attribute...]), grouped by status (e.g., Status: Activo), including 📎 [Attached Manual] markers when a record has a populated knowledge column.
    • More than 150 records (> 150 up to MAX_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 the query_records tool into the agent's toolkit so it can search, filter, and paginate records on demand.
  3. Customer Orders & Appointments (e.g., pedidos, citas with isolation = 'auto'):
    • Preloads the customer's last 20 records (MAX_EVENTS_PRELOAD = 20) ordered by starts_at DESC, including formatted local start/end times (🕒 Starts / 🏁 Ends), status, attributes, and summarized line_items.
  4. 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.

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 has permission in ['write', 'full']):
    • Supports single creation (data is a dict) or batch creation (data is 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 a relation or tags column, 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 a type = 'block' rule in schedule_rules.
    • Line Items (line_items): On transactional tables (pedidos, cotizaciones, citas) with has_line_items = True, passing line_items: [{"offering_id": "<uuid or exact name>", "quantity": 2, "unit_price": 50000}] inside data automatically populates event_items and resolves catalog items by name if offering_id is omitted or passed as a product name.
  • update_record(entity_slug, record_id, data) (injected when at least one table has permission in ['write', 'full']):
    • Supports single update or batch updates (data as a list of dicts containing record_id).
    • record_id accepts the record's UUID, its exact name, or "me" / "self" / "current" when updating the active interlocutor on a customer table with isolation = 'auto'.
    • Smart Media Append: On multi-file media columns, passing new file URLs appends them to the existing array without overwriting prior attachments.
  • query_records(entity_slug, filters?, search?, limit?, offset?, order_by?, order_direction?) (auto-injected when any accessible table has > 150 records):
    • Performs type-aware queries across large tables, supporting comparison prefixes (>=, <=, >, <, !=) on numeric/price columns, name-to-UUID resolution on relation/tags filters, case-insensitive status matching, and ilike text search on name.
  • expand_relation(record_id, column_key) (auto-injected when accessible tables contain relation columns):
    • Fetches the full details of related records referenced by a relation column.
  • get_line_items(event_id) (auto-injected when accessible transactional tables have line_items):
    • Retrieves the detailed breakdown of items (event_items) for a specific order, quote, or appointment.

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. Evaluates schedule_rules (subtractive 24/7 model where type = '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's knowledge column (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 catalog media columns to the customer, or visually inspects stored files. (Never call preview_media on 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 any media column via create_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:

  1. 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>'",
      )
      
  2. 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 via agent_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 signed custom_views micro-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_tool Does Automatically:
    1. Validates config against the tool's config_schema (ensuring no secret keys are placed in config).
    2. Creates or updates the row in agent_tools (type = 'store', is_enabled = True).
    3. Checks required_secrets on store_tools and automatically provisions any missing secret placeholders (value = "") in automation_secrets so the user can fill them in Settings -> Secrets.
  • Contextual Micro-Apps (custom_views Store Tool):
    • When a transactional agent needs to send a customer an interactive booking calendar, quote configurator, or onboarding form built in custom_views, install the custom_views Store 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>) carrying contact_id, conversation_id, agent_id, and prefill_data so the customer never has to re-type their name or phone number.

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

ColumnTypeRequiredDescription
agent_iduuidYesFK -> agents.id
typetextYesMust be 'custom'
nametextYesFunction identifier in snake_case (e.g., 'check_shipping_guide'). Exposed to the LLM as custom_<name>
display_nametextYesHuman-readable label shown in the UI (e.g., 'Check Shipping Guide')
descriptiontextYesDetailed instructions telling the LLM when and how to invoke the tool
input_schemajsonbYesOpenAI-compatible JSON Schema object ({"type": "object", "properties": {...}, "required": [...]}). Note: The column name is input_schema (there is NO parameters_schema column)
script_codetextYesPython code defining def run(event_data: dict) -> dict:
requirementstext[]OptionalExtra PyPI packages if needed (prefer from satia_sdk import http which needs zero extra packages)
configjsonbOptionalStatic non-secret configuration dictionary ({})
is_enabledbooleanYesTrue

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_args are also merged at the top level of event_data, though reading args = event_data.get("tool_args") or {} is the recommended canonical pattern. All secrets stored in automation_secrets are injected automatically into os.environ (os.environ.get("MY_API_KEY")).

Mandatory TDD Workflow for Creating or Editing Custom Tools

  1. Write & Test First (automation__test_script): Before inserting into agent_tools, always test your script_code in the dry-run sandbox using automation__test_script(code=..., mock_context={"tool_args": {"guide_number": "12345"}, "contact_id": "test-uuid"}).
  2. Insert into agent_tools (database__insert): Once automation__test_script succeeds, insert the record into agent_tools with type="custom", input_schema={...}, and script_code=....
  3. Surgical Edits (database__inspect_text + database__patch_text): To modify an existing Custom Tool's script_code, always inspect line numbers with database__inspect_text(table_name="agent_tools", record_id="<tool_uuid>", column_name="script_code") and apply changes with database__patch_text.

7. Channels (channels) & Custom Webhook Channel Bridges

7.1 channels Table Schema & Native vs. Webhook Channels

ColumnTypeDescription
iduuidUnique channel ID
agent_iduuidFK -> agents.id (the transactional agent answering on this channel)
channel_typetext'whatsapp', 'instagram', 'facebook', 'tiktok', 'telegram', 'web', or 'webhook'
custom_nametextDisplay label for the channel (e.g., 'Sales WhatsApp', 'Kommo CRM')
external_identifiertextPhone number ID, page ID, or webhook:<webhook_slug> for webhook bridges
is_activebooleanWhether the channel is active (true / false)
credentialsjsonbChannel 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 updating channels.agent_id via database__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:

  1. def on_inbound(event_data: dict) -> dict: — Triggered when the external platform sends an HTTP POST to https://satiawebhook.up.railway.app/webhooks/<webhook_slug>. It parses the external payload and calls channels.receive_message(...) (or channels.record_echo(...) for human echoes).
  2. 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 via http.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_url is passed to channels.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

  1. What on_inbound(event_data) Receives:
    • event_data["payload"]: Parsed webhook body (supports both application/json and automatic nested application/x-www-form-urlencoded parsing such as Kommo's message[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"] and event_data["agent_id"].
  2. How Turn Context (inbound_payload & turn_metadata) Travels Through the Turn:
    • Everything in the raw webhook body (inbound_payload) plus any dictionary you pass in turn_metadata={...} inside channels.receive_message(...) is carried in memory throughout the entire turn:
      • Inside any Custom Tool (agent_tools) invoked by the agent: Available at event_data["inbound_payload"] and event_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": {...}}
            ]
        }
        

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:

  1. Step 1 — Identity & Contact Anchor (agents):
    SELECT id, name, type, category, default_contact_entity_definition_id, config, LENGTH(personality) AS personality_len
    FROM agents;
    
    Verify default_contact_entity_definition_id is a valid Contact business table UUID (not null and not hidden inside config), and check whether config->>'is_paused' is 'true'. Use database__inspect_text to read personality.
  2. Step 2 — Table Permissions, Filters & Isolation (agent_tables):
    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>';
    
    Verify that the agent has access to its Customer table (e.g., clientes), Catalog table (e.g., productos, with isolation = 'none'), and Transactional/Appointments table (e.g., pedidos or citas, with isolation = 'auto'), and inspect column_permissions and default_filters.
  3. Step 3 — Assigned Playbooks (agent_docs + knowledge_docs):
    SELECT 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>';
    
    (Note: The foreign key column in agent_docs is knowledge_doc_id, never doc_id).
  4. 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>';