Arquitecto de automatización con IA: Día 27
Índice de contenido
Resumen:
Un cliente escribió Me aparta el de "siempre" pal sábado y el escenario de pedidos de Trigo Lento se detuvo: las comillas de su mensaje rompieron el cuerpo JSON que Valeria había escrito a mano para consultar a un modelo de lenguaje. En esta entrada, Valeria repasa JSON desde sus tipos y escapes, diagnostica diez formas de JSON roto con su posición exacta, mide que concatenar texto rompe el $8%$ de los cuerpos y que un texto malicioso puede cambiar el modelo y el límite de salida, y sustituye la concatenación por Create JSON. Después distingue variables de escenario, variables personalizadas y almacenes de datos, declara la estructura de un almacén de clientes con clave seudonimizada y lo deja listo en TL Pedidos WhatsApp v2.2. Al terminar, usted sabrá leer y escribir JSON sin sorpresas, construir cuerpos seguros con el texto del cliente y decidir dónde guarda un escenario de Make lo que necesita recordar.
¿Cómo citar el presente artículo?
Romero, J. (27 de febrero de 2026). Arquitecto de automatización con IA: Día 27. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_27/.
Arquitecto de automatización con IA: Día 27 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_27/.
Precios, límites y políticas de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude revisados en octubre de 2026. Se contrastaron con el centro de ayuda y la referencia de la API de Make en esa fecha: los módulos de la aplicación JSON (Parse JSON, Create JSON, Transform to JSON y Aggregate to JSON), la función escapeJSON, añadida en 2026, la función sha256 con clave para HMAC, get con índices que empiezan en $1$, los tipos de variable (de escenario, personalizadas, incrementales y del sistema), la retirada de los ciclos y de la duración “One cycle” de las variables el 1 de octubre de 2026, y los límites de los almacenes de datos (1 MB por cada 1 000 créditos del plan, 1 MB como mínimo por almacén, 1 000 almacenes por organización y 15 MB por registro). El fragmento de blueprint del Bloque 8 tiene la estructura verificada contra la documentación, revisado en octubre de 2026; no es la exportación de una cuenta real, y los nombres internos de los parámetros de json:CreateJSON, json:ParseJSON y datastore:AddRecord deben contrastarse exportando un escenario propio.
Día 27. JSON y variables en Make: estructuras de datos, Parse JSON y almacenes
Introducción
Ayer, en el Día 26 (Laboratorio guiado en Make: el escenario de recordatorios de recogida, paso a paso), Valeria construyó módulo por módulo un escenario programado con búsqueda, filtros de consentimiento y franja horaria, un almacén de datos que impide avisar dos veces y un manejador de errores, y Trigo Lento ganó TL Recordatorio de recogida v1, aprobado con una lista de comprobación de $12$ puntos. Aquel almacén guardaba tres campos y se consultaba por su clave; hoy toca entender a fondo el formato en que viajan y se guardan los datos en Make, y lo que pasa cuando ese formato se rompe.
El jueves por la noche, con el laboratorio terminado, Valeria se animó a algo que el Día 25 había dejado escrito solo en Python: el segundo nivel de la cascada, la consulta a un modelo de lenguaje para los pedidos que las reglas no saben leer. Hizo una copia de TL Pedidos WhatsApp v2.1, añadió en la ruta de revisión un módulo HTTP que envía el texto del pedido a la API de Claude y escribió el cuerpo de la petición a mano, en el campo de contenido del módulo, con el texto del cliente mapeado en medio: "content": "{{4.texto}}". Lo probó con tres mensajes, los tres funcionaron, y a las 22:40 activó la copia.
El viernes 27 de febrero, a las 9:12, Ramiro, que lleva años comprando la misma hogaza, escribió por WhatsApp: Me aparta el de "siempre" pal sábado. Las reglas no reconocieron ningún producto y el pedido tomó la ruta de revisión. El módulo HTTP envió el cuerpo, la API respondió con un error $400$ de tipo invalid_request_error, el escenario se detuvo en ese módulo y, como Valeria había puesto la fila de revisión después de la consulta para escribir la sugerencia en la misma fila, la fila nunca se escribió. La ejecución quedó guardada entre las ejecuciones incompletas, porque el escenario las tiene activadas desde el Día 22, pero nadie la miró hasta las 12:30. A la una, Ramiro llamó para preguntar si le habían apartado su pan. Las comillas de su mensaje, las de “siempre”, habían cerrado antes de tiempo la cadena del cuerpo JSON. Y aunque la petición hubiera salido bien, ni el modelo ni el escenario sabían qué significa “el de siempre” para Ramiro: eso solo lo sabía Valeria.
Don Ernesto escucha la historia con la caja de hogazas en los brazos y resume lo que piensa en dos frases: "¿Y por unas comillas se cae todo? Mis clientes escriben como quieren, Valeria; no les voy a pedir que escriban como le gusta a la computadora. Y ’el de siempre’ es la hogaza grande, eso lo sabe cualquiera que trabaje aquí." Tiene razón dos veces. Un sistema que se rompe con lo que escribe un cliente normal está mal construido, y la culpa no es del cliente. Y “lo que sabe cualquiera que trabaje aquí” es exactamente lo que un escenario necesita guardar en algún sitio para dejar de depender de la memoria de una persona.
En la presente entrada, descubrirá qué es JSON por dentro: sus seis tipos, sus reglas de escape, cómo viajan los acentos y los emojis y por qué un número muy grande puede cambiar de valor sin que nadie lo note. Verá los módulos Parse JSON y Create JSON de Make y las estructuras de datos que los acompañan, y distinguirá las variables de escenario, las variables personalizadas, las variables incrementales y los almacenes de datos. Diagnosticará diez formas de JSON roto con la posición exacta del error, medirá cuántos cuerpos rompe la concatenación en $200$ mensajes simulados, verá cómo un texto preparado puede reescribir una petición sin romperla y aprenderá las dos formas seguras de construir un cuerpo en Make. Después declarará la estructura del almacén de clientes, lo simulará con sqlite3, proyectará su tamaño a doce meses y terminará con TL Pedidos WhatsApp v2.2 ensamblado, validado, contado en créditos y dibujado.
Objetivos de aprendizaje
Al terminar esta entrada, usted podrá:
- Leer y escribir JSON sin errores (tipos, escapes, Unicode) y diagnosticar JSON roto.
- Usar
json:ParseJSONyjson:CreateJSONcon estructuras de datos definidas. - Distinguir variables del ciclo, variables del escenario y almacenes de datos (data stores) persistentes.
- Construir el cuerpo de una petición con texto del cliente de forma segura.
JSON en diez minutos
JSON (por sus siglas en inglés: “JavaScript Object Notation”, notación de objetos de JavaScript) es el formato de texto en el que viaja casi todo lo que entra y sale de un escenario de Make: el cuerpo de un webhook, la respuesta de una API, el blueprint que el Día 22 aprendió a leer y la petición que se envía a un modelo de lenguaje. Lo define la norma RFC 8259 del IETF, y su gramática es tan pequeña que cabe en una página: un documento JSON es un valor, y un valor solo puede ser de seis tipos.
- Objeto: una colección de pares clave y valor entre llaves,
{"clave": "P01"}. Las claves son siempre cadenas entre comillas dobles. En Make, un objeto se llama colección. - Arreglo: una lista ordenada de valores entre corchetes,
["P01", "P04"]. Make lo llama igual, arreglo (array), y cuenta sus elementos desde $1$, como recordó el Día 25 con la funciónget. - Cadena: texto entre comillas dobles, nunca simples:
"Concha de vainilla". - Número: sin comillas, con punto decimal y sin separador de miles:
95,95.0,0.0085. - Booleano:
trueofalse, en minúsculas y sin comillas. - Nulo:
null, la forma de decir “este campo existe y no tiene valor”.
La anatomía de la Figura 1 señala cada una de esas piezas sobre un pedido de Trigo Lento, el mismo que imprimirá el Bloque 2.
Figura 1. Anatomía de un JSON: el pedido del Bloque 2 con el objeto, las claves, una cadena, un número, un booleano, un nulo y un arreglo señalados, y los tres escapes que necesita una cadena: comilla, barra invertida y salto de línea.
Dentro de una cadena hay caracteres que no pueden escribirse tal cual, porque el analizador los confundiría con la estructura. Son tres familias. La comilla doble cierra la cadena, así que dentro de ella se escribe \". La barra invertida inicia los escapes, así que dentro de una cadena se escribe \\. Y los caracteres de control, como el salto de línea o el tabulador, no pueden aparecer literalmente: se escriben \n y \t. Todo lo demás, incluidos los acentos y los emojis, puede escribirse directamente si el texto viaja en UTF-8, o como secuencia \uXXXX si se quiere un JSON que solo contenga caracteres ASCII.
Comencemos, como siempre, por las importaciones, la semilla de la serie y los supuestos fijos del día: los $1,560$ mensajes de WhatsApp al mes, los $257$ pedidos que llegan por ese canal (el $55%$ de los $468$ encargos, como calculó el Día 25), la cuota de Make Core y el espacio para almacenes que esa cuota trae consigo:
# Importaciones base, semilla de la serie y carpeta del día en Python:
import warnings
warnings.filterwarnings('ignore')
import copy
import hashlib
import hmac
import json
import re
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import urlencode
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
pd.set_option('display.width', 120)
carpeta = Path('trigo_lento') / 'dia_27'
flujos = carpeta / 'flujos' / 'make'
flujos.mkdir(parents = True, exist_ok = True)
# Supuestos fijos de la serie y del plan Core de Make (revisado en octubre de 2026):
MENSAJES_MES = 1_560 # 60 mensajes de WhatsApp al día, 26 días
PEDIDOS_WHATSAPP_MES = 257 # el 55 % de los 468 encargos llega por WhatsApp (Día 25)
CUOTA_CORE = 10_000 # créditos al mes
MB_ALMACENES = CUOTA_CORE / 1_000 # 1 MB de almacenes por cada 1 000 créditos del plan
print('numpy', np.__version__, '| pandas', pd.__version__)
print(f'Mensajes al mes: {MENSAJES_MES} | pedidos por WhatsApp: {PEDIDOS_WHATSAPP_MES} | '
f'espacio para almacenes en Core: {MB_ALMACENES:.0f} MB')
numpy 2.4.6 | pandas 3.0.3
Mensajes al mes: 1560 | pedidos por WhatsApp: 257 | espacio para almacenes en Core: 10 MB
El espacio para almacenes es la primera cifra del día que conviene retener: según el centro de ayuda de Make, revisado en octubre de 2026, cada $1,000$ créditos del plan dan 1 MB de almacenes de datos, y con los $10,000$ créditos de Core, Trigo Lento dispone de $10$ MB para repartir entre todos sus almacenes. Cada almacén ocupa como mínimo 1 MB.
El siguiente bloque hace el ejercicio más básico y más revelador: convierte cinco textos reales de clientes en JSON con json.dumps (serializar), los vuelve a leer con json.loads (analizar) y compara los dos modos de escribir caracteres que no son ASCII. Después muestra cómo se traducen los tipos de Python a JSON y qué le pasa a un número demasiado grande:
# Ida y vuelta de JSON con json.dumps y json.loads: acentos, comillas, saltos, emojis y números grandes en Python:
casos = {
'acentos': 'Me aparta 2 conchas pal sábado, ¿sí?',
'comillas': 'el de "siempre"',
'salto de línea': '2 baguettes\n1 pay de queso',
'barra invertida': 'ruta C:\\pedidos',
'emoji': 'Quiero 3 croissants 🥐',
}
filas = []
for nombre, texto in casos.items():
ascii_ = json.dumps({'texto': texto}) # ensure_ascii = True, el valor por omisión
utf8 = json.dumps({'texto': texto}, ensure_ascii = False)
vuelta = json.loads(ascii_)['texto'] == texto and json.loads(utf8)['texto'] == texto
filas.append({'caso': nombre, 'cadena JSON (ensure_ascii=False)': utf8[10:-1],
'bytes ascii': len(ascii_.encode('utf-8')), 'bytes utf8': len(utf8.encode('utf-8')),
'ida y vuelta': vuelta})
print(pd.DataFrame(filas).to_string(index = False))
print('Con ensure_ascii=True, "sábado" viaja como', json.dumps('sábado'), 'y el croissant como', json.dumps('🥐'))
# Tipos de Python frente a tipos de JSON:
pedido = {'clave': 'P01', 'cantidad': 2, 'precio_mxn': 95.0, 'urgente': True, 'nota': None, 'renglones': ['P01', 'P04']}
print(json.dumps(pedido, ensure_ascii = False))
# Números grandes: JSON no limita los dígitos, pero un número de doble precisión solo es exacto hasta 2**53:
grande = 2**53 + 1
print('entero de Python intacto:', json.loads(json.dumps(grande)) == grande,
'| como doble precisión:', int(float(grande)), '| WaId como texto:', json.dumps({'WaId': '5215500000007'}))
caso cadena JSON (ensure_ascii=False) bytes ascii bytes utf8 ida y vuelta
acentos "Me aparta 2 conchas pal sábado, ¿sí?" 64 52 True
comillas "el de \"siempre\"" 30 30 True
salto de línea "2 baguettes\n1 pay de queso" 40 40 True
barra invertida "ruta C:\\pedidos" 29 29 True
emoji "Quiero 3 croissants 🥐" 45 37 True
Con ensure_ascii=True, "sábado" viaja como "s\u00e1bado" y el croissant como "\ud83e\udd50"
{"clave": "P01", "cantidad": 2, "precio_mxn": 95.0, "urgente": true, "nota": null, "renglones": ["P01", "P04"]}
entero de Python intacto: True | como doble precisión: 9007199254740992 | WaId como texto: {"WaId": "5215500000007"}
Los cinco textos hacen ida y vuelta sin pérdida (True en todos los casos), que es lo mínimo que se le pide a un serializador. La columna central enseña los escapes en acción: las comillas de “siempre” aparecen como \", el salto de línea como \n y la barra invertida como \\. Nadie las escribió a mano; json.dumps las puso porque sabe dónde hacen falta.
La diferencia entre las dos columnas de bytes es la de ensure_ascii. Con su valor por omisión, True, Python escribe “sábado” como "sábado", y el croissant, que está fuera del plano básico de Unicode, como dos secuencias, "🥐". Las dos formas son JSON válido y se leen igual, pero no ocupan lo mismo:
$$ \Delta B = \sum_{c \in T,; c > \text{U+007F}} \big(6,k(c) - u(c)\big) $$
La lógica detrás de esta fórmula es múltiple:
- $T$ es el texto y la suma recorre solo sus caracteres no ASCII, los que están por encima de U+007F (acentos, signos de apertura, eñes y emojis).
- $6,k(c)$ son los bytes del escape: una secuencia
\uXXXXocupa $6$ caracteres, y un emoji necesita $k(c) = 2$ secuencias (un par sustituto); un acento necesita $k(c) = 1$. - $u(c)$ son los bytes del mismo carácter escrito directamente en UTF-8: $2$ para “á”, “í” o “¿”, y $4$ para un emoji.
- En conclusión, cada acento cuesta $6 - 2 = 4$ bytes más con
ensure_asciiy cada emoji $12 - 4 = 8$. El mensaje de las conchas tiene tres caracteres no ASCII (“á”, “¿”, “í”) y crece $12$ bytes, de $52$ a $64$; el del croissant crece $8$, de $37$ a $45$, exactamente lo que imprime el bloque.
Ninguna de las dos formas es incorrecta, y la elección rara vez importa por los bytes; importa por la legibilidad del historial y por los sistemas antiguos que no aceptan UTF-8. Lo que sí importa siempre es no mezclar: un cuerpo escrito en UTF-8 debe declararse como tal en la cabecera Content-Type.
La última línea de la salida es la advertencia más silenciosa de JSON. La norma no limita los dígitos de un número, pero muchos sistemas los guardan como números de doble precisión de 64 bits, el tipo de JavaScript, que solo representa enteros exactos hasta $2^{53} = 9,007,199,254,740,992$. El entero $2^{53} + 1$ sobrevive en Python, que tiene enteros de tamaño arbitrario, pero convertido a doble precisión se convierte, sin ningún error, en $9,007,199,254,740,992$. Un identificador numérico largo que pase por un sistema así cambia de valor y deja de apuntar a lo que apuntaba. Por eso la regla de la serie es que los identificadores viajan como texto aunque solo tengan dígitos: el WaId de Twilio, el MessageSid y los teléfonos son cadenas, como muestra el ejemplo final, y nunca se suman, se redondean ni se ordenan como números.
Parse JSON y Create JSON
Make convierte JSON en datos y datos en JSON de dos maneras. La primera es automática y casi invisible: cuando un webhook recibe un cuerpo JSON, o cuando un módulo HTTP recibe una respuesta JSON con la opción de analizar la respuesta activada, Make la convierte en un paquete con campos que se pueden mapear en los módulos siguientes. La segunda es explícita y se hace con los módulos de la aplicación JSON, que no necesita conexión. Según su documentación, revisada en octubre de 2026, son estos:
- Parse JSON (
json:ParseJSON): recibe una cadena JSON y la convierte en un paquete con campos mapeables. Si la cadena contiene una colección, devuelve un paquete; si contiene un arreglo en la raíz, devuelve una serie de paquetes, uno por elemento, igual que un iterador. Se usa cuando el JSON llega como texto dentro de otro dato: una columna de una hoja, un campo de un formulario o, como en este día, el texto que devuelve un modelo de lenguaje. - Create JSON (
json:CreateJSON): construye una cadena JSON a partir de campos que usted rellena, uno por uno, según una estructura de datos. Es la forma segura de construir un cuerpo de petición, y la sección de cuerpos seguros explica por qué. - Transform to JSON: convierte cualquier objeto de un módulo anterior en texto JSON de una vez, sin reconstruirlo campo por campo.
- Aggregate to JSON: junta varios paquetes en un solo JSON; suele ir detrás de un Parse JSON que emitió varios paquetes, con el mismo papel que el agregador del Día 25.
Los dos primeros trabajan con una estructura de datos (data structure): un documento que describe el formato de los datos, campo por campo, con su nombre y su tipo, para que el editor sepa qué campos ofrecer al mapear. Make la puede generar a partir de un ejemplo: se pega un JSON de muestra en el generador y el editor propone la estructura, que después se corrige a mano. En Parse JSON la estructura es opcional (si no la tiene, basta con ejecutar el módulo una vez con datos de ejemplo para que el editor descubra los campos), pero declararla tiene dos ventajas: los campos aparecen en el panel de mapeo antes de la primera ejecución, y el escenario queda documentado. Según la referencia de la API, revisada en octubre de 2026, los tipos admitidos en una estructura son text, number, boolean, date, buffer (datos binarios), collection y array, y la opción estricta (strict) hace que se rechacen los datos con campos que la estructura no declara.
Hay una conversión automática que conviene conocer porque produce JSON sin que nadie lo pida. Según la tabla de conversión de tipos de Make, cuando se mapea una colección en un campo que espera texto, Make la convierte en una cadena JSON. Es cómodo, pero quiere decir que una columna de una hoja de cálculo puede terminar con un objeto entero dentro, y que leerlo después exigirá un Parse JSON.
Variables y memoria
El caso de Ramiro tiene una segunda mitad que no es de formato sino de memoria: el escenario no sabía que “el de siempre” es una hogaza de masa madre. Para saberlo, algo tiene que recordar lo que pasó en ejecuciones anteriores, y Make ofrece varios sitios donde guardar un valor, cada uno con su alcance y su duración. La Tabla 1 los compara con la documentación de Make revisada en octubre de 2026 y con un ejemplo de Trigo Lento para cada uno.
Tabla 1. Dónde guarda un escenario de Make lo que necesita recordar (revisado en octubre de 2026).
| Mecanismo | Módulo o lugar | Alcance | Duración | Coste | Ejemplo en Trigo Lento |
|---|---|---|---|---|---|
| Variable de escenario | util:SetVariable2 (Set variable) y Get variable para leerla en otra ruta | El escenario en curso | Una ejecución (la opción de un ciclo se retiró el 1 de octubre de 2026) | $1$ crédito por paquete que pasa por el módulo | dia_semana_hoy del obrador (Día 25) |
| Varias variables de escenario | util:SetVariables (Set multiple variables) | El escenario en curso | Una ejecución | $1$ crédito por paquete, con todas las variables a la vez | cantidad, producto y clave de cada renglón (Día 25) |
| Variable incremental | Herramienta Increment function | El escenario | Se reinicia en cada ejecución o nunca | $1$ crédito por paquete | Número de mensaje dentro de una ejecución |
| Variable personalizada | Organización o equipo | Todos los escenarios del equipo | Hasta que alguien la cambie | Solo en los planes Pro, Teams y Enterprise | No disponible en Make Core |
| Variable del sistema | {{now}}, identificador del escenario y similares | Todos los escenarios | Solo lectura | Sin coste | La hora de la ejecución |
| Almacén de datos | datastore:GetRecord, datastore:AddRecord y el resto de la aplicación | Cualquier escenario de la organización | Permanente, hasta que se borre | $1$ crédito por operación y espacio del plan (1 MB por cada 1 000 créditos) | recordatorios_enviados (Día 26) y clientes (hoy) |
La tabla esconde una corrección que el plan de la serie tuvo que hacer. Hasta septiembre de 2026, las variables de Set variable y Set multiple variables tenían dos duraciones posibles: un ciclo (útil cuando una sola ejecución procesaba varios ciclos, por ejemplo varios lotes de un disparador programado) y una ejecución. Según las notas de versión de Make, el 1 de octubre de 2026 desaparecieron los ciclos: todas las ejecuciones completan un solo ciclo, y la opción “One cycle” ya no existe ni en las variables ni en la función de incremento. En un escenario instantáneo como el de pedidos, con un mensaje por ejecución, nunca hubo diferencia práctica; en uno programado que procesaba varios lotes por ejecución, sí, y por eso conviene revisarlo si usted heredó escenarios antiguos.
La segunda corrección es de plan. Las variables personalizadas, que se definen a nivel de organización o de equipo y que cualquier escenario puede leer, serían el sitio natural para un valor compartido como la sal de la clave de clientes, pero solo existen en los planes Pro, Teams y Enterprise. Trigo Lento está en Core. Además, la documentación advierte que sus valores no se cifran, se guardan en texto plano y los ven los administradores del equipo: no son un lugar para secretos.
La Figura 2 resume la tabla con tres objetos de oficina que Don Ernesto reconoció sin explicación: la nota adhesiva que se tira al terminar el día, el pizarrón del obrador que todos leen y nadie debería usar para apuntar contraseñas, y el archivero que guarda las fichas de los clientes de un año para otro.
Figura 2. Tres cajones de memoria: la nota adhesiva (variable de escenario, dura una ejecución), el pizarrón (variable personalizada, compartida y solo en planes Pro o superiores) y el archivero (almacén de datos, permanente, con 10 MB para repartir en Make Core).
La regla práctica que sale de la tabla y de la figura es sencilla. Lo que solo sirve durante una ejecución va en una variable; lo que debe sobrevivir a la ejecución va en un almacén; y lo que es secreto no va en ninguno de los dos, sino en una conexión o en el llavero de Make. “El de siempre” de Ramiro tiene que sobrevivir de un viernes al siguiente, así que su sitio es un almacén.
Errores típicos de JSON
Antes de construir nada, conviene saber reconocer un JSON roto cuando aparece, porque en Make el aviso llega como un error del módulo y en Python como una excepción, y en los dos casos la información útil es la misma: qué esperaba el analizador y en qué posición dejó de entender. El siguiente bloque escribe una función, diagnosticar, que intenta leer un texto como JSON y devuelve el mensaje de json.JSONDecodeError, la línea, la columna, la posición en caracteres y un fragmento alrededor del error. La función es más estricta que json.loads en un punto (rechaza NaN, que Python acepta por herencia de JavaScript pero que la norma no admite) y comprueba además un tipo esperado: que cantidad sea un número entero. La prueban diez casos, cada uno visto alguna vez en un escenario real:
# Casos típicos de JSON roto y su diagnóstico con json.JSONDecodeError en Python:
def rechazar_constante(nombre):
raise ValueError(f'constante no válida en JSON estricto: {nombre}')
def diagnosticar(texto, enteros = ('cantidad',)):
try:
datos = json.loads(texto, parse_constant = rechazar_constante)
except json.JSONDecodeError as error:
return {'mensaje': error.msg, 'línea': error.lineno, 'columna': error.colno, 'posición': error.pos,
'alrededor': repr(texto[max(0, error.pos - 7): error.pos + 5])}
except ValueError as error:
return {'mensaje': str(error), 'línea': '-', 'columna': '-', 'posición': '-', 'alrededor': '-'}
malos = [k for k in enteros if isinstance(datos, dict) and k in datos and not isinstance(datos[k], int)]
estado = f'válido, pero {malos[0]} es {type(datos[malos[0]]).__name__}' if malos else 'válido'
return {'mensaje': estado, 'línea': '-', 'columna': '-', 'posición': '-', 'alrededor': '-'}
rotos = {
'comillas sin escapar': '{"texto": "el de "siempre""}',
'coma final': '{"clave": "P01", "cantidad": 2,}',
'salto de línea literal': '{"texto": "2 baguettes\n1 pay"}',
'comillas simples': "{'clave': 'P01'}",
'clave sin comillas': '{clave: "P01"}',
'cuerpo vacío': '',
'texto alrededor (modelo)': 'Claro, aquí tiene: {"clave": "P01", "cantidad": 2}',
'cerca de markdown (modelo)': '```json\n{"clave": "P01", "cantidad": 2}\n```',
'NaN de una división': '{"clave": "P01", "precio_mxn": NaN}',
'número como texto': '{"clave": "P01", "cantidad": "2"}',
}
tabla2 = pd.DataFrame([{'caso': caso, **diagnosticar(texto)} for caso, texto in rotos.items()])
print(tabla2.to_string(index = False))
# Rescate de la respuesta de un modelo sin salida estructurada: del primer "{" al último "}":
def extraer_json(texto):
inicio, fin = texto.find('{'), texto.rfind('}')
return json.loads(texto[inicio:fin + 1]) if 0 <= inicio < fin else None
for caso in ('texto alrededor (modelo)', 'cerca de markdown (modelo)'):
print(f'Rescate de "{caso}":', extraer_json(rotos[caso]))
print('json.loads acepta NaN si no se lo impide:', json.loads('{"x": NaN}'))
caso mensaje línea columna posición alrededor
comillas sin escapar Expecting ',' delimiter 1 19 18 'el de "siemp'
coma final Expecting property name enclosed in double quotes 1 32 31 'ad": 2,}'
salto de línea literal Invalid control character at 1 23 22 'guettes\n1 pa'
comillas simples Expecting property name enclosed in double quotes 1 2 1 "{'clav"
clave sin comillas Expecting property name enclosed in double quotes 1 2 1 '{clave'
cuerpo vacío Expecting value 1 1 0 ''
texto alrededor (modelo) Expecting value 1 1 0 'Claro'
cerca de markdown (modelo) Expecting value 1 1 0 '```js'
NaN de una división constante no válida en JSON estricto: NaN - - - -
número como texto válido, pero cantidad es str - - - -
Rescate de "texto alrededor (modelo)": {'clave': 'P01', 'cantidad': 2}
Rescate de "cerca de markdown (modelo)": {'clave': 'P01', 'cantidad': 2}
json.loads acepta NaN si no se lo impide: {'x': nan}
Tabla 2. Diez casos de JSON roto o engañoso, con el mensaje de json.JSONDecodeError y la posición del error.
| Caso | Texto recibido (resumido) | Mensaje de Python | Línea | Columna | Posición | Causa y corrección |
|---|---|---|---|---|---|---|
| Comillas sin escapar | {"texto": "el de "siempre""} | Expecting ‘,’ delimiter | 1 | 19 | 18 | La comilla de “siempre” cierra la cadena; serializar con json.dumps o Create JSON |
| Coma final | {"clave": "P01", "cantidad": 2,} | Expecting property name enclosed in double quotes | 1 | 32 | 31 | JSON no admite comas finales; quitar la última |
| Salto de línea literal | Cadena escrita en dos líneas | Invalid control character at | 1 | 23 | 22 | El salto debe ir como \n; serializar |
| Comillas simples | {'clave': 'P01'} | Expecting property name enclosed in double quotes | 1 | 2 | 1 | Es la forma de imprimir de Python, no JSON |
| Clave sin comillas | {clave: "P01"} | Expecting property name enclosed in double quotes | 1 | 2 | 1 | Las claves siempre van entre comillas dobles |
| Cuerpo vacío | (nada) | Expecting value | 1 | 1 | 0 | La petición llegó sin cuerpo; filtrar antes de analizar |
| Texto alrededor (modelo) | Claro, aquí tiene: {...} | Expecting value | 1 | 1 | 0 | Un modelo sin salida estructurada añadió una frase |
| Cerca de markdown (modelo) | JSON entre tres acentos graves con la palabra json | Expecting value | 1 | 1 | 0 | El modelo envolvió el JSON en una cerca de código |
| NaN de una división | {"precio_mxn": NaN} | Constante no válida en JSON estricto: NaN | - | - | - | Una división entre cero en el origen; validar antes de enviar |
| Número como texto | {"cantidad": "2"} | Válido, pero cantidad es str | - | - | - | JSON correcto con el tipo equivocado; lo detecta una estructura de datos |
El primer caso es el ejemplo del plan y el del viernes: con el de "siempre" concatenado, el analizador lee "el de " como cadena completa y, en la posición $18$ (columna $19$, porque las columnas cuentan desde $1$), encuentra una s donde esperaba una coma o una llave. El mensaje, Expecting ',' delimiter, describe lo que el analizador esperaba, no lo que está mal, y por eso confunde la primera vez: la coma no falta; sobran unas comillas. La posición del error señala el primer carácter que el analizador no pudo interpretar, que casi nunca es el carácter culpable, sino el siguiente.
Tres casos merecen un comentario aparte:
- El salto de línea literal. Es el error más frecuente en escenarios que copian texto de un cliente, porque un mensaje de WhatsApp escrito en dos líneas lleva un salto real. En el cuerpo concatenado, la cadena se rompe en el salto, y el mensaje de Python,
Invalid control character at, apunta exactamente a él, la posición $22$. - Los dos casos del modelo. Un modelo de lenguaje al que se le pide JSON sin salida estructurada a veces lo envuelve en una frase de cortesía o en una cerca de markdown. Las dos variantes fallan en la posición $0$ con
Expecting value, porque el primer carácter ya no es JSON. La funciónextraer_jsondel bloque los rescata tomando el texto entre la primera llave de apertura y la última de cierre, y la salida muestra que funciona; pero es un parche. La solución de fondo, que el módulo 16 del escenario usará, es pedir salida estructurada con un esquema (output_configconformatde tipojson_schema), de modo que la respuesta sea JSON válido por construcción. - El número como texto.
{"cantidad": "2"}es JSON impecable y ningún analizador se quejará, pero si un módulo posterior suma cantidades, sumará textos. Este es el error que no se ve en el historial y que solo detecta una estructura de datos con tipos declarados, como la del almacén de clientes.
En Make, el equivalente de esta tabla aparece en el historial de ejecuciones: Parse JSON detiene el escenario con un error de datos cuando recibe un texto que no es JSON, y un módulo HTTP recibe un $400$ cuando lo que envía no lo es. La diferencia práctica con Python es que en Make no hay try alrededor de cada módulo; hay manejadores de errores, que serán el tema de mañana.
Construir cuerpos seguros
El cuerpo que rompió el viernes estaba escrito como lo escribe casi todo el mundo la primera vez: una plantilla de texto con el JSON fijo y el dato del cliente pegado en medio. La Figura 3 muestra qué le pasa a esa plantilla cuando el dato trae una comilla, y por qué la misma operación, hecha con un serializador, no se rompe nunca.
Figura 3. La comilla que rompe todo: concatenado, el mensaje de Ramiro cierra la cadena antes de tiempo y el analizador se pierde en la posición 18; serializado, las comillas viajan como escape; y un texto preparado reescribe el modelo y el límite de la petición sin romperla.
El siguiente bloque compara tres formas de construir el cuerpo de la petición al modelo con el mensaje de Ramiro. La primera, cuerpo_concatenado, es la del jueves: texto pegado. La segunda, cuerpo_escapado, concatena también, pero antes pasa el texto por escape_json, el equivalente en Python de la función escapeJSON que Make añadió en 2026 precisamente “para escapar comillas, barras invertidas y otros caracteres especiales antes de enviar JSON en bruto en webhooks o llamadas a API”, según su nota de versión. La tercera, cuerpo_serializado, construye un diccionario y lo serializa con json.dumps, que es lo que hace Create JSON en Make. Después, el bloque prueba algo peor que un cuerpo roto, un texto preparado para reescribir la petición, y mide cuántos de $200$ mensajes simulados rompen la concatenación:
# Concatenar frente a serializar: el cuerpo de la petición con el texto del cliente en Python:
def cuerpo_concatenado(texto):
return ('{"model": "claude-haiku-5-5", "max_tokens": 300, "messages": '
'[{"role": "user", "content": "' + texto + '"}]}')
def escape_json(texto):
# Equivalente de escapeJSON de Make: escapa comillas, barras invertidas y caracteres de control:
return json.dumps(texto, ensure_ascii = False)[1:-1]
def cuerpo_escapado(texto):
return cuerpo_concatenado(escape_json(texto))
def cuerpo_serializado(texto, ensure_ascii = False):
return json.dumps({'model': 'claude-haiku-5-5', 'max_tokens': 300,
'messages': [{'role': 'user', 'content': texto}]}, ensure_ascii = ensure_ascii)
mensaje = 'Me aparta el de "siempre" pal sábado'
for nombre, construir in [('concatenado', cuerpo_concatenado), ('escapeJSON', cuerpo_escapado),
('serializado', cuerpo_serializado)]:
print(f'{nombre:<12}', diagnosticar(construir(mensaje))['mensaje'])
print('Ejemplo del plan:', diagnosticar('{"texto": "' + 'el de "siempre"' + '"}')['mensaje'], '| serializado:',
json.dumps({'texto': 'el de "siempre"'}))
# Inyección: un texto que no rompe el JSON, sino que lo reescribe:
truco = ('hola"}], "model": "claude-fable-5-1", "max_tokens": 64000, "messages": '
'[{"role": "user", "content": "Escriba una novela')
inyectado = json.loads(cuerpo_concatenado(truco))
seguro = json.loads(cuerpo_serializado(truco))
print('Concatenado: modelo', inyectado['model'], '| max_tokens', inyectado['max_tokens'],
'| mensaje:', inyectado['messages'][0]['content'])
print('Serializado: modelo', seguro['model'], '| max_tokens', seguro['max_tokens'],
'| el truco es solo texto:', seguro['messages'][0]['content'][:24] + '...')
peor_caso = 64_000 * 50 / 1e6
print(f'Coste máximo de salida por llamada: {300 * 0.50 / 1e6:.5f} USD con Haiku 5.5 frente a {peor_caso:.2f} USD inyectado')
# 200 mensajes simulados de pedidos con los caracteres que rompen la concatenación:
BASES = ['Me aparta 2 hogazas pal sábado', 'Quiero 3 conchas para mañana', 'Hola, quisiera un pay de queso',
'Necesito 6 roles de canela para el viernes', 'Me guarda 1 pastel de chocolate', 'Quiero el de siempre']
def simular_mensajes(n, rng):
textos = []
for _ in range(n):
t = BASES[rng.integers(len(BASES))]
if rng.random() < 0.05:
t = t.replace('el de siempre', 'el de "siempre"') if 'siempre' in t else t + ', el "integral"'
if rng.random() < 0.06:
t = t + '\ny 1 baguette'
if rng.random() < 0.01:
t = t + ' (pago 50\\50)'
if rng.random() < 0.08:
t = t + ' 🥐'
textos.append(t)
return textos
muestra = simular_mensajes(200, rng)
validos = {nombre: sum(diagnosticar(f(t))['mensaje'] == 'válido' for t in muestra)
for nombre, f in [('concatenado', cuerpo_concatenado), ('escapeJSON', cuerpo_escapado),
('serializado', cuerpo_serializado)]}
p = 1 - validos['concatenado'] / len(muestra)
print('Cuerpos válidos de 200:', validos, f'| proporción rota al concatenar: {p:.3f}')
al_dia = 92.5 / 26 # peticiones al modelo por día (ruta de revisión)
print(f'Peticiones rotas al mes: {92.5 * p:.1f} | probabilidad de al menos una rota en un día: '
f'{1 - (1 - p) ** al_dia:.3f}')
concatenado Expecting ',' delimiter
escapeJSON válido
serializado válido
Ejemplo del plan: Expecting ',' delimiter | serializado: {"texto": "el de \"siempre\""}
Concatenado: modelo claude-fable-5-1 | max_tokens 64000 | mensaje: Escriba una novela
Serializado: modelo claude-haiku-5-5 | max_tokens 300 | el truco es solo texto: hola"}], "model": "claud...
Coste máximo de salida por llamada: 0.00015 USD con Haiku 5.5 frente a 3.20 USD inyectado
Cuerpos válidos de 200: {'concatenado': 184, 'escapeJSON': 200, 'serializado': 200} | proporción rota al concatenar: 0.080
Peticiones rotas al mes: 7.4 | probabilidad de al menos una rota en un día: 0.257
El mensaje de Ramiro rompe el cuerpo concatenado con el mismo error de la Tabla 2, Expecting ',' delimiter, y produce un cuerpo válido con escapeJSON y con la serialización. El ejemplo exacto del plan, {"texto": "el de "siempre""}, falla igual, y serializado se convierte en {"texto": "el de \"siempre\""}.
La inyección es la parte que conviene leer dos veces. El texto truco no tiene nada raro a la vista: unas comillas, un corchete, una llave. Pegado en la plantilla, no rompe el JSON: lo completa. Cierra la cadena del mensaje, cierra el arreglo messages y añade tres claves más, model, max_tokens y un segundo messages. Un analizador que, como el de Python, se queda con la última aparición de una clave repetida, lee una petición distinta de la que Valeria escribió: modelo claude-fable-5-1 en lugar de claude-haiku-5-5, $64,000$ tokens de salida en lugar de $300$ y un mensaje que pide “Escriba una novela”. Con los precios de la serie, el techo de coste de salida de una llamada pasa de $0.00015$ USD a $3.20$ USD, más de veinte mil veces más. Serializado, el mismo texto es solo eso, texto: el modelo sigue siendo Haiku 5.5, el límite sigue en $300$ y el truco queda dentro del contenido del mensaje, donde no puede cambiar la estructura.
Atención aquí: concatenar texto de un cliente dentro de un JSON no es solo una fuente de errores; es una inyección, la misma familia de vulnerabilidades que la inyección de SQL y que OWASP clasifica como A03 en su lista de riesgos. Un texto preparado puede cambiar el modelo, el límite de salida o las instrucciones de una petición sin que el escenario dé ningún error, y la cuenta llega a fin de mes. En Make, construya los cuerpos con Create JSON (o con el cuerpo de tipo estructura de datos del módulo HTTP) y, si no tiene más remedio que escribir JSON en bruto, envuelva cada dato mapeado con escapeJSON, no solo el que le dio problemas. Ponga además un tope de gasto en la consola del proveedor del modelo: es la última defensa cuando falla todo lo demás.
La última parte del bloque pone números al riesgo cotidiano, el de las comillas y los saltos de línea que los clientes escriben sin mala intención. En la muestra de $200$ pedidos simulados, con un $5%$ de mensajes con comillas, un $6%$ escritos en dos líneas, un $1%$ con barra invertida y un $8%$ con algún emoji (supuestos de juguete), la concatenación produce $184$ cuerpos válidos y $16$ rotos, el $8.0%$. Con escapeJSON y con la serialización, los $200$ son válidos. Los emojis no rompen nada: son caracteres normales para JSON; lo que rompe son las comillas, los saltos y las barras.
Con esa proporción, la probabilidad de que el escenario se detenga al menos una vez en un día depende de cuántas peticiones hace:
$$ P(\text{al menos un cuerpo roto en un día}) = 1 - (1 - p)^{n} $$
La lógica detrás de esta fórmula es múltiple:
- $p = 0.08$ es la probabilidad de que un mensaje rompa el cuerpo concatenado, medida en la muestra, y se supone independiente de un mensaje a otro.
- $n$ es el número de peticiones al modelo en un día. Solo los pedidos que las reglas no normalizan llegan a la ruta de revisión: el $36%$ de los $257$ pedidos de WhatsApp, $92.5$ al mes y $n = 92.5 / 26 \approx 3.6$ al día.
- $(1 - p)^n$ es la probabilidad de que las $n$ peticiones del día salgan bien; su complemento, la de que al menos una falle.
- En conclusión, con $p = 0.08$ y $n = 3.6$, la probabilidad es $0.257$: uno de cada cuatro días, el escenario del jueves se habría detenido, con unas $7.4$ peticiones rotas al mes. El viernes de Ramiro no fue mala suerte; fue lo esperable.
Make ofrece dos formas de construir un cuerpo seguro, y las dos producen el mismo resultado que el bloque:
- Create JSON y el cuerpo mapeado entero. Se declara una estructura de datos para el cuerpo de la petición (modelo, límite de tokens, mensajes, configuración de salida), se rellenan sus campos en el módulo Create JSON y, en el módulo HTTP, el contenido de la petición es una sola referencia:
{{16.json}}. Las versiones recientes del módulo HTTP permiten además elegirapplication/jsoncomo tipo de contenido y una estructura de datos como método de entrada del cuerpo, con el mismo efecto. Es la forma que usaTL Pedidos WhatsApp v2.2. - JSON en bruto con
escapeJSONen cada dato. Se conserva la plantilla de texto, pero cada referencia mapeada se envuelve:"content": "{{escapeJSON(4.texto)}}". Es útil para cambios rápidos, pero depende de no olvidar ningún campo, y un validador lo comprueba peor.
El almacén de clientes
Con los cuerpos resueltos, queda la memoria. Valeria quiere que el escenario recuerde, de cada cliente de WhatsApp, lo justo para atender mejor el siguiente pedido: cómo llamarle, cuántos pedidos lleva, cuándo fue el último, si dio su consentimiento para recibir avisos por WhatsApp y cuál es su producto habitual. Nada más. El teléfono completo, la dirección y el historial de conversaciones viven solo en la base principal de Trigo Lento, con acceso restringido, y no se copian a Make.
Un almacén de datos de Make es una tabla pequeña con una clave única por registro y los campos de una estructura de datos. La documentación de Make, revisada en octubre de 2026, fija sus reglas: si no se elige una estructura, el almacén solo guarda la clave (sirve para saber si algo existe, como el de recordatorios del Día 26); añadir un registro con una clave que ya existe da error, salvo que se active la opción de sobrescribir; un registro puede ocupar hasta 15 MB; una organización puede tener hasta $1,000$ almacenes; y renombrar un campo de la estructura deja inaccesibles los datos que ya tenía, porque el nombre del campo es su identificador. Esa última regla obliga a pensar los nombres antes de crear el almacén.
La clave del registro merece su propia decisión. Usar el teléfono tal cual sería guardar un dato personal como identificador. Usar su sha256 parece anónimo, pero no lo es: los teléfonos móviles de la Ciudad de México tienen $10$ dígitos, y quien conozca el formato puede calcular el hash de todos los números posibles y deshacer la seudonimización en poco tiempo. La solución es un HMAC (por sus siglas en inglés: “Hash-based Message Authentication Code”, código de autenticación de mensajes basado en hash): el hash del número calculado con una sal secreta que solo conoce Trigo Lento. Make lo permite sin módulos extra, porque su función sha256 acepta una clave como tercer argumento y entonces devuelve el HMAC: sha256(1.WaId; "hex"; 13.data.sal_clientes). Valeria añade el campo sal_clientes al registro de configuración que el módulo $13$ ya lee en la versión 2.1.
Atención aquí: una clave seudonimizada sigue siendo un dato personal según la LFPDPPP y el RGPD, porque con la sal se puede volver a asociar a la persona. La sal es un secreto: no la escriba en un blueprint, no la ponga en una variable personalizada (se guardan en texto plano y las ven los administradores) y no la comparta en capturas del historial. Si alguna vez se filtra, cámbiela y regenere las claves, aunque eso obligue a reconstruir el almacén. Y no guarde en el almacén nada que no haga falta para la tarea: el teléfono completo, la dirección y el texto de los mensajes no tienen por qué estar ahí.
La estructura de datos del almacén se declara como un documento JSON. Este es el que Valeria guarda en trigo_lento/dia_27/flujos/make/clientes.estructura.json, con el formato de la referencia de la API de Make (nombre, opción estricta y una lista de campos con nombre, tipo, etiqueta y obligatoriedad):
{
"name": "TL Clientes",
"strict": true,
"spec": [
{"name": "nombre_pila", "type": "text", "label": "Nombre de pila", "required": true, "multiline": false},
{"name": "pedidos", "type": "number", "label": "Pedidos acumulados", "required": true, "default": 0},
{"name": "ultimo_pedido", "type": "date", "label": "Fecha del último pedido", "required": true},
{"name": "consentimiento_whatsapp", "type": "boolean", "label": "Consentimiento para avisos por WhatsApp", "required": true, "default": false},
{"name": "producto_habitual", "type": "text", "label": "Clave del producto habitual", "required": false, "multiline": false}
]
}
La estructura es estricta ("strict": true), y esa decisión es la más importante del archivo: un almacén estricto rechaza cualquier campo que no esté declarado, de modo que si un día alguien mapea por error el teléfono en el módulo de guardar, el registro no se guarda y el error aparece en el historial. La minimización de datos deja de ser una intención y se convierte en una regla que Make aplica. La Tabla 3 resume la estructura con la clave, los tipos y un ejemplo.
Tabla 3. Estructura de datos del almacén de clientes (TL Clientes), con la clave seudonimizada y un registro de ejemplo.
| Campo | Tipo | Obligatorio | Ejemplo (Ramiro) | Para qué sirve |
|---|---|---|---|---|
| Clave del registro | Texto (HMAC-SHA256 en hexadecimal) | Sí | 4a27f50f14e7... | Encontrar al cliente sin guardar su teléfono |
nombre_pila | text | Sí | Ramiro | Saludar por el nombre en la confirmación |
pedidos | number | Sí | 6 | Contador de pedidos; distinguir clientes nuevos y frecuentes |
ultimo_pedido | date | Sí | 2026-02-10T16:00 | Saber cuándo pidió por última vez |
consentimiento_whatsapp | boolean | Sí | true | Filtrar avisos, como el recordatorio del Día 26 |
producto_habitual | text | No | P01 | Resolver “el de siempre” |
El siguiente bloque simula el almacén con sqlite3. La clase Almacen reproduce las tres operaciones que usará el escenario: get (como datastore:GetRecord), add (como datastore:AddRecord, con error si la clave existe y no se pide sobrescribir) y update (un add con sobrescritura sobre el registro leído). Antes de guardar, valida el registro contra la estructura con las reglas de la opción estricta. El bloque comprueba también que la función sha256 de Python coincide con el ejemplo de la documentación de Make, y después registra $30$ pedidos de $12$ clientes inventados, con Ramiro pidiendo siempre su hogaza (P01):
# Almacén de clientes simulado con sqlite3: get, add y update con la estructura TL Clientes en Python:
estructura = json.loads((flujos / 'clientes.estructura.json').read_text(encoding = 'utf-8'))
TIPOS_PY = {'text': str, 'number': (int, float), 'date': str, 'boolean': bool}
def validar_registro(datos, estructura):
errores, nombres = [], {c['name'] for c in estructura['spec']}
if estructura['strict']:
errores += [f'campo no declarado: {k}' for k in datos if k not in nombres]
for c in estructura['spec']:
valor = datos.get(c['name'])
if valor is None:
errores += [f"falta el campo obligatorio {c['name']}"] if c.get('required') else []
elif not isinstance(valor, TIPOS_PY[c['type']]) or (c['type'] == 'number' and isinstance(valor, bool)):
errores.append(f"{c['name']} debe ser {c['type']}")
return errores
class Almacen:
# Un almacén de datos de Make: una clave por registro y los campos de su estructura:
def __init__(self, ruta, estructura):
self.bd = sqlite3.connect(ruta)
self.estructura = estructura
self.bd.execute('DROP TABLE IF EXISTS registros')
self.bd.execute('CREATE TABLE registros (clave TEXT PRIMARY KEY, datos TEXT NOT NULL)')
def get(self, clave):
fila = self.bd.execute('SELECT datos FROM registros WHERE clave = ?', (clave,)).fetchone()
return json.loads(fila[0]) if fila else None
def add(self, clave, datos, sobrescribir = False):
errores = validar_registro(datos, self.estructura)
if errores:
raise ValueError('; '.join(errores))
if self.get(clave) is not None and not sobrescribir:
raise ValueError(f'la clave {clave[:10]}... ya existe y no se pidió sobrescribir')
self.bd.execute('INSERT OR REPLACE INTO registros VALUES (?, ?)',
(clave, json.dumps(datos, ensure_ascii = False)))
def update(self, clave, cambios):
self.add(clave, {**self.get(clave), **cambios}, sobrescribir = True)
def bytes(self):
return sum(len(c.encode()) + len(d.encode()) for c, d in self.bd.execute('SELECT * FROM registros'))
# Clave seudonimizada: HMAC-SHA256 del WaId con una sal secreta, como sha256(texto; "hex"; clave) en Make:
SAL = os.environ.get('TL_SAL_CLIENTES', 'sal-de-ejemplo-del-dia-27').encode()
def clave_cliente(wa_id):
return hmac.new(SAL, wa_id.encode(), hashlib.sha256).hexdigest()
print('sha256("Make") coincide con la documentación:',
hashlib.sha256(b'Make').hexdigest() == 'ccdd25d4230fb997a7ee1166f8afabd157248eb812ea55ec7c3d1b7c8af7fa11')
NOMBRES = ['Lucía', 'Ramiro', 'Paty', 'Jorge', 'Inés', 'Tomás', 'Elena', 'Beto', 'Carmen', 'Raúl', 'Nora', 'Iván']
PESOS = np.array([6, 5, 4, 3, 2, 2, 2, 1, 1, 1, 1, 1], dtype = float)
CLAVES = ['P01', 'P02', 'P03', 'P04', 'P05', 'P06', 'P07', 'P08', 'P09', 'P10', 'P11', 'P12']
almacen = Almacen(carpeta / 'almacen_clientes.sqlite', estructura)
inicio = datetime(2026, 2, 2, 10, 0)
for k in range(30):
i = int(rng.choice(len(NOMBRES), p = PESOS / PESOS.sum()))
clave = clave_cliente(f'52155000000{i + 10:02d}')
producto = 'P01' if NOMBRES[i] == 'Ramiro' else CLAVES[rng.integers(len(CLAVES))]
cuando = (inicio + timedelta(days = k // 3, hours = 3 * (k % 3))).isoformat(timespec = 'minutes')
previo = almacen.get(clave) # datastore:GetRecord
if previo is None: # datastore:AddRecord
almacen.add(clave, {'nombre_pila': NOMBRES[i], 'pedidos': 1, 'ultimo_pedido': cuando,
'consentimiento_whatsapp': bool(rng.random() < 0.7), 'producto_habitual': producto})
else: # Add/Replace con sobrescritura
almacen.update(clave, {'pedidos': previo['pedidos'] + 1, 'ultimo_pedido': cuando,
'producto_habitual': producto})
almacen.bd.commit()
estado = pd.DataFrame([{'clave': c[:12] + '...', **json.loads(d)} for c, d in
almacen.bd.execute('SELECT * FROM registros')]).sort_values('pedidos', ascending = False)
print(estado.head(6).to_string(index = False))
n_reg = len(estado)
print(f'Registros: {n_reg} | pedidos acumulados: {estado.pedidos.sum()} | bytes: {almacen.bytes()} '
f'({almacen.bytes() / n_reg:.0f} por registro)')
# Lo que el almacén se niega a guardar:
ramiro = clave_cliente('5215500000011')
for intento, accion in [('duplicar la clave', lambda: almacen.add(ramiro, almacen.get(ramiro))),
('guardar el teléfono', lambda: almacen.update(ramiro, {'telefono': '+52 55 0000 0011'})),
('pedidos como texto', lambda: almacen.update(ramiro, {'pedidos': '7'}))]:
try:
accion()
print(f'{intento:<20} -> aceptado')
except ValueError as error:
print(f'{intento:<20} -> rechazado: {error}')
print('Ramiro, "el de siempre":', almacen.get(ramiro)['producto_habitual'])
sha256("Make") coincide con la documentación: True
clave nombre_pila pedidos ultimo_pedido consentimiento_whatsapp producto_habitual
4a27f50f14e7... Ramiro 6 2026-02-10T16:00 True P01
b98486dd1a3c... Paty 5 2026-02-11T16:00 False P04
4b3b1a397427... Lucía 5 2026-02-11T10:00 True P01
7978df3684c0... Inés 4 2026-02-09T16:00 True P12
7b7d81caea32... Jorge 3 2026-02-06T16:00 False P01
6f86d5d2d0f4... Tomás 2 2026-02-08T10:00 True P03
Registros: 9 | pedidos acumulados: 30 | bytes: 1804 (200 por registro)
duplicar la clave -> rechazado: la clave 4a27f50f14... ya existe y no se pidió sobrescribir
guardar el teléfono -> rechazado: campo no declarado: telefono
pedidos como texto -> rechazado: pedidos debe ser number
Ramiro, "el de siempre": P01
La primera línea confirma que hashlib.sha256 devuelve, para el texto “Make”, el mismo resultado que publica el centro de ayuda de Make: la función que el escenario usará y la de la simulación son la misma. Tras $30$ pedidos, el almacén tiene $9$ registros (tres de los doce clientes posibles no pidieron en estas dos semanas) y ocupa $1,804$ bytes, unos $200$ bytes por registro, de los que $64$ son la clave en hexadecimal. Ramiro encabeza la lista con $6$ pedidos y su producto habitual es P01, la hogaza de masa madre: lo que Don Ernesto sabía de memoria ya está escrito.
Las tres pruebas finales muestran que el almacén se defiende solo. Duplicar la clave sin pedir sobrescritura se rechaza, como en Make. Guardar el teléfono se rechaza porque telefono no está en la estructura estricta. Y guardar los pedidos como texto ("7" en lugar de $7$) se rechaza porque el campo es numérico: es el caso “número como texto” de la Tabla 2, detenido esta vez antes de que llegue a ninguna suma.
¿Cabe en un mega?
Un almacén de Make no crece sin límite: se crea con un tamaño, como mínimo 1 MB, y ese tamaño se descuenta de los $10$ MB que Core reparte entre todos los almacenes de Trigo Lento. Hoy ya hay tres (configuración y catálogo, recordatorios enviados y clientes), así que conviene saber si 1 MB alcanza para el de clientes y por cuánto tiempo.
La respuesta depende de cuántos clientes distintos acumula el almacén, y eso no crece al ritmo de los pedidos, porque los clientes frecuentes repiten. Si los pedidos se repartieran por igual entre $M$ clientes posibles, el número esperado de clientes distintos tras $N$ pedidos y el tamaño del almacén serían:
$$ E[U(N)] = M\left(1 - \left(1 - \frac{1}{M}\right)^{N}\right), \qquad S = U \cdot b $$
La lógica detrás de esta fórmula es múltiple:
- $(1 - 1/M)^N$ es la probabilidad de que un cliente concreto no aparezca en ninguno de los $N$ pedidos, si cada pedido elige cliente al azar entre $M$.
- $M$ por el complemento es el número esperado de clientes que sí aparecieron, es decir, de registros en el almacén.
- $S$ es el tamaño: $U$ registros de $b$ bytes cada uno; con el registro mínimo del Bloque 6, $b \approx 200$.
- En conclusión, el almacén crece rápido al principio, cuando casi todo cliente es nuevo, y se aplana cuando la mayoría de los pedidos vienen de clientes conocidos. La fórmula uniforme es un techo: con clientes frecuentes y ocasionales, como en una panadería real, los clientes distintos son menos.
El siguiente bloque simula doce meses de pedidos por WhatsApp ($257$ de media al mes, con llegadas de Poisson) entre $900$ clientes posibles con pesos desiguales (unos pocos muy frecuentes y muchos ocasionales, un supuesto de juguete), compara el número de clientes distintos con la fórmula uniforme y proyecta dos diseños: el registro mínimo de la Tabla 3 y un registro con historial, que además guardara cada mensaje del cliente con su fecha (unos $110$ bytes por mensaje), que es lo que muchos escenarios acaban haciendo “por si acaso”:
# Crecimiento del almacén de clientes en 12 meses simulados frente al tamaño reservado en Python:
POBLACION = 900 # clientes posibles por WhatsApp (supuesto)
pesos = 1 / np.arange(1, POBLACION + 1) ** 0.8 # pocos clientes frecuentes y muchos ocasionales
pesos = pesos / pesos.sum()
bytes_minimo = almacen.bytes() / n_reg # registro mínimo medido en el Bloque 6
BYTES_POR_MENSAJE = 110 # texto del mensaje y su fecha, si se guardara el historial
vistos, mensajes_por_cliente = set(), np.zeros(POBLACION)
filas = []
for mes in range(1, 13):
n = rng.poisson(PEDIDOS_WHATSAPP_MES)
elegidos = rng.choice(POBLACION, size = n, p = pesos)
vistos.update(elegidos.tolist())
np.add.at(mensajes_por_cliente, elegidos, 1)
total = int(mensajes_por_cliente.sum())
uniforme = POBLACION * (1 - (1 - 1 / POBLACION) ** total)
filas.append({'mes': mes, 'pedidos': total, 'clientes': len(vistos), 'uniforme': round(uniforme),
'kB mínimo': len(vistos) * bytes_minimo / 1000,
'kB con historial': (len(vistos) * bytes_minimo + total * BYTES_POR_MENSAJE) / 1000})
crecimiento = pd.DataFrame(filas).set_index('mes')
print(crecimiento.round(1).to_string())
ultimo = crecimiento.iloc[-1]
print(f"Uso del almacén de 1 MB a los 12 meses: {ultimo['kB mínimo'] / 10:.1f} % (mínimo) frente a "
f"{ultimo['kB con historial'] / 10:.1f} % (con historial)")
ritmo = (ultimo['kB con historial'] - crecimiento.iloc[0]['kB con historial']) / 11
print(f'Con historial crece {ritmo:.1f} kB al mes: el almacén se llenaría hacia el mes {1000 / ritmo:.0f}')
fig, ax = plt.subplots(figsize = (10, 5))
ax.plot(crecimiento.index, crecimiento['kB mínimo'], marker = 'o', color = '#2a78d6', lw = 2,
label = 'Registro mínimo (clave seudonimizada y cinco campos)')
ax.plot(crecimiento.index, crecimiento['kB con historial'], marker = 's', color = '#eb6834', lw = 2,
label = 'Registro con el historial de mensajes')
ax.axhline(1000, color = '#c8423b', ls = '--', lw = 1.5)
ax.text(1, 1015, 'Tamaño reservado: 1 MB', color = '#c8423b', fontsize = 10)
for col, color in [('kB mínimo', '#2a78d6'), ('kB con historial', '#eb6834')]:
ax.annotate(f"{ultimo[col]:.0f} kB", (12, ultimo[col]), textcoords = 'offset points', xytext = (-10, 10),
ha = 'right', color = color, fontsize = 10)
ax.set_xticks(range(1, 13))
ax.set_ylim(0, 1100)
ax.set_xlabel('Meses de operación')
ax.set_ylabel('Tamaño del almacén (kB)')
ax.set_title('Almacén de clientes: 12 meses simulados', fontsize = 16)
ax.legend(fontsize = 9, loc = 'upper left', bbox_to_anchor = (0.01, 0.9))
plt.tight_layout()
plt.show()
pedidos clientes uniforme kB mínimo kB con historial
mes
1 237 155 208 31.1 57.1
2 488 271 377 54.3 108.0
3 769 362 517 72.6 157.2
4 1026 432 612 86.6 199.5
5 1274 498 682 99.8 240.0
6 1515 554 733 111.0 277.7
7 1786 583 776 116.9 313.3
8 2066 621 809 124.5 351.7
9 2335 652 833 130.7 387.5
10 2582 674 849 135.1 419.1
11 2854 702 862 140.7 454.7
12 3113 721 872 144.5 487.0
Uso del almacén de 1 MB a los 12 meses: 14.5 % (mínimo) frente a 48.7 % (con historial)
Con historial crece 39.1 kB al mes: el almacén se llenaría hacia el mes 26

A los doce meses, $3,113$ pedidos han venido de $721$ clientes distintos, frente a los $872$ que daría la fórmula uniforme: la diferencia son los clientes frecuentes, que repiten sin crear registros nuevos. Con el registro mínimo, el almacén ocupa $144.5$ kB, el $14.5%$ de 1 MB, y su curva se aplana, como predice la fórmula. Con el historial, ocupa $487$ kB, el $48.7%$, y crece en línea recta, unos $39$ kB al mes, porque cada pedido añade bytes aunque el cliente ya exista: hacia el mes $26$ llenaría el almacén.
La figura enseña la diferencia de forma más clara que las cifras. Las dos curvas empiezan juntas, pero la azul se curva y la naranja no. Un almacén que guarda estado crece con los clientes; uno que guarda historia crece con el tiempo. El primero cabe en 1 MB durante años; el segundo obliga, tarde o temprano, a ampliar el almacén, a podarlo con un escenario de limpieza o a pagar un plan mayor, y además acumula conversaciones de clientes que nadie necesita en Make. La minimización que pide la ética de la serie es también la decisión más barata.
TL Pedidos WhatsApp v2.2
Con el formato, la memoria y el almacén resueltos, Valeria monta la versión nueva del escenario. Parte de TL Pedidos WhatsApp v2.1, la del Día 25, y solo cambia el final de la ruta de pedidos, a partir del módulo $8$, que normaliza el pedido:
- Módulo $14$, leer cliente (
datastore:GetRecord): antes del router, busca el registro del cliente con la clave HMAC. Va antes del router para que las dos rutas puedan usar lo que encuentre: el contador en la de pedidos completos y el producto habitual en la de revisión. - Ruta “Completo”: la fila en la hoja
Pedidos(módulo $10$) añade una columna con el número de pedido del cliente, y el módulo $15$ (datastore:AddRecordcon sobrescritura) guarda el registro actualizado después de escribir el pedido, con el mismo criterio que el Día 26: no se apunta lo que todavía no ocurrió. - Ruta “Falta algo”: la fila en la hoja
Revisión(módulo $11$) se escribe primero, la lección más directa del viernes: si la consulta al modelo falla, el pedido no se pierde. Después, Create JSON (módulo $16$) construye el cuerpo de la petición con el texto del pedido, la fecha del obrador y el producto habitual, sin teléfono ni nombre; el módulo HTTP ($17$) lo envía a la API de Claude con el cuerpo mapeado entero,{{16.json}}, y la clave en la conexión; Parse JSON ($18$) convierte en campos el texto que devuelve el modelo, solo si la respuesta fue un $200$ constop_reasonigual aend_turn; y la fila en la hojaSugerencias($19$) deja la propuesta del modelo para que Valeria la revise.
El fragmento con esos cambios, guardado en trigo_lento/dia_27/flujos/make/pedidos_whatsapp_v2_2.fragmento.json, es este:
{
"name": "TL Pedidos WhatsApp v2.2 (fragmento: módulo 14 y el router 9 con sus dos rutas)",
"flow": [
{
"id": 14,
"module": "datastore:GetRecord",
"version": 1,
"parameters": {"datastore": 0},
"mapper": {"key": "{{sha256(1.WaId; \"hex\"; 13.data.sal_clientes)}}"},
"metadata": {"designer": {"x": 2700, "y": 150}}
},
{
"id": 9,
"module": "builtin:BasicRouter",
"version": 1,
"parameters": {},
"mapper": null,
"metadata": {"designer": {"x": 3000, "y": 150}},
"routes": [
{"flow": [
{
"id": 10,
"module": "google-sheets:addRow",
"version": 2,
"parameters": {"__IMTCONN__": 0},
"filter": {
"name": "Completo",
"conditions": [[
{"a": "{{8.fecha_entrega}}", "o": "exist"},
{"a": "{{8.telefono_valido}}", "b": "true", "o": "text:equal"},
{"a": "{{8.productos_reconocidos}}", "b": "true", "o": "text:equal"}
]]
},
"mapper": {"mode": "select", "spreadsheetId": "/ID_DE_EJEMPLO", "sheetId": "Pedidos", "includesHeaders": true, "valueInputOption": "RAW", "values": {"0": "{{1.MessageSid}}", "1": "{{8.telefono}}", "2": "{{8.fecha_entrega}}", "3": "{{join(map(7.array; \"clave\"); \", \")}}", "4": "{{8.total_centavos}}", "5": "{{ifempty(14.data.pedidos; 0) + 1}}"}},
"metadata": {"designer": {"x": 3300, "y": 0}}
},
{
"id": 15,
"module": "datastore:AddRecord",
"version": 1,
"parameters": {"datastore": 0},
"mapper": {
"key": "{{sha256(1.WaId; \"hex\"; 13.data.sal_clientes)}}",
"overwrite": true,
"data": {
"nombre_pila": "{{ifempty(14.data.nombre_pila; first(split(1.ProfileName; \" \")))}}",
"pedidos": "{{ifempty(14.data.pedidos; 0) + 1}}",
"ultimo_pedido": "{{now}}",
"consentimiento_whatsapp": "{{ifempty(14.data.consentimiento_whatsapp; false)}}",
"producto_habitual": "{{first(map(7.array; \"clave\"))}}"
}
},
"metadata": {"designer": {"x": 3600, "y": 0}}
}
]},
{"flow": [
{
"id": 11,
"module": "google-sheets:addRow",
"version": 2,
"parameters": {"__IMTCONN__": 0},
"filter": {
"name": "Falta algo",
"conditions": [
[{"a": "{{8.fecha_entrega}}", "o": "notexist"}],
[{"a": "{{8.telefono_valido}}", "b": "false", "o": "text:equal"}],
[{"a": "{{8.productos_reconocidos}}", "b": "false", "o": "text:equal"}]
]
},
"mapper": {"mode": "select", "spreadsheetId": "/ID_DE_EJEMPLO", "sheetId": "Revisión", "includesHeaders": true, "valueInputOption": "RAW", "values": {"0": "{{1.MessageSid}}", "1": "{{4.texto}}", "2": "normalización incompleta"}},
"metadata": {"designer": {"x": 3300, "y": 300}}
},
{
"id": 16,
"module": "json:CreateJSON",
"version": 1,
"parameters": {"type": 0, "space": ""},
"mapper": {
"model": "claude-haiku-5-5",
"max_tokens": 300,
"system": "Eres el asistente de pedidos de una panadería. Devuelve solo lo que el texto permita saber; si falta un dato, escribe una pregunta en usted.",
"messages": [{"role": "user", "content": "Fecha del obrador: {{formatDate(now; \"YYYY-MM-DD\"; \"America/Mexico_City\")}}. Producto habitual: {{ifempty(14.data.producto_habitual; \"desconocido\")}}. Pedido: {{4.texto}}"}],
"output_config": {"effort": "low", "format": {"type": "json_schema", "schema": {"type": "object", "properties": {"productos": {"type": "array", "items": {"type": "string"}}, "fecha_entrega": {"type": ["string", "null"]}, "pregunta": {"type": ["string", "null"]}}, "required": ["productos", "fecha_entrega", "pregunta"], "additionalProperties": false}}}
},
"metadata": {"designer": {"x": 3600, "y": 300}}
},
{
"id": 17,
"module": "http:ActionSendData",
"version": 3,
"parameters": {"handleErrors": true, "__IMTCONN__": 0},
"mapper": {"url": "https://api.anthropic.com/v1/messages", "method": "post", "headers": [{"name": "anthropic-version", "value": "2023-06-01"}], "bodyType": "raw", "contentType": "application/json", "data": "{{16.json}}", "parseResponse": true, "timeout": 40},
"metadata": {"designer": {"x": 3900, "y": 300}}
},
{
"id": 18,
"module": "json:ParseJSON",
"version": 1,
"parameters": {"type": 0},
"filter": {
"name": "Respuesta completa",
"conditions": [[
{"a": "{{17.statusCode}}", "b": "200", "o": "text:equal"},
{"a": "{{17.data.stop_reason}}", "b": "end_turn", "o": "text:equal"}
]]
},
"mapper": {"json": "{{get(17.data.content; 1.text)}}"},
"metadata": {"designer": {"x": 4200, "y": 300}}
},
{
"id": 19,
"module": "google-sheets:addRow",
"version": 2,
"parameters": {"__IMTCONN__": 0},
"mapper": {"mode": "select", "spreadsheetId": "/ID_DE_EJEMPLO", "sheetId": "Sugerencias", "includesHeaders": true, "valueInputOption": "RAW", "values": {"0": "{{1.MessageSid}}", "1": "{{join(18.productos; \", \")}}", "2": "{{18.fecha_entrega}}", "3": "{{18.pregunta}}", "4": "sugerencia del modelo: revisar"}},
"metadata": {"designer": {"x": 4500, "y": 300}}
}
]}
]
}
]
}
Cinco detalles del fragmento se leen con calma:
- La clave se calcula igual en los dos módulos del almacén. El $14$ y el $15$ usan la misma expresión,
sha256(1.WaId; "hex"; 13.data.sal_clientes). Si una de las dos cambiara, el escenario leería un cliente y escribiría otro. ifemptyresuelve al cliente nuevo. Cuando el módulo $14$ no encuentra registro, sus campos llegan vacíos, yifempty(14.data.pedidos; 0) + 1da $1$. El nombre de pila del cliente nuevo sale de su nombre de perfil de WhatsApp, cortado en el primer espacio.- El cuerpo de Create JSON incluye la salida estructurada. El campo
output_configpide un esfuerzo bajo y un esquema con tres campos (productos,fecha_entregaypregunta), sin campos adicionales. Con ese esquema, la respuesta es JSON válido por construcción, y Parse JSON no se encontrará con los casos del modelo de la Tabla 2. - El texto del modelo se lee con
getdesde $1$. La respuesta de la API trae un arreglocontent, y el texto está en su primer elemento:get(17.data.content; 1.text), con el índice $1$ de Make y no el $0$ de Python. - Los identificadores de estructura y de almacén van en $0$.
"type": 0en los dos módulos JSON y"datastore": 0en los del almacén sustituyen a los identificadores reales de la cuenta, con la misma regla que__IMTCONN__.
El siguiente bloque ensambla la versión 2.2 a partir del blueprint de la 2.1 que guardó el Día 25 y del fragmento, y la valida con seis reglas: las cuatro de siempre (identificadores únicos, posiciones enteras, conexiones, almacenes y estructuras en $0$, ningún secreto ni teléfono) y dos del día, que todo cuerpo de un módulo HTTP sea una sola referencia a un Create JSON o use escapeJSON, y que toda clave de cliente se calcule con HMAC. Para comprobar que la regla de cuerpos detiene lo que debe, valida también una copia con el cuerpo escrito a mano del jueves. Después cuenta los créditos de cada ruta propagando paquetes, como el Día 25, y dibuja el escenario con las funciones dibujar_flujo (Día 2) y nodos_desde_blueprint (Día 22), copiadas porque cada entrada debe entenderse sola:
# TL Pedidos WhatsApp v2.2: ensamblar, validar, contar créditos y dibujar el escenario en Python:
from matplotlib.patches import Ellipse, FancyBboxPatch, Polygon, RegularPolygon, FancyArrowPatch
v21 = json.loads((Path('trigo_lento') / 'dia_25' / 'flujos' / 'make' / 'pedidos_whatsapp_v2_1.blueprint.json')
.read_text(encoding = 'utf-8'))
fragmento = json.loads((flujos / 'pedidos_whatsapp_v2_2.fragmento.json').read_text(encoding = 'utf-8'))['flow']
v22 = copy.deepcopy(v21)
v22['name'] = 'TL Pedidos WhatsApp v2.2'
ruta_pedido = v22['flow'][2]['routes'][0]['flow']
ruta_pedido[-1:] = fragmento # el router 9 de la v2.1 se sustituye por el módulo 14 y el router nuevo
def todos(flujo):
for m in flujo:
yield m
yield from todos(m.get('onerror', []))
for ruta in m.get('routes', []):
yield from todos(ruta['flow'])
def validar(bp):
modulos = list(todos(bp['flow']))
ids = [m['id'] for m in modulos]
texto = json.dumps(bp, ensure_ascii = False)
cuerpos = [m['mapper']['data'] for m in modulos if m['module'] == 'http:ActionSendData'
and isinstance(m['mapper'].get('data'), str)]
return {
'ids únicos': len(ids) == len(set(ids)),
'posiciones enteras': all(isinstance(v, int) for m in modulos for v in m['metadata']['designer'].values()),
'conexiones, almacenes y estructuras en 0': all((m.get('parameters') or {}).get(k, 0) == 0 for m in modulos
for k in ('__IMTCONN__', 'hook', 'datastore', 'type')),
'sin claves ni teléfonos': not re.search(r'sk-ant|x-api-key|\+?52\s?\d{2}\s?\d{4}\s?\d{4}', texto),
'cuerpos JSON seguros': all(re.fullmatch(r'\{\{\d+\.json\}\}', c) or 'escapeJSON(' in c for c in cuerpos),
'clave de cliente con HMAC': all('sha256(1.WaId; "hex"; ' in m['mapper']['key'] for m in modulos
if m['module'].startswith('datastore:') and 'WaId' in str(m['mapper'])),
}
# El borrador del jueves: el cuerpo escrito a mano en el módulo HTTP, sin Create JSON:
borrador = copy.deepcopy(v22)
borrador['name'] = 'TL Pedidos WhatsApp v2.2 con el cuerpo del jueves'
revision = borrador['flow'][2]['routes'][0]['flow'][-1]['routes'][1]['flow']
http = next(m for m in revision if m['module'] == 'http:ActionSendData')
http['mapper']['data'] = ('{"model": "claude-haiku-5-5", "max_tokens": 300, "messages": '
'[{"role": "user", "content": "{{4.texto}}"}]}')
revision[:] = [m for m in revision if m['module'] != 'json:CreateJSON']
for bp in (borrador, v22):
resultado = validar(bp)
print(f"{bp['name']}: {'APTO' if all(resultado.values()) else 'RECHAZADO'}")
for regla, ok in resultado.items():
print(f" {'ok ' if ok else 'MAL'} {regla}")
ruta = flujos / 'pedidos_whatsapp_v2_2.blueprint.json'
ruta.write_text(json.dumps(v22, ensure_ascii = False, indent = 2), encoding = 'utf-8')
# Créditos por mensaje propagando paquetes por las rutas tomadas (routers y filtros no cobran):
def creditos(flujo, n, rutas, p = 1):
total = 0
for m in flujo:
if 'filter' in m and m['filter']['name'] not in rutas:
return total
if m['module'] == 'builtin:BasicRouter':
return total + sum(creditos(r['flow'], n, rutas, p) for r in m['routes'])
total += 1 if m['module'] == 'builtin:BasicAggregator' else p
p = p * n if m['module'] == 'builtin:BasicFeeder' else (1 if m['module'] == 'builtin:BasicAggregator' else p)
return total
COMPLETO = {'Es pedido', 'Es renglón', 'Completo'}
REVISION = {'Es pedido', 'Es renglón', 'Falta algo', 'Respuesta completa'}
renglones = 1 + rng.poisson(0.54, PEDIDOS_WHATSAPP_MES * 12) # media de 1.54 renglones (Día 25)
completos = rng.random(renglones.size) < 0.64 # el 64.0 % se normaliza con reglas (Día 25)
filas = []
for bp in (v21, v22):
c_pedido = [creditos(bp['flow'], int(n), COMPLETO if ok else REVISION) for n, ok in zip(renglones, completos)]
otros = creditos(bp['flow'], 1, {'No es pedido'})
pedidos_mes = np.mean(c_pedido) * PEDIDOS_WHATSAPP_MES
total = pedidos_mes + (MENSAJES_MES - PEDIDOS_WHATSAPP_MES) * otros
filas.append({'versión': bp['name'][-4:], 'módulos': len(list(todos(bp['flow']))),
'completo n=1': creditos(bp['flow'], 1, COMPLETO), 'revisión n=1': creditos(bp['flow'], 1, REVISION),
'no es pedido': otros, 'pedidos al mes': round(pedidos_mes), 'escenario al mes': round(total),
'% de Core': round(100 * total / CUOTA_CORE, 1)})
tabla_creditos = pd.DataFrame(filas).set_index('versión')
print(tabla_creditos.to_string())
llamadas = PEDIDOS_WHATSAPP_MES * (1 - 0.64)
print(f'Llamadas al modelo al mes: {llamadas:.1f} | coste con Haiku 5.5 (430 y 70 tokens por llamada, Día 25): '
f'{llamadas * (430 * 0.10 + 70 * 0.50) / 1e6:.4f} USD | blueprint: {ruta.stat().st_size} bytes')
TIPOS = {'gateway:CustomWebHook': 'disparador', 'builtin:BasicRouter': 'decision', 'http:ActionSendData': 'ia'}
FORMAS = {'disparador': ('ovalo', '#3a9d5d'), 'accion': ('rectangulo', '#2a78d6'),
'decision': ('rombo', '#eb6834'), 'ia': ('rectangulo', '#7b5ea7'),
'error': ('rectangulo', '#c8423b'), 'humano': ('hexagono', '#8a8986')}
CORTO = {'gateway:CustomWebHook': 'Webhook', 'gateway:WebhookRespond': 'Responder\n200', 'builtin:BasicRouter': 'Router',
'util:SetVariables': 'Variables', 'builtin:BasicFeeder': 'Iterador', 'builtin:BasicAggregator': 'Agregador',
'datastore:AddRecord': 'Guardar\ncliente', 'json:CreateJSON': 'Create\nJSON', 'json:ParseJSON': 'Parse\nJSON',
'http:ActionSendData': 'HTTP al\nmodelo'}
def texto_de(m):
if m['module'] == 'google-sheets:addRow':
return 'Fila en\n' + m['mapper']['sheetId']
if m['module'] == 'datastore:GetRecord':
return 'Leer\n' + ('catálogo' if m['mapper']['key'] == 'catalogo' else 'cliente')
return CORTO.get(m['module'], m['module'])
def tipo_de(m):
if m['mapper'] and m['mapper'].get('sheetId') in ('Revisión', 'Sugerencias'):
return 'humano' # las hojas de revisión las atiende una persona
return TIPOS.get(m['module'], 'accion')
def dibujar_flujo(nodos, aristas, titulo, ax = None, ancho = 2.4, alto = 0.95, recorte = 30):
if ax is None:
fig, ax = plt.subplots(figsize = (12, 5.6))
for nd in nodos:
forma, color = FORMAS[nd['tipo']]
x, y = nd['x'], nd['y']
if forma == 'ovalo':
figura = Ellipse((x, y), ancho, alto)
elif forma == 'rombo':
figura = Polygon([(x - ancho / 2, y), (x, y + alto / 1.3), (x + ancho / 2, y), (x, y - alto / 1.3)])
elif forma == 'hexagono':
a, b = ancho / 2, alto * 0.65
figura = Polygon([(x - a, y), (x - a * 0.7, y + b), (x + a * 0.7, y + b), (x + a, y),
(x + a * 0.7, y - b), (x - a * 0.7, y - b)])
else:
figura = FancyBboxPatch((x - ancho / 2, y - alto / 2), ancho, alto, boxstyle = 'round,pad=0.03')
figura.set_facecolor(color)
figura.set_edgecolor('white')
figura.set_linewidth(2)
ax.add_patch(figura)
ax.text(x, y, nd['texto'], ha = 'center', va = 'center', fontsize = 8.5, color = 'white')
posicion = {nd['id']: (nd['x'], nd['y']) for nd in nodos}
for origen, destino, etiqueta in aristas:
(x1, y1), (x2, y2) = posicion[origen], posicion[destino]
ax.add_patch(FancyArrowPatch((x1, y1), (x2, y2), arrowstyle = '-|>', mutation_scale = 12,
shrinkA = recorte, shrinkB = recorte + 2, color = '#52514e', linewidth = 1.2))
if etiqueta:
ax.text((x1 + x2) / 2, (y1 + y2) / 2 + (0.75 if y1 == y2 else 0.1), etiqueta, ha = 'center', fontsize = 8,
color = '#52514e', bbox = dict(facecolor = 'white', edgecolor = 'none', pad = 1))
xs, ys = [nd['x'] for nd in nodos], [nd['y'] for nd in nodos]
ax.set_xlim(min(xs) - 1.3, max(xs) + 1.3)
ax.set_ylim(min(ys) - 1.1, max(ys) + 1.1)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(titulo, fontsize = 12)
return ax
def nodos_desde_blueprint(bp, escala = 300 / 3.4):
nodos, aristas = [], []
def recorrer(flujo, previo, etiqueta, profundidad):
for m in flujo:
d = m['metadata']['designer']
nodos.append({'id': m['id'], 'tipo': tipo_de(m), 'texto': f"{m['id']}. {texto_de(m)}",
'x': d['x'] / escala, 'y': -d['y'] / escala, 'profundidad': profundidad})
if previo is not None:
aristas.append((previo, m['id'], m.get('filter', {}).get('name', etiqueta)))
recorrer(m.get('onerror', []), m['id'], 'si falla', profundidad + 1)
for ruta_ in m.get('routes', []):
recorrer(ruta_['flow'], m['id'], '', profundidad + 1)
previo, etiqueta = m['id'], ''
recorrer(bp['flow'], None, '', 0)
return nodos, aristas
nodos, aristas = nodos_desde_blueprint(v22)
print(f'{len(nodos)} nodos, {len(aristas)} aristas, profundidad máxima {max(nd["profundidad"] for nd in nodos)}')
fig, ax = plt.subplots(figsize = (18, 4.6))
dibujar_flujo(nodos, aristas, '', ax = ax, ancho = 2.7, alto = 1.1)
fig.suptitle('TL Pedidos WhatsApp v2.2 dibujado desde su blueprint', fontsize = 16)
plt.tight_layout()
plt.show()
TL Pedidos WhatsApp v2.2 con el cuerpo del jueves: RECHAZADO
ok ids únicos
ok posiciones enteras
ok conexiones, almacenes y estructuras en 0
ok sin claves ni teléfonos
MAL cuerpos JSON seguros
ok clave de cliente con HMAC
TL Pedidos WhatsApp v2.2: APTO
ok ids únicos
ok posiciones enteras
ok conexiones, almacenes y estructuras en 0
ok sin claves ni teléfonos
ok cuerpos JSON seguros
ok clave de cliente con HMAC
módulos completo n=1 revisión n=1 no es pedido pedidos al mes escenario al mes % de Core
versión
v2.1 13 9 9 3 2456 6365 63.6
v2.2 19 11 14 3 3254 7163 71.6
Llamadas al modelo al mes: 92.5 | coste con Haiku 5.5 (430 y 70 tokens por llamada, Día 25): 0.0072 USD | blueprint: 23281 bytes
19 nodos, 18 aristas, profundidad máxima 2

La copia con el cuerpo del jueves sale RECHAZADA por una sola regla, la de cuerpos JSON seguros, y TL Pedidos WhatsApp v2.2 sale APTA en las seis. El blueprint completo ocupa $23,281$ bytes y queda guardado junto al fragmento.
Los créditos muestran el precio de lo que se añadió, y la Tabla 4 los ordena:
Tabla 4. Créditos de TL Pedidos WhatsApp v2.1 y v2.2 por mensaje y al mes (1 560 mensajes, 257 pedidos, 64 % completos y 1.54 renglones de media; supuestos del Día 25).
| Versión | Módulos | Pedido completo (1 renglón) | Pedido a revisión (1 renglón) | Mensaje que no es pedido | Ruta de pedidos al mes | Escenario al mes | Uso de Make Core |
|---|---|---|---|---|---|---|---|
| v2.1 | 13 | 9 | 9 | 3 | 2 456 | 6 365 | 63.6 % |
| v2.2 | 19 | 11 | 14 | 3 | 3 254 | 7 163 | 71.6 % |
La diferencia por pedido se escribe con la misma suma de créditos del Día 22, separada por ruta:
$$ C_{v2.2}(n) = C_{v2.1}(n) + \begin{cases} 2 & \text{si el pedido está completo (leer y guardar cliente)} \ 5 & \text{si va a revisión (leer cliente, Create JSON, HTTP, Parse JSON y sugerencia)} \end{cases} $$
La lógica detrás de esta fórmula es múltiple:
- $C_{v2.1}(n) = 8 + n$ es el coste de la versión anterior con $n$ renglones, en las dos rutas: $9$ créditos con un renglón.
- La ruta completa añade la lectura del cliente y su registro: $9 + 2 = 11$. La de revisión añade la lectura y los cuatro módulos de la consulta al modelo: $9 + 5 = 14$.
- Al mes, con $257$ pedidos y un $64%$ de completos, el aumento esperado es $257 \cdot (0.64 \cdot 2 + 0.36 \cdot 5) = 257 \cdot 3.08 = 791.6$ créditos; la simulación da $3,254 - 2,456 = 798$, la misma cifra con el ruido de la muestra.
- En conclusión, la memoria y el segundo nivel cuestan unos $800$ créditos al mes, el $8%$ de Core, y los mensajes que no son pedidos no pagan nada nuevo, porque el módulo $14$ está dentro de la ruta de pedidos.
El escenario completo pasa del $63.6%$ al $71.6%$ de Make Core. Sumados el recordatorio del Día 26 ($1,008.8$ créditos al mes) y el formulario ($280$, según la cuenta del Día 24), la organización llega a unos $8,452$ créditos, el $84.5%$ de la cuota: cabe, pero con poco margen para lo que añadan los días siguientes, y la evaluación del Día 32 tendrá que cuadrar la cuenta. El modelo, en cambio, apenas cuesta: $92.5$ llamadas al mes a Haiku 5.5, con los $430$ tokens de entrada y $70$ de salida que estimó el Día 25, suman $0.0072$ USD al mes. En este escenario, lo caro no es la IA; son los módulos que la rodean.
En el dibujo, la entrada y la ruta de pedidos son las del Día 25 hasta el módulo $8$. Después aparecen las piezas nuevas: el módulo $14$, que lee el cliente antes del segundo router; la rama superior, con la fila de pedidos y el registro del cliente; y la rama inferior, que empieza con la fila de revisión dibujada como paso humano (hexágono gris), sigue con Create JSON, la consulta al modelo en morado (el color de la IA en la paleta de la serie) y Parse JSON, y termina en la hoja de sugerencias, otro paso humano. La etiqueta “Respuesta completa” en la flecha que llega a Parse JSON es el filtro que impide analizar una respuesta que no terminó bien. Que la fila de revisión esté antes de la IA y que la sugerencia termine en una persona es, dibujado, todo lo que aprendió Valeria el viernes.
¿Cuánto pesa un mensaje?
Queda una pregunta práctica: cuánto ocupan los cuerpos que viajan por el escenario, para saber si algún límite de tamaño puede preocupar y si la elección de ensure_ascii importa en la práctica. El siguiente bloque simula un mes de $1,560$ mensajes con el generador del Bloque 4 y mide tres cuerpos: el del webhook de Twilio, que llega como formulario codificado (application/x-www-form-urlencoded) con los campos que el Día 23 describió, y el de la petición al modelo, serializado con y sin ensure_ascii:
# Tamaño en bytes de los cuerpos que viajan: webhook de Twilio y petición al modelo en Python:
textos_mes = simular_mensajes(MENSAJES_MES, rng)
def cuerpo_twilio(k, texto):
campos = {'SmsMessageSid': f'SM{k:032x}', 'NumMedia': '0', 'ProfileName': 'Cliente de prueba',
'MessageType': 'text', 'SmsSid': f'SM{k:032x}', 'WaId': '5215500000099', 'SmsStatus': 'received',
'Body': texto, 'To': 'whatsapp:+5215500000001', 'NumSegments': '1', 'ReferralNumMedia': '0',
'MessageSid': f'SM{k:032x}', 'AccountSid': 'AC' + '0' * 32, 'From': 'whatsapp:+5215500000099',
'ApiVersion': '2010-04-01'}
return urlencode(campos)
tamanos = pd.DataFrame({
'webhook de Twilio': [len(cuerpo_twilio(k, t).encode()) for k, t in enumerate(textos_mes)],
'petición (utf-8)': [len(cuerpo_serializado(t).encode()) for t in textos_mes],
'petición (ascii)': [len(cuerpo_serializado(t, ensure_ascii = True).encode()) for t in textos_mes],
})
resumen = tamanos.describe(percentiles = [0.5, 0.95]).loc[['mean', '50%', '95%', 'max']].round(1)
print(resumen.to_string())
extra = tamanos['petición (ascii)'] - tamanos['petición (utf-8)']
print(f'ensure_ascii=True añade {extra.mean():.1f} bytes de media por petición ({extra.max()} como máximo)')
print(f"Bytes al mes hacia el modelo (92.5 peticiones): {92.5 * tamanos['petición (utf-8)'].mean() / 1000:.1f} kB")
fig, (ax1, ax2) = plt.subplots(1, 2, figsize = (12, 4.8))
for col, color in [('petición (utf-8)', '#7b5ea7'), ('petición (ascii)', '#eb6834')]:
ax1.hist(tamanos[col], bins = np.arange(108, 180, 3), alpha = 0.55, color = color,
label = f"{col}: mediana {tamanos[col].median():.0f} B")
ax1.set_xlabel('Bytes por petición al modelo')
ax1.set_ylabel('Mensajes en un mes simulado')
ax1.set_title('Cuerpo JSON de la petición', fontsize = 12)
ax1.legend(fontsize = 9)
ax2.hist(tamanos['webhook de Twilio'], bins = np.arange(410, 500, 3), color = '#2a78d6')
ax2.axvline(tamanos['webhook de Twilio'].quantile(0.95), color = '#52514e', ls = '--', lw = 1.2)
ax2.text(tamanos['webhook de Twilio'].quantile(0.95) + 1, 700, 'p95', fontsize = 9, color = '#52514e')
ax2.set_xlabel('Bytes por cuerpo del webhook (formulario codificado)')
ax2.set_title(f"Webhook de Twilio: mediana {tamanos['webhook de Twilio'].median():.0f} B", fontsize = 12)
fig.suptitle('Tamaño de los cuerpos de 1 560 mensajes simulados', fontsize = 16)
plt.tight_layout()
plt.show()
webhook de Twilio petición (utf-8) petición (ascii)
mean 438.4 127.9 129.8
50% 436.0 126.0 128.0
95% 458.0 142.0 147.0
max 489.0 171.0 171.0
ensure_ascii=True añade 1.9 bytes de media por petición (12 como máximo)
Bytes al mes hacia el modelo (92.5 peticiones): 11.8 kB

El cuerpo del webhook de Twilio pesa $436$ bytes de mediana y $489$ como máximo: los campos fijos (identificadores, números, versión de la API) pesan más que el propio mensaje, que es corto. La petición al modelo pesa $126$ bytes de mediana en UTF-8 y $128$ con ensure_ascii, con un máximo de $171$ en los dos casos. La diferencia media, $1.9$ bytes por petición, confirma la fórmula de $\Delta B$: los pedidos de Trigo Lento tienen pocos acentos (“sábado”, “mañana”) y algún emoji, y cada uno añade $4$ u $8$ bytes. Al mes, las $92.5$ peticiones al modelo suman unos $11.8$ kB.
La conclusión práctica es doble. El tamaño no es un problema en este escenario: ningún cuerpo se acerca a ningún límite razonable de una API o de un webhook, y el registro de un cliente, con sus $200$ bytes, pesa menos que la mitad de un webhook. Y ensure_ascii es una cuestión de legibilidad, no de coste: Valeria lo deja en False en sus cuadernos para que el historial muestre “sábado” y no sábado, y en Make no tiene que decidir nada, porque Create JSON y el módulo HTTP envían UTF-8.
Don Ernesto, que ha seguido la sesión a ratos entre hornada y hornada, mira la hoja de sugerencias de prueba, donde el modelo ha propuesto “P01” para el mensaje de Ramiro porque se le dio su producto habitual, y hace la pregunta que le importa: "¿Y si el sábado le escribe ’el de siempre’ alguien que pide cosas distintas cada vez?" Entonces el producto habitual no dirá nada útil, el modelo devolverá una pregunta en usted y la fila de revisión llegará a Valeria como hasta ahora. “Bueno”, concede, “mientras la máquina pregunte cuando no sepa, que pregunte.”
Consideraciones finales
El día empezó con unas comillas que detuvieron un escenario y una hogaza que nadie sabía que era “la de siempre”, y termina con dos piezas que resuelven las dos mitades del problema. La primera es de formato: los cuerpos se construyen con Create JSON, nunca pegando texto, y la regla está en un validador que rechazó el cuerpo del jueves. La segunda es de memoria: un almacén estricto, con clave seudonimizada y cinco campos, recuerda lo justo de cada cliente y cabe en un mega durante años. Un escenario no debe pedirle al cliente que escriba como le conviene a la máquina; debe tratar cualquier texto como dato, nunca como estructura.
Antes de dar por bueno un escenario que maneja JSON y memoria, conviene hacerse algunas preguntas:
- ¿Hay algún campo de un módulo HTTP o de un webhook donde un dato mapeado aparezca dentro de un JSON escrito a mano? Si lo hay, ¿está envuelto en
escapeJSONo puede sustituirse por Create JSON? - ¿Qué pasa con el pedido si la llamada al modelo falla? Si la respuesta es “se pierde”, la fila de revisión está en el sitio equivocado.
- ¿Qué guarda cada almacén, y podría guardar menos? ¿Su estructura es estricta?
- ¿Dónde vive cada secreto (claves de API, sales, tokens)? Si la respuesta incluye un blueprint, una variable personalizada o un almacén sin necesidad, hay que moverlo.
Las cifras de hoy tienen límites claros. Las proporciones de comillas, saltos de línea y emojis de los $200$ mensajes son supuestos de juguete, y el $8.0%$ de cuerpos rotos describe la muestra, no a los clientes reales de Trigo Lento; los $900$ clientes posibles, sus pesos y los $110$ bytes por mensaje del historial también son supuestos. El tamaño que Make cuenta para un registro puede no coincidir con los bytes de su JSON. Y el fragmento del blueprint tiene la estructura documentada, pero los nombres internos de los parámetros de Create JSON, Parse JSON y del almacén deben contrastarse con una exportación real antes de activarlo. Ninguno de esos límites cambia la estructura de la conclusión: la concatenación falla con una probabilidad que no es cero, la serialización no falla nunca, y un almacén que guarda estado cabe donde uno que guarda historia no.
Hay además una lección que irá más allá de este módulo. La inyección del Bloque 4 no necesitaba ningún modelo de lenguaje para funcionar: bastaba con un texto y una plantilla. Cuando el texto del cliente llegue a las instrucciones de un modelo, como ocurrirá en el Día 30 y, con todo detalle, en el Día 64 dedicado a la inyección de instrucciones, el riesgo será el mismo con otra forma, y la defensa empezará por lo mismo: separar lo que es instrucción de lo que es dato.
Un gran libro que habla de esto es Diseño de aplicaciones intensivas en datos (o Designing Data-Intensive Applications), en el que Martin Kleppmann dedica el capítulo sobre codificación y evolución a los formatos como JSON: por qué la ambigüedad de sus números causa errores silenciosos con identificadores grandes, por qué un formato sin esquema obliga a cada lector a adivinar los tipos y cómo cambiar un esquema sin romper a quien ya lo usa. El Día 25 lo recomendó por los relojes; hoy vale la pena volver a él por la codificación, que es exactamente el problema de la comilla de Ramiro visto desde lejos.
Conclusiones clave
JSON
- JSON tiene seis tipos (objeto, arreglo, cadena, número, booleano y nulo); las cadenas van con comillas dobles y las comillas, las barras invertidas y los saltos de línea se escriben con escape.
- Los identificadores viajan como texto: $2^{53} + 1$ se convierte en $2^{53}$ sin error al pasar por un número de doble precisión.
- La posición de un error de JSON señala el primer carácter que el analizador no entendió: en
{"texto": "el de "siempre""}, la posición $18$, después de la comilla culpable.
Cuerpos seguros
- Concatenar el texto del cliente rompió el $8.0%$ de $200$ cuerpos simulados; con $3.6$ peticiones al día, uno de cada cuatro días ($0.257$) el escenario se habría detenido.
- Un texto preparado no rompe el JSON concatenado, lo reescribe: cambió el modelo a Fable 5.1 y el límite a $64,000$ tokens, de $0.00015$ a $3.20$ USD de techo por llamada.
- En Make, el cuerpo se construye con Create JSON y se mapea entero (
{{16.json}}), o cada dato se envuelve conescapeJSON; la salida estructurada evita que el modelo devuelva JSON con texto alrededor.
Variables
- Las variables de escenario (Set variable y Set multiple variables) duran una ejecución; desde el 1 de octubre de 2026 ya no existe la duración de un ciclo.
- Las variables personalizadas son compartidas, solo existen en Pro, Teams y Enterprise y se guardan en texto plano: no sirven para secretos ni están disponibles en Core.
Persistencia
- Un almacén de datos tiene una clave única y una estructura; la opción estricta rechaza campos no declarados, y así impidió guardar el teléfono y los pedidos como texto.
- La clave del cliente es un HMAC de su
WaIdcon una sal secreta,sha256(1.WaId; "hex"; sal), porque el hash simple de un teléfono se puede deshacer. - El almacén mínimo ocupa $144.5$ kB a los doce meses ($14.5%$ de 1 MB, con $721$ clientes); con historial ocuparía $487$ kB y lo llenaría hacia el mes $26$.
TL Pedidos WhatsApp v2.2tiene $19$ módulos y consume $7,163$ créditos al mes ($71.6%$ de Core): $2$ más por pedido completo y $5$ más por pedido a revisión, y $0.0072$ USD al mes de modelo.
Mañana, en el Día 28: Flujos profesionales: manejo de errores, reintentos y ejecuciones incompletas. Hoy un error de formato detuvo el escenario y la ejecución esperó tres horas en la lista de incompletas sin que nadie la viera; mañana Valeria aprenderá las cinco directivas de error de Make, cuándo reintentar y cuándo no, cómo calcular la fiabilidad de un escenario con y sin reintentos y cómo garantizar que un reintento no duplique un pedido.
Referencias
- Bray, T. (Ed.) (2017). The JavaScript Object Notation (JSON) Data Interchange Format (RFC 8259). IETF. https://www.rfc-editor.org/rfc/rfc8259
- Make (2026). JSON: Parse JSON, Create JSON, Transform to JSON y Aggregate to JSON. Aplicaciones integradas de Make. https://apps.make.com/json
- Make (2026). Data stores y data structures: almacenamiento por créditos, claves, estructura estricta y módulos. Centro de ayuda de Make. https://help.make.com/data-stores
- Make (2026). Scenario variables, custom variables y Tools (Set variable, Set multiple variables, Increment function). Centro de ayuda de Make. https://help.make.com/custom-variables
- Make (2026). Text and binary functions:
escapeJSONysha256. Centro de ayuda de Make. https://help.make.com/text-and-binary-functions - Make (2026). Release notes: Cycles removal on October 1, 2026; New functions arrayDiff, arrayIntersect, set y escapeJSON. Centro de ayuda de Make. https://help.make.com/
- Make (2026). Referencia de la API: data structures (
strictyspec). https://developers.make.com/api-documentation/api-reference/data-structures - OWASP Foundation (2021). OWASP Top 10: A03 Injection. https://owasp.org/Top10/A03_2021-Injection/
- Python Software Foundation (2026).
json: JSON encoder and decoder. https://docs.python.org/3/library/json.html - Kleppmann, M. (2017). Designing Data-Intensive Applications. O’Reilly Media.

