The chat box is the least interesting part of Paglipat’s AI concierge.
Relocation questions combine rules that change, personal circumstances, owned travel guides and live prices. A thin wrapper would send all of that to a model and trust whatever came back. It might remember an old visa rule, invent a date for a hotel search or keep calling a tool after the useful work was done.
The model can decide which bounded tool fits a question, but TypeScript decides whether the arguments are valid. Verified data supplies visa rules. The hotel and flight services supply prices. The Worker owns memory, privacy, budgets and retention.
This is the deeper AI layer behind Paglipat, an independent product I described in the wider Astro, Go and Flutter architecture. It is a production walkthrough, not an external-client case study or a claim that every assistant needs this exact stack.
Key Takeaways
- Tool calls are proposals. Every argument crosses a deterministic validation boundary before code executes it.
- The router can handle four formulaic categories, but it cannot author a factual reply. Ambiguity escalates.
- Memory is persisted for useful follow-up questions, then bounded by turn, token, replay and retention limits.
- Privacy and cost checks run before inference. The cheap rejection happens before the expensive dependency.
- Prompt caching is a tested billing invariant, not an optimisation I hope remains intact.
- The implementation has a ledger, kill switch, retention job and runbook. Its model-level evaluation is still manual, and I label that limitation plainly.
The model sits inside a deterministic system
The production path is a Cloudflare Worker in front of a hosted model API, D1, KV, a generated guide corpus, an owned hotel service and a third-party flight-price API. The current model configuration uses xAI’s OpenAI-compatible chat-completions API with grok-4.3. The Worker path around the API is the design.
The Worker starts with controls that do not need a model: parse the request, check the daily budget, increment the IP bucket and verify a new session with Turnstile. A router then decides whether a formulaic request can end locally or whether the concierge loop should run. The loop may call tools, but it has a hard iteration cap.
An invalid body should not spend a network request. A bot that has exhausted an IP allowance should not reach Turnstile. A greeting should not reach the full prompt and tool set. A failing tool should become a bounded failure result, not tear down the response.
The static Astro site still owns the content and UI. The Go services still own travel data. D1 owns durable chat state and the authoritative reconciliation ledger for usage the Worker successfully records. KV provides expiring rate and spend keys. Keeping those responsibilities separate is the same reason I split the rest of Paglipat’s live architecture.
Tool calls are proposals
The concierge exposes five tools: check_visa, get_checklist, read_guide, search_hotels and flight_prices. Their descriptions state when to call them. A conservative model can answer from memory unless the trigger is explicit.
Here is the shortened shape of the hotel tool in production:
{
name: 'search_hotels',
description:
'Search live hotel availability and prices. Use this when the user ' +
'asks where to stay in a specific city and has given, or can give, ' +
'dates. Do not guess dates.',
input_schema: {
type: 'object',
properties: {
city: { type: 'string' },
checkin: { type: 'string', description: 'ISO yyyy-mm-dd.' },
checkout: { type: 'string', description: 'ISO yyyy-mm-dd.' },
adults: { type: 'integer', minimum: 1, maximum: 10 },
},
required: ['city', 'checkin', 'checkout', 'adults'],
additionalProperties: false,
},
}
The JSON schema helps the model construct a valid proposal. The handler calls a separate validator before it resolves a city or touches the hotel API. Invalid input returns a structured failure with an instruction to ask for the missing detail.
This gives the system two independent checks. The model sees a strict schema. The runtime still assumes the model may send malformed JSON, invent a field or supply a date that does not exist.
The boundary also narrows what comes back. The hotel handler maps each result to the few fields the answer needs: name, star rating, review score, nightly rate, breakfast and booking URL. It deliberately excludes Agoda’s free-text overview. Third-party prose is an unnecessary prompt-injection surface when structured fields can answer the question.
The router can decline work, but it cannot answer facts
The first version of the router could have been another small chatbot: classify the request and compose a short reply. That would have made the cheap tier capable of smuggling a factual claim past the concierge. “You do not need a visa for a short stay” contains no price or day count, but it can still be dangerously wrong.
I removed that capability structurally. The router returns strict JSON and can handle only greeting, thanks, off-topic or injection. The Worker, not the model, renders canned localised copy for those categories.
export function parseRouterOutput(raw: unknown): RouterVerdict {
const escalate = (category: string): RouterVerdict =>
({ tier: 'escalate', category });
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
return escalate('malformed');
}
const result = raw as Record<string, unknown>;
if (result.tier === 'escalate') {
return escalate(typeof result.category === 'string'
? result.category
: 'unknown');
}
if (result.tier !== 'handle' ||
typeof result.category !== 'string' ||
!HANDLE_CATEGORIES.has(result.category)) {
return escalate('malformed');
}
return { tier: 'handle', category: result.category as HandleCategory };
}
Malformed, unknown and ambiguous outputs escalate. The safe failure costs another model call; it does not invent a travel rule. Once a session has escalated, subsequent turns stay with the concierge so “what about my spouse?” retains the right context.
This is tiered routing by responsibility and token budget. Both current model constants happen to be grok-4.3; I am not claiming that two different model families are deployed.
Five tools connect the conversation to verified and live systems
The clearest way to understand the tools is to follow two questions.
A DTV visa question goes through check_visa. The handler validates every field, loads a versioned rules file, verifies the consulate identifier, then runs deterministic eligibility and cost functions. The result includes verifiedAt, a source note and mustSayVerifiedAt: true. The model can explain the result, but it does not supply the financial threshold from memory.
A hotel question follows a different path. Dates are mandatory because the wrong dates can return perfectly plausible wrong prices.
The handler resolves an English city name to the catalogue’s city identifier, queries the Go hotel service, limits the result to three properties and wraps the changing price with an explicit note. If the dependency fails, it returns hotels_unreachable. The model can tell the visitor it could not check live rates; the Worker does not leak a raw upstream failure.
The remaining tools follow the same principle:
| Tool | Source of truth | Boundary |
|---|---|---|
get_checklist |
Versioned relocation rules | Deterministic dated checklist generation |
read_guide |
Build-generated owned corpus | Slug validation plus current catalogue membership |
flight_prices |
TravelPayouts recent-price API | Described as typical recent prices, not bookable live inventory |
A slug must pass a conservative regular expression and exist in the generated CHAT_CATALOG. That second gate stops a retired document from remaining fetchable because an old JSON file still happens to exist at the origin.
The system selects from a deterministic catalogue and fetches an owned document by exact slug. For rules where the wrong answer matters, a typed deterministic calculator is a better fit than asking similarity search to find the right paragraph.
Memory is useful only when it is bounded
Without memory, a follow-up such as “what about my spouse?” has no referent. Unbounded replay creates the opposite problem: every turn becomes slower, more expensive and more exposed than the one before it.
Paglipat stores sessions and turns in D1. Before a concierge request, it loads the last six turns that have a non-empty model-authored answer, oldest first. Router-handled greetings are excluded because canned copy carries no useful context. A stream that emitted some text and then failed is currently stored and can be replayed; only an empty failed response is excluded. Filtering incomplete streamed answers is therefore a remaining reliability improvement, not a control the present implementation has.
The turn insert and the session counter update run in one D1 batch:
await db.batch([
db.prepare(
`INSERT INTO chat_turns
(session_id, created_at, question, locale, tier,
tools_used, guides_read, ph_distinct_id, answer)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(/* bounded turn fields */),
db.prepare(
`UPDATE chat_sessions
SET turn_count = turn_count + 1,
output_tokens = output_tokens + ?
WHERE id = ?`
).bind(t.outputTokens, t.sessionId),
]);
That atomicity is a cost control. The same counters enforce the session limit. If the turn were committed but its counter update failed, the persisted state would silently undercount future usage.
It is bounded by six replayed turns, thirty total turns, sixty thousand output tokens and a retention job.
Privacy controls belong before inference
The concierge makes several choices before a prompt leaves the Worker.
The request parser constrains message length, locale, currency, UUID shape and the current page. The page must be a same-origin path. It rejects both protocol-relative paths beginning // and backslashes, because the WHATWG URL parser can normalise a backslash into a cross-origin URL.
The IP address is hashed with SHA-256 and a salt before it is stored. Raw IPs do not enter D1. New sessions require a valid Turnstile challenge based on whether the session exists, not whether the caller supplied a UUID. That distinction closed a bypass where a caller could generate a fresh, well-formed ID and avoid the “new session” challenge.
There is also a deliberately narrow inbound filter for passport-machine-readable zones, passport-number patterns and grouped payment-card numbers. When it fires, three things happen:
- the visitor receives a safe local response;
- the model never receives the text;
- the stored question becomes
[redacted sensitive input].
This is not comprehensive PII detection, and I do not describe it that way. It is a cheap pre-inference barrier for the most obvious material that should never enter this travel assistant.
Transcripts are deleted after ninety days by a scheduled Worker handler. The cost ledger is kept separately because it stores model, token counts, cache-token counts and micro-USD, not question text or an IP hash. That separation makes long-run reconciliation possible without retaining the conversation indefinitely.
Cost control is a chain
One max_tokens value does not control the cost of a tool-using conversation. A public endpoint needs limits around the request, session, model loop and whole day.
The current configured bounds are deliberately visible in one pure constants module:
| Boundary | Current configuration |
|---|---|
| Daily blast-radius cap | $5.00 |
| Requests per IP per hour | 20 |
| Turns per session | 30 |
| Output tokens per session | 60,000 |
| Output tokens per response | 2,048 |
| Concierge tool-loop iterations | 8 |
Those are limits, not evidence of savings or normal user behaviour.
The guard chain is ordered by cost: parse, budget KV read, IP bucket, then Turnstile. The daily budget is checked again inside the tool loop before every model step. When the provider returns usage successfully, the Worker prices the input, output and cache-read tokens and writes them to the D1 reconciliation ledger.
KV provides fast expiring counters, but its increment is a read followed by a write. Concurrent requests can lose an update. I accept that for a blast-radius limiter and say so in the code. D1 is the reconciliation record.
The runbook adds the last layer: an emergency command pushes the current day’s spend key over the cap. Once that KV update is visible, subsequent requests stop before a model call. A code-level zero budget is one deploy away.
Prompt caching became a tested billing invariant
The largest input to the concierge is the stable part: system rules, the article catalogue, the verified visa digest and tool schemas. Dates, locale, currency, current page and conversation history are volatile.
If a volatile value enters the prefix, the API does not report a configuration error. Cache reads quietly fall to zero and every turn pays the full input rate. I therefore build the request with a stable first message and move request-specific context into the final user message:
const messages: XaiMessage[] = [
{
role: 'system',
content: buildSystemText(args.catalog, args.visaDigest),
},
...args.history.map((turn) => ({
role: turn.role,
content: historyContent(turn.content),
})),
{
role: 'user',
content:
`${buildContextPreamble(args.ctx)}\n\n` +
wrapUserMessage(args.message),
},
];
Tool ordering is frozen for the same reason. Reordering equivalent schemas changes the prefix bytes and discards reuse. Tests assert that dates, locale, page and session values do not leak into the stable system text.
The deployment runbook also checks the result in production. Across the first six earlier production concierge turns recorded in that runbook, the ledger observed 53,702 cache-read tokens against 11,662 written input tokens. That is a small, dated operational sample used to verify the expected one-write-many-reads shape. It is not a representative cache-hit rate, benchmark or cost-saving claim.
The tool loop has to fail safely
The concierge streams text while also accumulating tool calls. When a call arrives, the Worker parses its JSON arguments, validates and executes the handler, then appends a role-specific tool result before the next model step.
for (let i = 0; i < TOOL_MAX_ITERATIONS; i++) {
const still = checkBudget(await readSpendToday(env.CHAT_KV, dayKey));
if (!still.ok) {
await send({ type: 'error', error: 'daily_budget_exhausted' });
break;
}
const step = await streamChatCompletion({
apiKey,
convId: sessionId,
body: { ...params, messages },
onText: async (text) => send({
type: 'text',
text: sanitizeAnswer(text),
}),
});
await bill(MODEL_CONCIERGE, step.usage);
if (step.tool_calls.length === 0) break;
// Parse, validate, run and append each tool result.
}
Malformed tool JSON becomes an invalid-input outcome. An unknown tool becomes unknown_tool. A handler exception is caught and returned as tool_failed. The model receives a small hint to acknowledge that it could not retrieve the data rather than fill the gap from memory.
The browser and model also receive different views of a successful result. Deep links and result cards go directly to the browser as typed SSE frames. The model sees only the data it needs to explain the answer. A failed tool sends no empty card, because an empty card looks like a successful search with no results.
The loop stops after eight iterations even if the model still wants tools. Content filtering and length termination become explicit terminal events. If a visitor navigates away during a card write, that decoration cannot abort billing and turn persistence. In finally, the stream closes and the Worker attempts to record the turn exactly once.
Production work continues after deployment
The repository includes an operations runbook because “deployed” is not the final state of a public model endpoint.
It covers where the public Turnstile site key and private secret belong, how to migrate D1 remotely, why the static site must deploy before the Worker that fetches its guide corpus, how to confirm cache reads, how to trigger rate and budget controls, how to purge transcripts manually and how to query spend by day and model.
The automated suite exercises request parsing, budgets, rate limits, Turnstile responses, sensitive-input detection, prompt construction, routing, tool schemas, handlers, cost pricing, retention and the xAI streaming adapter. The production red-team table adds eight model-level cases: changing visa rules, refusal to collapse conditional answers, tool-grounded financial proof, unsupported-country boundaries, prompt injection, missing hotel dates, greetings and off-topic questions.
All eight were recorded as passing when the runbook was written. They are still manual, paid checks. They do not form an automated merge gate, and they do not prove a general accuracy percentage.
The observability boundary is similarly honest. The system has structured logs, tool names, guides read, token accounting and a cost ledger. It does not yet have one trace joining router decision, every tool latency, model latency, feedback and business outcome. The stored ph_distinct_id is currently null, so I cannot claim a measured link from a conversation to an affiliate conversion.
What I would improve next
The next step is a tighter quality loop.
I would turn the existing manual cases into a versioned set of fifty to one hundred scenarios covering router decisions, should-call and should-not-call behaviour, argument validity, source facts, refusals, languages and multi-turn repairs. Deterministic checks can run on every change. Paid model comparisons can run on demand or nightly and report quality, latency and estimated cost by prompt and model version.
I would also add a privacy-safe trace identifier across router, model and tool steps, then join explicit feedback and consented conversion events without making raw conversation text the default telemetry.
Retrieval experiments belong after that. The current exact catalogue and typed calculators are a strength for high-risk rules. If broader discovery across the travel corpus becomes the problem, I can compare lexical, embedding and reranked retrieval against a labelled query-to-source set instead of adding a vector database by fashion.
You can try the live product at paglipat.com, see the wider Paglipat case study, or compare it with the smaller, deliberately constrained davthecoder.com site assistant. My Applied AI and ML journal keeps the boundary between shipped product AI and the deeper ML work I am still learning explicit.
Frequently Asked Questions
Is it a multi-agent system?
No. It is one bounded request pipeline with a router and a concierge tool loop. Calling every model boundary an agent would make the architecture sound more autonomous than it is.
Does Paglipat train or fine-tune a model?
No. It uses a hosted model API.
La caja de chat es la parte menos interesante del conserje de IA de Paglipat.
Las preguntas de mudanza juntan reglas que cambian, circunstancias personales, guías de viaje propias y precios en vivo. Un wrapper fino mandaría todo eso a un modelo y se fiaría de lo que volviera. Podría acordarse de una regla de visado vieja, inventarse una fecha para una búsqueda de hotel o seguir llamando a una herramienta cuando el trabajo útil ya estaba hecho.
El modelo puede decidir qué herramienta acotada encaja con una pregunta, pero TypeScript decide si los argumentos son válidos. Los datos verificados aportan las reglas de visado. Los servicios de hoteles y de vuelos aportan los precios. El Worker controla la memoria, la privacidad, los presupuestos y la retención.
Esta es la capa de IA más profunda que hay detrás de Paglipat, un producto independiente que describí en la arquitectura más amplia con Astro, Go y Flutter. Es un recorrido de producción, no un caso de estudio de un cliente externo ni la afirmación de que cada asistente necesita exactamente esta pila.
Ideas clave
- Las llamadas a herramientas son propuestas. Cada argumento cruza una frontera de validación determinista antes de que el código lo ejecute.
- El enrutador puede ocuparse de cuatro categorías formulaicas, pero no puede redactar una respuesta factual. La ambigüedad escala.
- La memoria se persiste para las preguntas de seguimiento útiles, y luego se acota por límites de turnos, tokens, repetición y retención.
- Las comprobaciones de privacidad y de coste se ejecutan antes de la inferencia. El rechazo barato ocurre antes que la dependencia cara.
- La caché de prompts es un invariante de facturación puesto a prueba, no una optimización que espero que siga intacta.
- La implementación tiene un libro mayor, un interruptor de corte, un trabajo de retención y un runbook. Su evaluación a nivel de modelo sigue siendo manual, y etiqueto esa limitación sin rodeos.
El modelo vive dentro de un sistema determinista
El camino de producción es un Cloudflare Worker delante de una API de modelo alojada, D1, KV, un corpus de guías generado, un servicio de hoteles propio y una API de precios de vuelos de terceros. La configuración actual del modelo usa la API de chat-completions de xAI, compatible con OpenAI, con grok-4.3. El camino del Worker alrededor de la API es el diseño.
El Worker empieza con controles que no necesitan un modelo: parsear la petición, comprobar el presupuesto diario, sumar al contador de la IP y verificar una sesión nueva con Turnstile. Un enrutador decide después si una petición formulaica puede terminar en local o si debe ejecutarse el bucle del conserje. El bucle puede llamar a herramientas, pero tiene un tope duro de iteraciones.
Un cuerpo inválido no debería gastar una petición de red. Un bot que ha agotado la cuota de una IP no debería llegar a Turnstile. Un saludo no debería llegar al prompt completo ni al conjunto de herramientas. Una herramienta que falla debe convertirse en un resultado de fallo acotado, no romper la respuesta.
El sitio estático de Astro sigue llevando el contenido y la interfaz. Los servicios de Go siguen llevando los datos de viaje. D1 guarda el estado duradero del chat y el registro de conciliación de referencia para el uso que el Worker consigue anotar. KV aporta claves de tasa y de gasto que caducan. Mantener separadas esas responsabilidades es la misma razón por la que separé el resto de la arquitectura en vivo de Paglipat.
Las llamadas a herramientas son propuestas
El conserje expone cinco herramientas: check_visa, get_checklist, read_guide, search_hotels y flight_prices. Sus descripciones indican cuándo llamarlas. Un modelo conservador puede responder de memoria salvo que el disparador sea explícito.
Esta es la versión recortada de la herramienta de hoteles en producción:
{
name: 'search_hotels',
description:
'Search live hotel availability and prices. Use this when the user ' +
'asks where to stay in a specific city and has given, or can give, ' +
'dates. Do not guess dates.',
input_schema: {
type: 'object',
properties: {
city: { type: 'string' },
checkin: { type: 'string', description: 'ISO yyyy-mm-dd.' },
checkout: { type: 'string', description: 'ISO yyyy-mm-dd.' },
adults: { type: 'integer', minimum: 1, maximum: 10 },
},
required: ['city', 'checkin', 'checkout', 'adults'],
additionalProperties: false,
},
}
El esquema JSON ayuda al modelo a construir una propuesta válida. El manejador llama a un validador aparte antes de resolver una ciudad o de tocar la API de hoteles. Una entrada inválida devuelve un fallo estructurado, con una instrucción para pedir el dato que falta.
Eso le da al sistema dos comprobaciones independientes. El modelo ve un esquema estricto. El runtime sigue dando por hecho que el modelo puede mandar JSON mal formado, inventarse un campo o aportar una fecha que no existe.
La frontera también estrecha lo que vuelve. El manejador de hoteles mapea cada resultado a los pocos campos que la respuesta necesita: nombre, categoría en estrellas, puntuación de reseñas, tarifa por noche, desayuno y URL de reserva. Excluye a propósito el resumen en texto libre de Agoda. La prosa de terceros es una superficie de inyección de prompt innecesaria cuando unos campos estructurados pueden responder la pregunta.
El enrutador puede rechazar trabajo, pero no puede responder hechos
La primera versión del enrutador podría haber sido otro chatbot pequeño: clasificar la petición y componer una respuesta corta. Eso habría hecho al nivel barato capaz de colar una afirmación factual saltándose al conserje. «No necesitas visado para una estancia corta» no contiene ni un precio ni un recuento de días, pero aun así puede ser peligrosamente falsa.
Eliminé esa capacidad de forma estructural. El enrutador devuelve JSON estricto y solo puede ocuparse de greeting, thanks, off-topic o injection. El Worker, no el modelo, renderiza el texto fijo y localizado de esas categorías.
export function parseRouterOutput(raw: unknown): RouterVerdict {
const escalate = (category: string): RouterVerdict =>
({ tier: 'escalate', category });
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
return escalate('malformed');
}
const result = raw as Record<string, unknown>;
if (result.tier === 'escalate') {
return escalate(typeof result.category === 'string'
? result.category
: 'unknown');
}
if (result.tier !== 'handle' ||
typeof result.category !== 'string' ||
!HANDLE_CATEGORIES.has(result.category)) {
return escalate('malformed');
}
return { tier: 'handle', category: result.category as HandleCategory };
}
Las salidas mal formadas, desconocidas y ambiguas escalan. El fallo seguro cuesta otra llamada al modelo; no se inventa una regla de viaje. Cuando una sesión ha escalado, los turnos siguientes se quedan con el conserje, para que «¿y mi cónyuge?» conserve el contexto correcto.
Esto es enrutado por niveles según la responsabilidad y el presupuesto de tokens. Las dos constantes de modelo actuales resultan ser grok-4.3; no afirmo que haya dos familias de modelos distintas desplegadas.
Cinco herramientas conectan la conversación con sistemas verificados y en vivo
La forma más clara de entender las herramientas es seguir dos preguntas.
Una pregunta sobre el visado DTV pasa por check_visa. El manejador valida cada campo, carga un archivo de reglas versionado, verifica el identificador del consulado y luego ejecuta funciones deterministas de elegibilidad y de coste. El resultado incluye verifiedAt, una nota de fuente y mustSayVerifiedAt: true. El modelo puede explicar el resultado, pero no aporta el umbral económico de memoria.
Una pregunta de hotel sigue otro camino. Las fechas son obligatorias porque unas fechas equivocadas pueden devolver precios perfectamente verosímiles y equivocados.
El manejador resuelve un nombre de ciudad en inglés al identificador de ciudad del catálogo, consulta el servicio de hoteles en Go, limita el resultado a tres establecimientos y envuelve el precio cambiante con una nota explícita. Si la dependencia falla, devuelve hotels_unreachable. El modelo puede decirle a quien visita que no ha podido comprobar las tarifas en vivo; el Worker no filtra un fallo crudo del servicio de arriba.
El resto de herramientas sigue el mismo principio:
| Herramienta | Fuente de verdad | Frontera |
|---|---|---|
get_checklist |
Reglas de mudanza versionadas | Generación determinista de una lista con fechas |
read_guide |
Corpus propio generado en el build | Validación del slug más pertenencia al catálogo actual |
flight_prices |
API de precios recientes de TravelPayouts | Se describen como precios recientes típicos, no como inventario en vivo reservable |
Un slug tiene que pasar una expresión regular conservadora y existir en el CHAT_CATALOG generado. Esa segunda puerta impide que un documento retirado siga siendo recuperable porque da la casualidad de que un JSON viejo sigue existiendo en el origen.
El sistema selecciona a partir de un catálogo determinista y obtiene un documento propio por slug exacto. Para las reglas en las que una respuesta equivocada importa, una calculadora determinista y tipada encaja mejor que pedirle a una búsqueda por similitud que encuentre el párrafo correcto.
La memoria solo es útil cuando está acotada
Sin memoria, una pregunta de seguimiento como «¿y mi cónyuge?» no tiene referente. Repetir sin límite crea el problema contrario: cada turno se vuelve más lento, más caro y más expuesto que el anterior.
Paglipat guarda sesiones y turnos en D1. Antes de una petición al conserje, carga los últimos seis turnos que tienen una respuesta no vacía escrita por el modelo, el más antiguo primero. Los saludos que resuelve el enrutador se excluyen porque el texto fijo no aporta contexto útil. Un flujo que emitió algo de texto y luego falló se guarda ahora mismo y se puede repetir; solo se excluye una respuesta fallida vacía. Filtrar las respuestas en streaming incompletas es, por tanto, una mejora de fiabilidad pendiente, no un control que tenga la implementación actual.
La inserción del turno y la actualización del contador de la sesión van en un solo lote de D1:
await db.batch([
db.prepare(
`INSERT INTO chat_turns
(session_id, created_at, question, locale, tier,
tools_used, guides_read, ph_distinct_id, answer)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(/* bounded turn fields */),
db.prepare(
`UPDATE chat_sessions
SET turn_count = turn_count + 1,
output_tokens = output_tokens + ?
WHERE id = ?`
).bind(t.outputTokens, t.sessionId),
]);
Esa atomicidad es un control de coste. Los mismos contadores imponen el límite de sesión. Si el turno se confirmara pero fallara la actualización de su contador, el estado persistido contaría de menos, en silencio, el uso futuro.
Está acotada por seis turnos repetidos, treinta turnos en total, sesenta mil tokens de salida y un trabajo de retención.
Los controles de privacidad van antes de la inferencia
El conserje toma varias decisiones antes de que un prompt salga del Worker.
El parser de la petición acota la longitud del mensaje, el locale, la moneda, la forma del UUID y la página actual. La página tiene que ser una ruta del mismo origen. Rechaza tanto las rutas relativas al protocolo que empiezan por // como las barras invertidas, porque el parser de URL de WHATWG puede normalizar una barra invertida en una URL de otro origen.
La dirección IP se hashea con SHA-256 y una sal antes de guardarse. Las IP en bruto no entran en D1. Las sesiones nuevas exigen un desafío de Turnstile válido según si la sesión existe, no según si quien llama aportó un UUID. Esa distinción cerró un rodeo por el que quien llama podía generar un ID nuevo y bien formado y saltarse el desafío de «sesión nueva».
También hay un filtro de entrada estrecho a propósito para zonas de lectura mecánica del pasaporte, patrones de número de pasaporte y números de tarjeta de pago agrupados. Cuando se dispara, pasan tres cosas:
- quien visita recibe una respuesta local segura;
- el modelo no recibe nunca el texto;
- la pregunta guardada pasa a ser
[redacted sensitive input].
No es una detección exhaustiva de datos personales, y no lo describo así. Es una barrera barata, previa a la inferencia, para el material más obvio que no debería entrar nunca en este asistente de viaje.
Las transcripciones se borran a los noventa días mediante un manejador programado del Worker. El libro de costes se guarda aparte porque almacena el modelo, los recuentos de tokens, los recuentos de tokens de caché y los microdólares, no el texto de la pregunta ni un hash de IP. Esa separación hace posible la conciliación a largo plazo sin conservar la conversación de forma indefinida.
El control de coste es una cadena
Un solo valor de max_tokens no controla el coste de una conversación que usa herramientas. Un endpoint público necesita límites alrededor de la petición, la sesión, el bucle del modelo y el día entero.
Los límites configurados ahora mismo están a propósito a la vista en un único módulo puro de constantes:
| Frontera | Configuración actual |
|---|---|
| Tope diario del radio de impacto | 5,00 $ |
| Peticiones por IP y hora | 20 |
| Turnos por sesión | 30 |
| Tokens de salida por sesión | 60.000 |
| Tokens de salida por respuesta | 2.048 |
| Iteraciones del bucle de herramientas del conserje | 8 |
Son límites, no una prueba de ahorro ni del comportamiento normal de quien usa el producto.
La cadena de barreras está ordenada por coste: parseo, lectura del presupuesto en KV, cubo de IP y luego Turnstile. El presupuesto diario se vuelve a comprobar dentro del bucle de herramientas antes de cada paso del modelo. Cuando el proveedor devuelve el uso con éxito, el Worker pone precio a los tokens de entrada, de salida y de lectura de caché y los escribe en el libro mayor de conciliación de D1.
KV aporta contadores rápidos que caducan, pero su incremento es una lectura seguida de una escritura. Las peticiones concurrentes pueden perder una actualización. Lo acepto para un limitador de radio de impacto y lo digo en el código. D1 es el registro de conciliación.
El runbook añade la última capa: un comando de emergencia empuja la clave de gasto del día por encima del tope. En cuanto esa actualización de KV es visible, las peticiones siguientes se paran antes de una llamada al modelo. Un presupuesto a cero a nivel de código está a un despliegue de distancia.
La caché de prompts pasó a ser un invariante de facturación puesto a prueba
La mayor entrada del conserje es la parte estable: las reglas de sistema, el catálogo de artículos, el resumen verificado de visados y los esquemas de las herramientas. Las fechas, el locale, la moneda, la página actual y el historial de la conversación son volátiles.
Si un valor volátil entra en el prefijo, la API no informa de un error de configuración. Las lecturas de caché caen en silencio a cero y cada turno paga la tarifa completa de entrada. Por eso construyo la petición con un primer mensaje estable y muevo el contexto propio de la petición al mensaje final del usuario:
const messages: XaiMessage[] = [
{
role: 'system',
content: buildSystemText(args.catalog, args.visaDigest),
},
...args.history.map((turn) => ({
role: turn.role,
content: historyContent(turn.content),
})),
{
role: 'user',
content:
`${buildContextPreamble(args.ctx)}\n\n` +
wrapUserMessage(args.message),
},
];
El orden de las herramientas está congelado por la misma razón. Reordenar esquemas equivalentes cambia los bytes del prefijo y tira la reutilización. Los tests afirman que las fechas, el locale, la página y los valores de sesión no se filtran al texto de sistema estable.
El runbook de despliegue también comprueba el resultado en producción. En los seis primeros turnos anteriores del conserje en producción anotados en ese runbook, el libro mayor observó 53.702 tokens leídos de caché frente a 11.662 tokens de entrada escritos. Es una muestra operativa pequeña y fechada, usada para verificar la forma esperada de una escritura y muchas lecturas. No es una tasa representativa de aciertos de caché, ni un benchmark, ni una afirmación de ahorro.
El bucle de herramientas tiene que fallar de forma segura
El conserje hace streaming del texto y, a la vez, acumula llamadas a herramientas. Cuando llega una llamada, el Worker parsea sus argumentos JSON, valida y ejecuta el manejador, y luego añade un resultado de herramienta específico del rol antes del siguiente paso del modelo.
for (let i = 0; i < TOOL_MAX_ITERATIONS; i++) {
const still = checkBudget(await readSpendToday(env.CHAT_KV, dayKey));
if (!still.ok) {
await send({ type: 'error', error: 'daily_budget_exhausted' });
break;
}
const step = await streamChatCompletion({
apiKey,
convId: sessionId,
body: { ...params, messages },
onText: async (text) => send({
type: 'text',
text: sanitizeAnswer(text),
}),
});
await bill(MODEL_CONCIERGE, step.usage);
if (step.tool_calls.length === 0) break;
// Parse, validate, run and append each tool result.
}
Un JSON de herramienta mal formado se convierte en un resultado de entrada inválida. Una herramienta desconocida se convierte en unknown_tool. Una excepción del manejador se captura y se devuelve como tool_failed. El modelo recibe una pista pequeña para que reconozca que no ha podido recuperar los datos, en lugar de rellenar el hueco de memoria.
El navegador y el modelo también reciben vistas distintas de un resultado con éxito. Los enlaces profundos y las tarjetas de resultado van directos al navegador como frames SSE tipados. El modelo solo ve los datos que necesita para explicar la respuesta. Una herramienta fallida no envía una tarjeta vacía, porque una tarjeta vacía parece una búsqueda con éxito y sin resultados.
El bucle se para a las ocho iteraciones aunque el modelo siga queriendo herramientas. El filtrado de contenido y el corte por longitud se convierten en eventos terminales explícitos. Si quien visita se va durante la escritura de una tarjeta, esa decoración no puede abortar la facturación ni la persistencia del turno. En el finally, el flujo se cierra y el Worker intenta registrar el turno exactamente una vez.
El trabajo de producción sigue después del despliegue
El repositorio incluye un runbook de operaciones porque «desplegado» no es el estado final de un endpoint de modelo público.
Cubre dónde van la site key pública de Turnstile y el secreto privado, cómo migrar D1 en remoto, por qué el sitio estático tiene que desplegarse antes que el Worker que obtiene su corpus de guías, cómo confirmar las lecturas de caché, cómo disparar los controles de tasa y de presupuesto, cómo purgar transcripciones a mano y cómo consultar el gasto por día y por modelo.
La suite automatizada ejercita el parseo de peticiones, los presupuestos, los límites de tasa, las respuestas de Turnstile, la detección de entrada sensible, la construcción del prompt, el enrutado, los esquemas de las herramientas, los manejadores, el precio del coste, la retención y el adaptador de streaming de xAI. La tabla de red team de producción añade ocho casos a nivel de modelo: reglas de visado que cambian, la negativa a colapsar respuestas condicionales, prueba financiera anclada a herramientas, fronteras de países no soportados, inyección de prompt, fechas de hotel ausentes, saludos y preguntas fuera de tema.
Los ocho quedaron anotados como superados cuando se escribió el runbook. Siguen siendo comprobaciones manuales y de pago. No forman una barrera automática de merge, y no demuestran un porcentaje general de precisión.
La frontera de observabilidad es igual de honesta. El sistema tiene logs estructurados, nombres de herramientas, guías leídas, contabilidad de tokens y un libro de costes. Todavía no tiene una traza que una la decisión del enrutador, la latencia de cada herramienta, la latencia del modelo, el feedback y el resultado de negocio. El ph_distinct_id guardado es ahora mismo null, así que no puedo afirmar un vínculo medido entre una conversación y una conversión de afiliación.
Qué mejoraría a continuación
El siguiente paso es un bucle de calidad más cerrado.
Convertiría los casos manuales que ya existen en un conjunto versionado de cincuenta a cien escenarios que cubran decisiones del enrutador, comportamiento de debería-llamar y no-debería-llamar, validez de los argumentos, hechos de la fuente, rechazos, idiomas y reparaciones de varios turnos. Las comprobaciones deterministas pueden ejecutarse en cada cambio. Las comparaciones de pago del modelo pueden ejecutarse bajo demanda o cada noche, e informar de calidad, latencia y coste estimado por prompt y por versión del modelo.
También añadiría un identificador de traza seguro para la privacidad a través de los pasos del enrutador, del modelo y de las herramientas, y luego uniría el feedback explícito y los eventos de conversión con consentimiento, sin hacer del texto crudo de la conversación la telemetría por defecto.
Los experimentos de recuperación van después. El catálogo exacto actual y las calculadoras tipadas son una fortaleza para las reglas de alto riesgo. Si el problema pasa a ser un descubrimiento más amplio por el corpus de viaje, puedo comparar recuperación léxica, por embeddings y reordenada frente a un conjunto etiquetado de consulta a fuente, en lugar de añadir una base de datos vectorial por moda.
Puedes probar el producto en vivo en paglipat.com, ver el caso de estudio más amplio de Paglipat, o compararlo con el asistente del sitio de davthecoder.com, más pequeño y acotado a propósito. Mi diario de IA aplicada y ML mantiene explícita la frontera entre la IA de producto ya publicada y el trabajo de ML más profundo que todavía estoy aprendiendo.
Preguntas frecuentes
¿Es un sistema multiagente?
No. Es un único pipeline de petición acotado, con un enrutador y un bucle de herramientas del conserje. Llamar agente a cada frontera del modelo haría que la arquitectura sonara más autónoma de lo que es.
¿Paglipat entrena o hace fine-tuning de un modelo?
No. Usa una API de modelo alojada.

Loading comments…Cargando comentarios…