# Prompt Engineering Guide — Platform-Aware

Guía operativa para escribir e iterar prompts en Ninjo. Cada regla está respaldada por investigación documentada en `fix-lab/research/`.

**Complementa al [playbook](../playbook.md)** (reglas de conversación cross-agent) y a [PLATFORM_FEATURES.md](PLATFORM_FEATURES.md) (referencia exhaustiva de la plataforma).

**Audiencia**: Prompt authors — quienes escriben y mantienen system.md, examples, knowledge base, y configs de agentes.

---

## §1. Reglas de plataforma que todo prompt debe respetar

Restricciones que NO se resuelven con prompt engineering — hay que diseñar alrededor de ellas.

| # | Regla | Por qué | Source |
|---|---|---|---|
| 1 | El agente solo ve los últimos ~10 mensajes | History window fijo del runtime. Mensajes anteriores son invisibles para el LLM | [diagnostic-test-findings] |
| 2 | Mensajes ≤80 chars nunca se splitean | `split-message.ts:218` retorna el contenido sin llamar a GPT. Hardcoded, no configurable. Causa principal de que aperturas cortas no se dividan en burbujas | [message-splitting-mechanics] |
| 3 | First Contact Trigger messages nunca se splitean (bug) | `main-handler.ts:1362` envía mensajes de trigger pre-configurados sin `agentConfig` → chunking deshabilitado. Solo afecta agentes con FC triggers (ej: Angie). No afecta respuestas del LLM | [message-splitting-mechanics] |
| 4 | GPT-4.1-mini decide el split para mensajes >80 chars | NO es `\n\n`. Un segundo LLM (temp 0.3) decide cortes semánticos. `\n\n` solo aplica en fallback si GPT falla | [message-splitting-mechanics] |
| 5 | Entre burbujas hay delay configurable | `delay_split`: LOW (0.6-3.6s), MEDIUM (4-7s), HIGH (8-11s). No se controla desde el prompt | [response-delay-analysis] |
| 5 | Los JSON (keywords, resources, program, case_studies) son tools on-demand, NO contexto estático | Si no mencionás las tools en system.md, el agente no las invoca | [prompt-assembly-pipeline] |
| 6 | system.md es el primer bloque que lee el LLM | Lo que pongas primero tiene más peso. Hard rules al TOP | [prompt-assembly-pipeline] |
| 7 | RAG = full text dump, no vector search | Un PDF de 20 páginas = ~20K chars de contexto que diluye la atención del modelo | [diagnostic-test-findings] |
| 8 | Audio llega como transcripción de texto | El agente no sabe que el usuario habló. No hay señal de modalidad | [diagnostic-test-findings] |
| 9 | No hay summary de conversación en single-agent | Solo INTENT_WORKFLOW (multi-agent) genera resúmenes. Cada turno del agente solo tiene el history window | [conversation-summary-mechanics] |
| 10 | El modelo es el mismo para todos los v5 | No se puede elegir Claude vs GPT por agente. Todos usan el mismo modelo del runtime v5 | [diagnostic-test-findings] |
| 11 | Custom properties se inyectan como contexto invisible | 20-53 properties × ~50 chars = 1-3K tokens compitiendo con tu prompt por atención del modelo | [custom-properties-in-context] |
| 12 | `shouldRespondCheckEnabled` es dead code | La flag existe en config pero el runtime la ignora. No usarla | [response-evaluation-mechanics] |
| 13 | Prompt Evaluator solo funciona en trigger ALL_DM | Keyword triggers no pasan por el evaluator — la evaluación se hace antes del matching | [response-evaluation-mechanics] |
| 14 | Follow-ups cuentan desde último INBOUND | Outbound del agente o de un humano NO resetea el timer de min_minutes_since_last_inbound | [workflow-timing-mechanics] |
| 15 | `collapse_markdown()` colapsa `\n` sueltos en prosa | Single newlines en texto plano se convierten en espacio. Listas, headers, emoji lines se preservan. Testear con `--stdout` | [prompt-assembly-pipeline] |

### Implicaciones para diseño de prompts

**Memory window (~10 mensajes)**:
- Front-load las preguntas de discovery. Si esperás al turno 12 para preguntar algo clave, el contexto de los primeros turnos ya desapareció
- Si el agente necesita "recordar" algo, usá un memory schema en el prompt que el LLM llene por tool calling (custom properties)
- No asumas que el agente puede referenciar algo que el usuario dijo 15 mensajes atrás

**Burbujas WhatsApp**:
- Diseñá los examples con `\n\n` explícito para marcar cortes de burbuja
- Anotá cada example con `<!-- N burbujas -->` para auditar rápido
- Regla de oro: max 3 burbujas por respuesta (~40 seg). 4 burbujas solo para la respuesta de saludo con modalidades

**Tools on-demand**:
- Las 4 tools (`lookup_keyword`, `get_resource`, `get_case_study`, `get_program`) solo se activan si system.md las menciona
- Escribí cuándo usar cada tool: "Cuando el usuario pregunte por precio, usá `get_program()` para obtener los detalles"
- Sin instrucciones explícitas → el agente no sabe que tiene herramientas disponibles

---

## §2. Checklist pre-deploy

Antes de deployar con `deploy_agent_sdk`, verificar cada item:

- [ ] **Bubble rules**: system.md tiene sección explícita de formato de burbujas con regla `\n\n` = burbuja nueva
- [ ] **Examples anotados**: cada example tiene `<!-- N burbujas -->` y ninguno excede `limitSplitMessages`
- [ ] **Hard rules al TOP**: las reglas que nunca deben romperse están en los primeros 500 chars del prompt
- [ ] **Tools mencionadas**: las 4 tools (lookup_keyword, get_resource, get_case_study, get_program) están referenciadas en system.md con instrucciones de cuándo usarlas
- [ ] **Sin dependencia de memoria larga**: ninguna instrucción asume que el agente recuerda más de ~10 mensajes
- [ ] **Estructura revisada**: releíste `system.md` y confirmaste que la estructura (saltos `\n\n`, burbujas) queda intacta — `deploy_agent_sdk` escribe el prompt tal cual, así que lo que ves es lo que se deploya
- [ ] **Follow-ups independientes**: los prompts en `followups/` no asumen contexto del prompt principal ni de mensajes anteriores a su activation window
- [ ] **Escalation cerrada**: si objections.md tiene niveles de escalation, el nivel 3 tiene un cierre claro (deriva a humano o cierra conversación). No loops infinitos
- [ ] **Sin Markdown links**: no hay `[texto](url)` en examples ni en system.md. WhatsApp no renderiza Markdown links — el usuario ve el texto crudo. Usar URL bare: "Acá te paso: https://..."
- [ ] **`[[CONTACT_ID]]` correcto**: si hay tracking, el placeholder está dentro de la URL literal (`?utm_term=[[CONTACT_ID]]`), no en instrucciones al LLM
- [ ] **URLs en tools, no en prompt** (SDK): las URLs viven en resources.json/keywords.json, no duplicadas en system.md. Si divergen, el LLM se confunde
- [ ] **Changelog actualizado**: entrada con formato `problema → investigación → solución`

---

## §3. Diagnóstico de feedback — árbol de decisión

Cuando llega feedback del cliente, clasificar ANTES de tocar el prompt.

### "El agente no recuerda lo que dije"

```
¿Cuántos mensajes lleva la conversación?

→ >10 mensajes: LIMITACIÓN DE PLATAFORMA (history window)
  No se arregla con prompt. Acciones:
  • Front-load preguntas críticas en los primeros 5 turnos
  • Agregar memory schema en system.md con custom properties
    para persistir datos clave más allá del window
  • Considerar: ¿la conversación debería ser más corta?

→ <10 mensajes: BUG DE PROMPT
  El contexto está disponible pero el agente no lo usa. Acciones:
  • Revisar si el flow tiene skip-if-known directives
  • Verificar examples: ¿muestran cómo usar info previa?
  • Agregar regla: "Revisá el historial antes de preguntar"
```

### "Responde con muchos mensajes / tarda mucho"

```
Contar burbujas promedio (query: ver §5)

→ >3 burbujas por respuesta: PROBLEMA DE PROMPT
  Acciones:
  • Reducir en examples (restructurar con menos \n\n)
  • Agregar regla explícita: "Máximo N burbujas por respuesta"
  • Verificar que limitSplitMessages está configurado
  • Recordar: 4 burbujas = ~55-70 seg de espera

→ ≤3 burbujas pero aún lento: PROBLEMA DE CONFIG
  Acciones:
  • Revisar response_delay_min/max_seconds en agent_configs
  • Verificar delay_split (LOW/MEDIUM/HIGH)
  • Query: SELECT response_delay_* FROM agent_configs WHERE agent_id = X
```

### "Responde a mensajes que no debería"

```
¿Qué trigger tiene el agente?

→ ALL_DM (responde a todo):
  Agregar Prompt Evaluator al trigger. Ver §4 para templates.
  El evaluator es un LLM gate que bloquea mensajes irrelevantes
  antes de que lleguen al agente principal.

→ Keyword trigger:
  No soporta Prompt Evaluator. Acciones:
  • Agregar sección "Response Gate" en system.md
  • Regla: "Si el mensaje es solo un saludo/emoji/sticker,
    respondé brevemente sin iniciar el flow de ventas"
  • Documentar qué tipos de mensaje filtrar en feedback.md
```

### "Responde cosas inventadas / datos incorrectos"

```
¿La información correcta existe en los SDK files?

→ No existe: DATO FALTANTE
  • Agregar a knowledge_base.md o al JSON correspondiente
  • Verificar que el tool call correcto está mencionado en system.md

→ Sí existe pero el agente inventa otro dato: HALLUCINATION
  • Agregar regla: "Si no tenés información sobre X,
    decí que vas a consultar al equipo de {creator}"
  • Verificar que RAG docs no tienen info contradictoria
  • Si hay PDF de RAG: recordar que son full-text dump (~20K chars)
    y pueden estar diluyendo la atención del modelo
```

### "El follow-up se envía cuando ya respondió un humano"

```
LIMITACIÓN DE PLATAFORMA: min_minutes_since_last_inbound
no se resetea con mensajes outbound (ni del agente ni humanos).

Acciones:
• Verificar si pause_on_manual_outbound está habilitado
  (pausa agente cuando un humano responde)
• Ajustar timing del workflow: interval y min_minutes
• Si el humano suele responder rápido, acortar el window
```

### "Manda el link equivocado / no encuentra el link"

```
¿El agente es v5 (SDK) o v1/v2 (monolítico)?

→ SDK (v5): ¿system.md menciona las tools?
  → No: BUG DE PROMPT. Agregar instrucciones explícitas:
    "Usá get_resource(topic) para buscar links de recursos"
    "Usá lookup_keyword(keyword) para respuestas con links"
  → Sí: verificar resources.json — ¿el link está ahí?
    → No: agregarlo al SDK file correcto
    → Sí: revisar topic_index — ¿el topic matchea?

→ HARDCODED (v1/v2): ¿cuántas URLs tiene el prompt?
  → >50 URLs: el modelo pierde URLs en prompts largos
    Acciones:
    • Agrupar URLs por sección temática (## Links de X)
    • Poner las más usadas al principio
    • Considerar migrar a SDK (v5)
  → <50 URLs: revisar si la URL está bien formateada
    • Sin espacios, sin saltos de línea dentro de la URL
    • No envuelta en Markdown [texto](url)
    • Verificar con --stdout si collapse_markdown() rompió algo
```

### "El link aparece como texto raro / no es clickeable"

```
MARKDOWN LINKS en WhatsApp — bug confirmado.

El LLM genera [texto](url) pero WhatsApp no renderiza Markdown.
El usuario ve: "[Agenda tu llamada](https://calendly.com/...)"

Acciones:
• Agregar regla en system.md: "Nunca uses formato Markdown
  para links. Enviá la URL sola en su propia línea."
• Revisar examples: reemplazar [texto](url) por URL bare
• Agentes afectados confirmados: Valen, Juanma ADS,
  Mica Gallardo, Money Coach Vince, Angie ONN
```

### "El agente suena robótico / como bot"

```
Revisar tres cosas:

1. Playbook compliance
   • Anti-mirroring: no "entiendo/comprendo", max 3 words acknowledgment
   • No "servicial assistant" phrases
   • No hyphens, no ¿¡

2. Examples naturales
   • ¿Los examples suenan como DMs reales o como respuestas formulaicas?
   • ¿Hay variedad en los openings o todos empiezan igual?

3. Prompt size
   • ¿El prompt tiene >800 líneas?
   • Rule overload mata compliance: el modelo prioriza y pierde las sutilezas
   • Considerar: principios > micro-reglas
```

---

## §4. Prompt Evaluator — cuándo y cómo configurarlo

Para agentes con trigger ALL_DM que reciben ruido (saludos, spam, reacciones, mensajes para otro agente).

### Cuándo usarlo

- El agente responde a >30% de mensajes irrelevantes
- El agente tiene trigger ALL_DM y comparte inbox con otros agentes o con un humano
- Querés ahorrar costo: cada mensaje bloqueado ahorra ~$0.01-0.05 del LLM call principal

### Template corto (~250 chars)

Basado en Lolo (lolocappucci) — block rate 75%:

```
Respond ONLY with "true" or "false".
Return "true" if the message is a genuine question, request for information,
or shows interest in {CREATOR}'s {TOPIC}.
Return "false" for: greetings only, reactions, emojis only, spam,
messages clearly meant for someone else, or automated messages.
```

### Template extendido (~1000 chars)

Basado en MultiAgent Router (endika) — block rate 49%, más permisivo:

```
Respond ONLY with "true" or "false".

Evaluate whether this Instagram DM should receive a response from
{CREATOR}'s AI assistant.

Return "true" if ANY of these apply:
- User asks a question about {TOPIC_AREA}
- User expresses interest in {CREATOR}'s programs/services
- User shares a personal situation related to {TOPIC_AREA}
- User responds to a previous message from the assistant
- User sends a voice message (always process)

Return "false" if ALL of these apply:
- Message is just a greeting with no question ("hola", "hi")
- Message is a reaction or emoji only
- Message is clearly spam or promotional
- Message is an automated platform notification
- Message is a simple "ok", "thanks", "👍" with no follow-up question

When in doubt, return "true" — it's better to respond unnecessarily
than to miss a potential lead.
```

### Verificar post-deploy

Query para ver evaluaciones (ver §5 para query completa):
- Block rate esperado: 50-75% para agentes de ventas
- Si block rate >90%: prompt evaluator demasiado agresivo, revisar
- Si block rate <30%: prompt evaluator demasiado permisivo, ajustar

### Costo

- ~$0.0006 por evaluación (modelo rápido, prompt corto)
- Ahorro: ~$0.01-0.05 por mensaje bloqueado (evita el LLM call del agente principal)
- Para un agente con 100 mensajes/día y 60% block rate: ahorro neto ~$1.50/día

---

## §5. Queries de diagnóstico rápido

SQL snippets para cuando llega feedback y necesitás datos. Correr contra la DB de producción.

### Últimos N mensajes de un agente

```sql
SELECT
  m.direction,
  m.content,
  m.created_at,
  m.message_type
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE c.agent_id = '{AGENT_ID}'
  AND c.contact_id = '{CONTACT_ID}'
ORDER BY m.created_at DESC
LIMIT 20;
```

### Conteo de burbujas promedio

Detectar si un agente está generando demasiadas burbujas:

```sql
SELECT
  c.agent_id,
  AVG(
    ARRAY_LENGTH(
      STRING_TO_ARRAY(m.content, E'\n\n'), 1
    )
  ) AS avg_bubbles,
  MAX(
    ARRAY_LENGTH(
      STRING_TO_ARRAY(m.content, E'\n\n'), 1
    )
  ) AS max_bubbles,
  COUNT(*) AS total_messages
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE c.agent_id = '{AGENT_ID}'
  AND m.direction = 'OUTBOUND'
  AND m.created_at > NOW() - INTERVAL '7 days'
GROUP BY c.agent_id;
```

### Config actual del agente

```sql
SELECT
  a.name,
  ac.response_delay_min_seconds,
  ac.response_delay_max_seconds,
  ac.config->>'limitSplitMessages' AS limit_split,
  ac.config->>'enable_message_chunking' AS chunking,
  ac.config->>'delay_split' AS delay_split,
  ac.config->>'shouldRespondCheckEnabled' AS respond_check,
  ac.version
FROM agent_configs ac
JOIN agents a ON a.id = ac.agent_id
WHERE ac.agent_id = '{AGENT_ID}';
```

### Evaluaciones del Prompt Evaluator

```sql
SELECT
  te.result AS should_respond,
  te.reasoning,
  te.cost,
  te.created_at,
  m.content AS trigger_message
FROM trigger_evaluations te
JOIN messages m ON m.id = te.message_id
WHERE te.agent_id = '{AGENT_ID}'
  AND te.created_at > NOW() - INTERVAL '7 days'
ORDER BY te.created_at DESC
LIMIT 30;
```

Block rate:

```sql
SELECT
  COUNT(*) AS total_evals,
  COUNT(*) FILTER (WHERE result = false) AS blocked,
  ROUND(
    COUNT(*) FILTER (WHERE result = false)::numeric / COUNT(*)::numeric * 100, 1
  ) AS block_rate_pct
FROM trigger_evaluations
WHERE agent_id = '{AGENT_ID}'
  AND created_at > NOW() - INTERVAL '7 days';
```

### Follow-up timing real vs. configurado

```sql
SELECT
  w.name AS workflow_name,
  w.interval_minutes,
  w.min_minutes_since_last_inbound,
  w.only_run_once,
  w.track_task_state,
  COUNT(wl.id) AS total_runs,
  COUNT(wl.id) FILTER (WHERE wl.action = 'SENT') AS sends,
  COUNT(wl.id) FILTER (WHERE wl.action = 'SKIPPED') AS skips
FROM workflows w
LEFT JOIN workflow_logs wl ON wl.workflow_id = w.id
  AND wl.created_at > NOW() - INTERVAL '7 days'
WHERE w.agent_id = '{AGENT_ID}'
GROUP BY w.id, w.name, w.interval_minutes,
  w.min_minutes_since_last_inbound, w.only_run_once, w.track_task_state;
```

---

## Links a investigación

Todos los research docs están en `fix-lab/research/`:

| Doc | Qué cubre |
|---|---|
| [diagnostic-test-findings] | Inventario completo de contexto: history window, RAG, audio, modelo |
| [message-splitting-mechanics] | Reglas de `\n\n`, `collapse_markdown()`, `limitSplitMessages` |
| [response-delay-analysis] | Timing: initial delay, inter-bubble delay, fases de respuesta |
| [prompt-assembly-pipeline] | Los 11 bloques que ve el LLM, orden, transformaciones |
| [conversation-summary-mechanics] | Summaries solo en multi-agent, formato, tamaño |
| [custom-properties-in-context] | Inyección de properties, escala (20-53 props), categorías |
| [response-evaluation-mechanics] | Prompt Evaluator vs shouldRespondCheck (dead code) |
| [workflow-timing-mechanics] | Follow-up evaluation loop, only_run_once, timing rules |
| [link-delivery-mechanics] | URLs hardcoded vs SDK tools, Markdown links bug, `[[CONTACT_ID]]` |

[diagnostic-test-findings]: ../fix-lab/research/diagnostic-test-findings.md
[message-splitting-mechanics]: ../fix-lab/research/message-splitting-mechanics.md
[response-delay-analysis]: ../fix-lab/research/response-delay-analysis.md
[prompt-assembly-pipeline]: ../fix-lab/research/prompt-assembly-pipeline.md
[conversation-summary-mechanics]: ../fix-lab/research/conversation-summary-mechanics.md
[custom-properties-in-context]: ../fix-lab/research/custom-properties-in-context.md
[response-evaluation-mechanics]: ../fix-lab/research/response-evaluation-mechanics.md
[workflow-timing-mechanics]: ../fix-lab/research/workflow-timing-mechanics.md
[link-delivery-mechanics]: ../fix-lab/research/link-delivery-mechanics.md
