Documento de diseño · 2026-09-06

Esto es el diseño tal como se escribió ese día, y se deja como está. Lo que el árbol hace hoy está en ARCHITECTURE.md y en docs/protocol/, y donde este documento y el código no coinciden, el código es lo que pasó. Varias cosas de acá cambiaron de nombre o de forma al construirse — los scopes de una key, entre otras.

Pinecall v2

El harness de voice AI para contact centers, construido sobre LiveKit. Una llamada es un objeto: los campos son el estado, los métodos son lo que el modelo puede hacer, el prompt es una función del estado, y cada palabra queda en un ledger que se puede auditar, re-evaluar y aprender.

01La tesis

Vapi vende un panel. Pipecat y LiveKit venden un runtime. Nadie vende la capa de aplicación: cómo se estructura un negocio hablado, cómo se prueba, cómo se audita, cómo se le pide permiso a alguien antes de hacer algo irreversible.

Pinecall v2 es esa capa. La analogía exacta es Rails sobre Rack: Rails no inventó el servidor HTTP, puso el valor en la forma de la app, las convenciones y la cultura de tests. Nuestro protocolo es el Rack de la voz; LiveKit es el Puma; el framework es el Rails.

Un agente es un objeto. Los campos son el estado. Los métodos son capacidades. Los docstrings son prompts. Los tipos son contratos. El prompt es render(state). Las tools son lo único que cambia el estado. El log es la verdad.

La frase viene de NOOA (NVIDIA-Labs Object-Oriented Agents, arXiv 2607.20709, Apache 2.0) y la tomamos entera. Lo que agregamos es lo que un coding agent no necesita y una recepción sí: una view porque la conversación tiene fases, un gate de confirmación porque hay acciones irreversibles con un humano al teléfono, memoria por contacto porque la gente vuelve a llamar, y el ledger porque hay que poder auditar cada palabra.

Para quién

El público final habla con el agente por web WhatsApp teléfono — nunca ve código. El código es para quien construye el agente: un desarrollador que en una tarde tiene una recepción que atiende tres canales, recuerda al cliente, y tiene tests.

02The art of voice AI

Lo que aprendimos construyendo Pinecall dos años y convo en cuatro días. Cada regla tiene una medición detrás.

  1. El juez no tiene que ser más listo; tiene que ver más y decidir menos. Una política dura — “nunca reservar antes de un sí explícito” — es un grafo donde el código decide todo lo decidible y el modelo contesta una sola pregunta binaria con la evidencia adjunta. Cero llamadas al juez en el camino feliz. GEval flipeó entre 0.0 y 0.9 sobre la misma respuesta correcta hasta que hicimos esto.
  2. El consentimiento es un token, no un estado de ánimo. Un “sí” acuña un token de un solo uso ligado a sha(tool + args) con TTL. La plataforma rechaza lo irreversible sin él. El modelo puede prometer; solo la plataforma ejecuta.
  3. El log es la verdad. Append-only, un seq por evento, escrito antes de devolver el control — un proceso muerto a mitad de llamada deja un log que termina donde terminó la llamada. Consola, evals y memoria son lectores del mismo log.
  4. El prompt tiene tres regiones y el orden importa. Prefijo estático cacheado · historia append-only · bloques dinámicos al final. La ganancia de caché depende de ese orden. La view del estado va al final, nunca adelante.
  5. La fecha es un tool result, no un system message. El framework reescribe los system messages intermedios como turno del usuario y Haiku los contesta. Un resultado de tool es evidencia; una instrucción es alguien hablándote.
  6. Cada tool en la superficie cuesta. 154 corridas: agregar una tool que un stage nunca usa bajó el acierto de 95 % a 79 % (p = 0.025), y ningún párrafo del prompt lo arregló. La visibilidad de una tool es una condición sobre el estado.
  7. Leé el fallo del eval antes de arreglarlo. De los primeros cinco fallos, cuatro eran del eval y uno del agente. Ablandar el golden hubiera escondido el defecto real.
  8. Un golden nunca se ablanda para que pase un modelo. La suite es lo fijo; el modelo es la variable. Una divergencia entre modelos es un hallazgo, no un bug del test.
  9. Compactar la vista, nunca el log. Una fase cerrada se pliega a una línea en lo que ve el modelo. El ledger queda entero y buscable.
  10. Cada milestone termina con un comando que corre el humano. Si no hay comando, no terminó.

03Arquitectura

Dos procesos Python sobre LiveKit, un Postgres, una clase en Node, y un lector del log fuera del servidor. Hosted y self-hosted son la misma imagen.

 web (widget) · WhatsApp · ☎ teléfono              ← el público
        │            │           │
        │ texto      │ texto     │ SIP → livekit-sip → livekit-server (SFU) → worker
        ▼            ▼           ▼
 ┌────────────────────────────────────────────────────────────────────────────┐
 │  GATEWAY  (Python · FastAPI)                                                │
 │   apps        WS del protocolo ↔ la clase del tenant                        │
 │   text        sesiones chat y WhatsApp: mismo agente, sin audio             │
 │   log         el ledger: seq · gap · caught_up · SSE · export OTel GenAI    │
 │   knowledge   push por path · chunk por heading · bge-m3 + BM25 · RRF       │
 │   memory      recall() ≤ 150 ms en el turno · remember() al colgar          │
 │   evals       rings 1–4 · PolicyJudge · consent · grounded · call.score     │
 │   auth        keys · tokens sellados · grants por canal                     │
 └──────────────────┬───────────────────────────▲──────────────────────────────┘
                    │ HTTP                      │ HTTP
                    ▼                           │
 ┌───────────────────────────────┐   ┌──────────┴──────────────────────────┐
 │  POSTGRES 17                  │   │  WORKER  (Python · livekit-agents)  │
 │  pgvector · pg_textsearch     │   │  1 proceso por llamada              │
 │  tenants · routes · call_log  │   │  session   STT · LLM · TTS · VAD    │
 │  kb_chunks · contact_memories │   │  bridge    eventos ↔ protocolo      │
 │  eval_runs                    │   │  confirm   el sí → token → run      │
 └───────────────────────────────┘   └───────────▲─────────────────────────┘
                                                 │ dispatch: room → job
                                     ┌───────────┴─────────────────────────┐
  navegador / móvil ─ WebRTC ───────▶│  livekit-server (SFU) · livekit-sip │◀── SIP ── Twilio ── ☎
                                     │  redis                              │
                                     └─────────────────────────────────────┘

  TU APP (Node, donde quieras)            CONSOLA (React)
  class ClinicaNorte extends Agent  ──WS──▶ gateway ◀──SSE── Calls · Sessions · Evals · Pipeline · Supervisor · Board

Una llamada, paso a paso

 1  new ClinicaNorte()     → WS al gateway: "soy clinica-norte, atiendo +34 910…"
 2  ☎ entra por Twilio     → SIP → livekit-sip crea el room → livekit-server despacha un worker
 3  worker.router          → "¿de quién es +34 910…?" → gateway: del socket de ClinicaNorte
 4  worker.session         → AgentSession con voice/llm/knowledge de la clase; el prefijo se cachea
 5  onCall()               → this.last(contact): ¿quedó a mitad de algo hace < 30 min? restore(). Si no, byPhone()
 6  render(state)          → la view, con este estado, al final del prompt; tools visibles = las que su `when` permite
 7  el caller habla        → STT → LLM → llama findPatient → bridge → WS → TU método corre en TU proceso
 8  this.patient = …       → state.changed (seq) → re-render → la consola lo muestra → `when` recalcula las tools
 9  book (confirm:)        → el worker lee la frase, espera el sí, acuña el token, recién ahí corre tu método
10  cuelga                 → session.end · grabación · remember() extrae hechos · ring 4 juzga al colgar: call.score sella el log

El runtime es tu Postgres: no lo escribís, te conectás. Lo alquilás (voice.pinecall.io) o lo corrés (docker compose up). Tu app es un proceso Node que corre donde quieras.

04El runtime (Python)

Una distribución, dos procesos, sobre livekit-server + livekit-sip. Convenciones 2026: src/ layout, pyproject.toml único, uv + hatchling, ruff + pyright estricto + mypy estricto, py.typed, guion bajo = privado. Reglas que convo probó: ningún archivo sobre 400 líneas, docstring de una línea con el porqué en docs/decisions/, providers/ son los únicos que nombran un vendor, el worker nunca importa el gateway.

quénombrepor qué
PyPIpinecalluna distribución; lo pesado va en extras: pip install pinecall[runtime]
importpinecallpinecall.protocol · pinecall.gateway · pinecall.worker; un futuro SDK Python para apps es pinecall.sdk
CLIpinecall-runtimeno choca con el pinecall de Node en la misma máquina
runtime/
├── pyproject.toml · uv.lock · README.md · CHANGELOG.md · CONTRIBUTING.md · SECURITY.md
├── .env.example · Dockerfile                    una imagen, dos comandos: gateway | worker
├── scripts/                                     cuatro ejecutables sin argumentos
│   ├── bootstrap · format · lint · test
│   └── generate                                 ../protocol/schema → src/pinecall/protocol/*.py
│
├── src/pinecall/
│   ├── __init__.py · py.typed · _version.py
│   ├── _settings.py                             pydantic-settings, env only. Las 50 lecturas de keys viven acá
│   ├── _exceptions.py · _types.py
│   │
│   ├── protocol/                                MECÁNICO. Generado del JSON Schema compartido con TS. Nunca a mano
│   │   ├── envelope.py                          Entry {seq, ts, call, agent, type, ephemeral, data}
│   │   ├── events.py · commands.py · verbs.py   eventos · comandos · verbos · métricas por turno — generados del schema
│   │   └── codec.py                             snake_case ↔ modelos. El único lugar que toca nombres de clave
│   │
│   ├── log/                                     el ledger — el bus. Sin framework
│   │   ├── entry.py · log.py                    append(type, data) → Entry con el próximo seq · CallLog · AgentLog
│   │   ├── fanout.py · replay.py · filters.py   suscriptores acotados · gap + snapshot · types= durable=
│   │   ├── pii.py                               enmascarado por declaración Y por valor
│   │   └── store/                               Protocol · postgres.py · memory.py (tests)
│   │
│   ├── domain/                                  contratos. Sin IO
│   │   └── agent.py · call.py · tool.py · route.py · token.py
│   │
│   ├── auth/                                    keys.py · tokens.py (LiveKit JWT + metadata sellada) · grants.py
│   │
│   ├── gateway/                                 PROCESO 1 — el plano de control. FastAPI, un router por recurso
│   │   ├── app.py · deps.py
│   │   ├── apps.py · registry.py                WS del protocolo · qué socket atiende qué número
│   │   ├── calls.py · agents.py                 GET events (JSON | SSE) · WS attach · el agent log
│   │   ├── tokens.py · routes.py · rooms.py
│   │   ├── text/                                chat · whatsapp — el LLM corre acá, sin audio
│   │   ├── knowledge/                           ingest · chunk · embed · search (RRF) · sources
│   │   ├── memory/                              Memory Protocol · pgvector.py · graphiti.py (opcional) · extractor.py
│   │   ├── evals/                               runner · endpoints · goldens · replay · latency — POST /v1/evals/run, ring 3
│   │   ├── supervise.py
│   │   └── otel.py                              el log → spans GenAI (invoke_agent, execute_tool)
│   │
│   ├── judging/                                 ring 4: los jueces al colgar, techo de precio, call.score. Lo alcanzan gateway (texto) y worker (voz)
│   │
│   ├── worker/                                  PROCESO 2 — la flota. Un proceso por llamada
│   │   ├── main.py · entry.py · router.py · sip.py
│   │   ├── client.py                            HTTP al gateway. Sin DB, sin Redis
│   │   ├── session.py · state.py                AgentSession desde AgentConfig · render(state) → update_instructions/tools
│   │   ├── bridge/                              events.py · commands.py · tools.py
│   │   ├── confirm.py · guard.py                el sí → token de un uso → run
│   │   ├── clock.py · stt_gate.py · barge_in.py · recordings.py · transfer.py
│   │
│   ├── providers/                               los ÚNICOS que nombran un vendor
│   │   └── llm.py · stt.py · tts.py · turn.py · embed.py · prices.py
│   ├── lang/es.py
│   └── cli/                                     pinecall-runtime gateway | worker | sessions | routes | doctor
│
├── tests/
│   ├── conftest.py                              keys sentinela muertas en todo test unit: construir anda, llamar falla en segundos
│   ├── test_layout.py · test_isolation.py       400 líneas · sin framework en protocol/log/domain · worker ≠ gateway · vendors solo en providers
│   ├── protocol/                                goldens compartidos con TS — los mismos bytes
│   ├── log/ · gateway/ · worker/ · evals/
│
└── docs/decisions/                              el porqué. El código guarda el qué

La cerca

protocol/ lo escribe el generador desde ../protocol/schema. Todo lo demás lo escribe una persona. CONTRIBUTING.md lo dice en un párrafo y CI regenera y hace diff. Un refactor sabe qué puede reescribir a ciegas.

pyproject — las líneas que importan

[project]
name = "pinecall"            requires-python = ">=3.12"          license = "Apache-2.0"
dependencies = ["pydantic>=2.7,<3", "pydantic-settings", "fastapi", "uvicorn[standard]", "httpx", "asyncpg", "pgvector"]
[project.optional-dependencies]
runtime = ["livekit-agents[anthropic,openai,soniox,deepgram,elevenlabs,silero,turn-detector]>=1.8,<2", "anthropic<1"]
memory-graph = ["graphiti-core"]
[project.scripts]
pinecall-runtime = "pinecall.cli:main"
[tool.pytest.ini_options]
addopts = "--tb=short -n auto"   xfail_strict = true   filterwarnings = ["error"]   markers = ["unit","needs_llm","voice","evals"]
[tool.pyright]  typeCheckingMode = "strict"       [tool.mypy]  strict = true       [tool.ruff]  line-length = 100

~10 000 líneas en total. Un tercio del sdk-server actual, ninguna sobre 400.

05El framework (Node)

Cuatro paquetes con una dependencia en cascada. El tenant instala uno: pinecall.

packages/
├── protocol/        @pinecall/protocol   tipos generados del schema · codec · el reducer del log (con su golden)
├── sdk/             @pinecall/sdk        cliente lean: conexión, registro, Call, tool proxy, observe(). Sin framework
├── pinecall/        pinecall             EL FRAMEWORK: Agent, @tool, estado reactivo, views, componentes, CLI
└── web/             @pinecall/web        navegador: VoiceSession sobre livekit-client · ChatSession · widget · useCall

El paquete pinecall, por dentro

packages/pinecall/src/
├── agent/
│   ├── agent.ts          la clase base: el Proxy que hace reactivos los campos, ciclo de vida, log()
│   ├── state.ts          diff de estado · state.changed → protocolo · restore/last · collapse
│   ├── tools.ts          registro: docstring → descripción · tipos → schema · when · confirm · preview · pii
│   └── decorators.ts     @tool
├── views/
│   ├── jsx-runtime.ts    ~60 líneas: JSX → texto plano. Sin React
│   ├── render.ts         layout(view(state)) · las tres regiones · colapso de fases
│   └── components/       Prompt · Knowledge · Rules · Protocols · Memory · Retrieved · Section · Rule · Example
├── channels/             phone.ts · whatsapp.ts · web.ts — campos declarativos → comandos del protocolo
├── runtime/              el puente Agent ↔ @pinecall/sdk: eventos → estado · tool calls → métodos · render → setPrompt/tools
├── testing/              loader de goldens · FakeCall · helpers para vitest
├── cli/                  new · g · run · chat · test · eval · sessions · knowledge
└── index.ts

El CLI del tenant

pinecall new clinica-norte          la app corriendo: una clase, una view, un knowledge, un golden
pinecall g tool free-slots          un método con su docstring y su test
pinecall g component choose         un componente de view
pinecall run                        la app + los tres canales: el proceso que se despliega
pinecall chat                       la app en ESTA terminal, y una llamada escrita contra ella
pinecall test                       ring 1 — goldens; --voice para ring 2
pinecall eval <call-id>             ring 3 — una llamada real, re-evaluada
pinecall sessions list | show | tail
pinecall knowledge push | query

06Un tenant, completo

Una carpeta. Lo que un desarrollador escribe: una clase, una view, un markdown, sus funciones de negocio y un JSON. Lo que no escribe: la máquina de estados, el gate de confirmación, la memoria, el retrieval, el log, la consola, el runner de evals, los tres transportes.

clinica-norte/
├── agent.ts                  la clase — el sistema entero, legible de arriba abajo
├── views/
│   ├── agent.tsx             UNA view: el prompt como función del estado
│   └── components/           cuando crece: Identify.tsx · Choose.tsx
├── knowledge/
│   ├── clinica.md            pequeño y fijo → cacheado delante de todo
│   └── docs/**/*.md          grande → indexado, recuperado por turno
├── lib/
│   └── agenda.ts             el sistema de la clínica — funciones sobre su API o su DB
├── test/
│   ├── choose.json           goldens desde un estado
│   └── agent.test.ts         la clase es una clase
├── .env                      PINECALL_API_KEY · PINECALL_URL
└── package.json              una dependencia: pinecall
// agent.ts
/**
 * Eres la recepción de Clínica Norte. Hablas de usted, con frases cortas.
 * Todo lo que dices se lee en voz alta: sin listas, sin markdown, los números como se dicen.
 * Nunca inventes una hora: las horas salen de la agenda, siempre.
 */
export default class ClinicaNorte extends Agent {
  // canales: un agente, tres puertas
  phone = "+34 910 000 000";  whatsapp = "clinica-norte";  web = true;
  voice = "carolina";  llm = "haiku";  language = "es";

  knowledge = "./knowledge/clinica.md";
  docs = "./knowledge/docs/**/*.md";
  memory = { remember: ["cómo prefiere que le llamen", "alergias", "su médico habitual"], forget: ["pagos"] };

  // el estado. Asignar re-renderiza, escribe state.changed en el log, actualiza la consola
  patient?: Patient;
  slots: Slot[] = [];
  slot?: Slot;
  booking?: Booking;

  // derivados: getters, como en React
  get identified() { return !!this.patient; }
  get done()       { return !!this.booking; }

  async onCall(call: Call) {
    const last = await this.last(call.contact);
    if (last?.unfinished && last.ago < "30m") return this.restore(last);   // quedó a mitad hace poco: retoma
    this.patient = await agenda.byPhone(call.from);
  }

  /** Busca al paciente por nombre y teléfono. Pide los dos antes de llamarla. */
  @tool({ when: s => !s.identified, pii: ["name", "phone"] })
  async findPatient(name: string, phone: string): Promise<Patient | NotFound> {
    return (this.patient = await agenda.find(name, phone)) ?? notFound("Pide el nombre completo otra vez.");
  }

  /** Horas libres de un día. Un día que nombre el paciente se consulta SIEMPRE, aunque su ficha ya tenga cita ese día. */
  @tool({ when: s => s.identified && !s.done, preview: 2 })      // el modelo ve 2; this.slots guarda todos
  async freeSlots(day: string): Promise<Slot[]> {
    return (this.slots = await agenda.free(day));
  }

  /** Reserva la hora que el paciente eligió. */
  @tool({ when: s => s.slots.length > 0 && !s.done,
          confirm: "Le reservo el {{slot.when}} con {{slot.doctor}}. ¿Lo confirmo?" })
  async book(slot: Slot): Promise<Booking> {
    this.slot = slot;
    this.booking = await agenda.book(this.patient!, slot);
    this.collapse(`Reservado ${slot.when} con ${slot.doctor}, confirmado por el paciente.`);
    this.log("appointment.booked", this.booking);
    return this.booking;
  }

  /** Pasa la llamada a una persona. Solo si el paciente lo pide o no puedes ayudarle. */
  @tool()
  transfer(call: Call) { return call.forward("+34 910 000 099", "Le paso con recepción."); }

  onMemory(ops: MemoryOp[], call: Call) { crm.apply(call.contact, ops); }
}
// views/agent.tsx — el prompt es una función del estado
export default ({ patient, slots, booking, identified, done, memory, resumed, call }) => (
  <>
    <Memory kinds={["preference", "health"]} />
    <Retrieved minScore={0.4} />

    {resumed && <p>Se cortó su llamada anterior. Retoma desde donde quedó sin volver a preguntar.</p>}

    {!identified && <p>Saluda y pide nombre y teléfono. Nada más hasta identificar al paciente.</p>}

    {identified && !done && <>
      <p>{patient.name} tiene cita el {patient.cita} con {patient.doctor}.</p>
      {memory.has("médico habitual") && <p>Ofrece primero las horas de su médico habitual.</p>}
      {slots.length === 0 && <p>Pregunta para qué día quiere cambiarla.</p>}
      {slots.length > 0 && (call.channel === "phone"
        ? <p>Ofrece como máximo dos de estas horas y pregunta cuál prefiere.</p>
        : <p>Muestra hasta cinco horas, una por línea.</p>)}
    </>}

    {done && <p>Confirma que le llega un SMS con la cita del {booking.when}. Despídete y cuelga.</p>}
  </>
);
// test/choose.json — goldens desde un estado. La política de consentimiento sale de confirm:
[
  { "state": { "patient": { "name": "Ana García", "cita": "jueves 10:00" } },
    "memory": ["prefiere a la Dra. Vidal"],
    "input": "¿tiene algo el martes?",
    "expect": { "tools": ["freeSlots"], "says": ["Vidal"] } },

  { "state": { "patient": { "name": "Ana García" }, "slots": [{ "when": "martes 16:00", "doctor": "Dra. Vidal" }] },
    "input": "la de las cuatro me viene bien",
    "expect": { "not": ["book"] } },                                // aceptar una hora no es un sí

  { "state": { "patient": { "name": "Ana García" } },
    "input": "¿cuánto cuesta una primera consulta?",
    "expect": { "tools": [], "grounded": "knowledge" } }
]

07El prompt como función del estado

Tres reglas. Los campos son el estado: asignar dispara el re-render y deja state.changed en el log con seq y autor. Las tools son lo único que lo cambia. El prompt es render(state), en el orden que NOOA demostró que cuida la caché:

[ estático · cacheado ]   docstring de la clase → knowledge/clinica.md → <Rules> → <Protocols> → doc de las tools visibles
[ historia · append   ]   ▸ (colapsado) "Paciente identificado: Ana García, cita actual jueves 10:00."
                          agent:  ¿Para qué día quiere cambiarla?
                          caller: el martes por la tarde
                          ⚙ freeSlots("martes") → Slot[] len=4, [:2] = [16:00 Dra. Vidal, 17:30 Dr. Ferrer]
[ dinámico · al final ]   <Memory> prefiere a la Dra. Vidal · alergia al látex
                          <Retrieved> —
                          la view, renderizada con este estado

Sin stages

Un stage era un if con nombre. La view lo expresa con condicionales; la visibilidad de una tool con when. Si un tenant quiere etiquetas para leer la consola, agrega stage = "choose" como un campo más: es estado.

Volver atrás es gratis

“En realidad quiero otro día” → una tool pone this.slots = [] y la view vuelve a preguntar. Con stages eso era una flecha más en el diagrama.

Retomar es trivial

this.last(contact) es un SELECT sobre el log; restore() vuelve a poner los campos y la view se renderiza sola en el punto correcto.

Previews acotadas

preview: 2: el modelo ve dos slots y len=4; this.slots guarda los cuatro. Un volcado grande nunca entra al contexto — la idea de NOOA de pasar por referencia.

Componentes, no partials

<Memory>, <Retrieved>, <Caller of={patient}/> son funciones con props y render props. Tres niveles: default · props · {facts => …}.

Colapsar la vista

this.collapse("…") pliega la fase cerrada a una línea en lo que ve el modelo. El ledger queda entero. Es events.collapse de NOOA.

08Memoria y retrieval

Investigado a septiembre de 2026 con cuatro agentes y fuentes verificadas. La conclusión es la misma en las dos capas: nada propietario, pero tampoco una librería a ciegas.

Memoria — interfaz propia, default en pgvector, Graphiti opcional

Ninguna librería publica retrieval por debajo de 150 ms de forma independiente. Mem0 OSS v2 quitó la consolidación (los hechos se acumulan y “retrieval decide”) y mete spaCy en inglés. Graphiti es el mejor cerebro — bi-temporal, invalida contradicciones — pero exige Neo4j o FalkorDB y es solo Python. Letta es un runtime, LangMem está muerto desde octubre de 2025.

class Memory(Protocol):
    async def recall(self, contact, query, k=6, as_of=None) -> list[Fact]    # ≤ 150 ms, sin LLM, en el turno
    async def remember(self, contact, turns, channel, at) -> list[Op]        # fuera de banda, al colgar: ADD / UPDATE / INVALIDATE
    async def forget(self, contact) -> None                                   # derecho al olvido
    async def history(self, contact) -> list[Fact]
adapterqué escuándo
PgvectorMemorytabla contact_memories: hecho, categoría, embedding, tsvector('spanish'), valid_from, invalidated_at, supersedes. RRF en SQL filtrado por contacto; ranking relevancia · recencia · importancia (el ACT-R de NOOA). Una llamada LLM al colgar consolida — el algoritmo de dos pasos que Mem0 abandonó por costo por turno; por llamada el costo es nada y la calidad para un CRM es mejor. ~500 líneas.default
GraphitiMemoryrazonamiento temporal (“¿qué plan tenía en marzo?”). Requiere Neo4j/FalkorDB.opcional, segunda release

Dos memorias distintas y las dos hacen falta: restore(last) es episódica de corto plazo — el estado de la última llamada, se restaura entero y caduca; <Memory/> son hechos consolidados para siempre. La identidad del contacto: el número en teléfono y WhatsApp; el contactId sellado en el token en web. Sin identidad no se recuerda.

Retrieval — híbrido o nada

De las cinco arquitecturas de moda, una sola cabe en un turno de voz. CRAG y agentic cuestan una llamada LLM más (300–800 ms); GraphRAG son 15–25 segundos por query en LightRAG. Quedan para background o pre-call.

capadefault (CPU)perfil gpu
store + léxicoPostgres 17 + pgvector 0.8 (HNSW, halfvec) + pg_textsearch (BM25 real, spanish)igual
embeddingbge-m3 (MIT) vía TEI cpu · lite: multilingual-e5-smallTEI cuda, 5–15 ms
fusiónRRF k=60, 30 candidatos por rama, top-8 al modelo+ rescoring ColBERT de bge-m3
rerankerninguno en el turnobge-reranker-v2-m3 (Apache) sobre top-20, entra al turno
grader (CRAG)Qwen3 chico con vLLM: “no recuperé nada” > “recuperé basura”
chunkingH2/H3, tope 300–400 tokens, ruta de headings prefijada al textoigual
ingesta background (opt-in)contextual chunks con LLM · late chunking con bge-m3igual, local
presupuesto por turno~70–120 ms (bge-m3) · 25–50 (lite)~30–60 con reranker

No se embebe en cada turno

Solo las views con <Retrieved/> disparan retrieval. BM25 va primero y es gratis: un turno corto o conversacional con score léxico bajo no se embebe. Cuando se embebe, se hace sobre el transcript parcial (eager.turn) mientras el caller todavía habla — listo antes de que termine la frase. Y solo se inyecta lo que supera minScore: lo caro no es el embedding, son los tokens que el modelo lee. Quien prefiera que el modelo decida: docs = { mode: "tool" } expone search(query).

docker compose up arranca en CPU y entra en el presupuesto. docker compose --profile gpu up suma TEI cuda, el reranker y vLLM. pinecall-runtime doctor --bench mide en tu hardware antes de prometer nada. Una T4 spot en GCP ronda los 90 USD/mes; una L4 on-demand, 500–580. Verificá en la calculadora.

09Evals: los cuatro anillos

El juicio corre en el runtime — tiene el juez, las keys, el log y el store. El CLI construye el caso porque es el que sabe qué estado tiene la clase. Un mismo vocabulario en los cuatro anillos; cambia de dónde viene la conversación, nunca cómo se juzga.

anillode dónde viene la conversaciónquién lo correcosto
1 · goldensun estado + una línea del caller, por chatpinecall test, CI~0.01 USD la suite; 0 jueces en el camino feliz
2 · vozuna persona sintética llama de verdad — STT, TTS, interrupcionespinecall test --voice, el nightly en la boxel minuto de voz
3 · una llamada realel log de una sesión, re-evaluado con las mismas métricaspinecall eval <id>0–1 juez
4 · cada llamadaal colgar, sin que nadie lo pida: call.score es la entrada terminal del logel worker (voz) o el gateway (texto), en proceso; runtime/judging/≤ 0.2 cent; 0 en el camino feliz — un PolicyJudge no pregunta
pinecall test (Node)                       gateway (Python)                          tu app
  lee test/*.json
  POST /v1/evals/run ──────────────▶  por cada golden:
                                       abre sesión chat ──────────────────────▶ session.configure {state}
                                                                                 → la clase pone los campos, renderiza la view
                                       manda el input ───────────────────────▶ el modelo responde, llama tools
                                       lee el log: tools · estado · texto
                                       decide por CÓDIGO lo decidible: tools · estado · hechos con fuente · registro · consentimiento
                                       1 pregunta binaria al juez, con la evidencia adjunta, solo si sobra algo
  ◀────────────────────────────────  JSON + el run en el store → la consola lo muestra con deltas

Contra el mercado

Coval cobra 100 / 500 / desde 4 500 USD al mes; Cekura 0.25 USD por minuto de test; Hamming no publica precio. Todos cerrados, todos miden después.

capacidadCoval · Hamming · CekuraPipecat evals · LiveKit testingPinecall v2
simulación de personas, texto y vozsí, pagoYAML · pytest
política determinista sobre acciones irreversiblesnono — aserciones a manoderivada de confirm:. Nadie más lo tiene
goldens por fase desde un estadonono{ state, input, expect }
cada llamada real puntuada al colgarmonitoreo pagonoring 4
re-evaluar una llamada realreplay (Roark)noring 3
matriz de modelos sobre los mismos goldensnono
ledger auditable con seq, replay, cursorspansnosí, y exporta a OTel GenAI
código abiertonoApache 2.0

Promoción y deriva: pinecall runs promote <call> convierte una llamada que falló ring 4 en golden candidato con promoted_from; pinecall runs drift es el delta de held-rate por juez entre dos ventanas, y el nightly cae con él. Latencias e interrupciones las lleva simulate al reporte, leídas de las métricas del log. Aviso honesto: Pipecat cerró parte de la ventana en cinco meses. Lo defendible es la política determinista, el ledger y el estado — no el juez.

10La consola

Borrada la mañana del 2026-09-08, de vuelta esa misma noche. Lo que se borró fue el webui del gateway, y eso no vuelve: el gateway es una API y no sirve ninguna páginaruntime/tests/gateway/test_no_page.py lo fija. La consola es console/, y la sirve pinecall ui [agente] en 127.0.0.1, desde el CLI del tenant, mientras dura el comando: bajo un path aleatorio de dieciséis bytes, firmando cada request al gateway con la key de la org, así que la key nunca llega al navegador. talk dejó de ser un verbo: es la primera pantalla de ui. El porqué está en docs/decisions/console.md. En todo este documento «la consola» significa eso — un lector del log servido por el CLI, nunca un proceso del gateway.

Una pantalla por pregunta que te hacés durante una llamada. No tiene estado propio: todo sale del log por SSE. La central:

┌ clinica-norte ─────────────────────────────────────────────────────── ● live · +34 910 000 000 ┐
│ CALLS                  │ CA_8f4a  ☎ +34 600 123 456   02:14                                      │
│ ● CA_8f4a  ☎ 02:14     │                                                                         │
│ ○ CA_c21e  ◉ 00:31     │  caller  Hola, quería cambiar mi cita.                            0:03  │
│                        │  agent   Buenos días, ¿me dice su nombre y teléfono?               0:04  │
│ RECENT                 │  caller  Ana García, 600 123 456                                   0:11  │
│   CA_11ab  ☎ booked    │  ⚙ findPatient {name, phone} → Patient              210 ms  · seq 14   │
│   CA_09f0  ✆ hangup    │  ◆ patient ← Ana García, cita jueves 10:00                    · seq 15   │
│                        │  agent   Tiene cita el jueves a las diez. ¿Para qué día la quiere?       │
│ STATE                  │  caller  ¿el martes por la tarde?                                        │
│  patient  Ana García   │  ⚙ freeSlots {day: "martes"} → Slot[] len=4               180 ms · 22   │
│  slots    16:00 · 17:30│  ◆ slots ← 4                                                      · 23   │
│  slot     —            │  agent   Tengo martes a las cuatro o a las cinco y media…                │
│  booking  —            │  caller  la de las cuatro                                                │
│                        │  ⏳ confirm  "Le reservo el martes 16:00 con Dra. Vidal. ¿Lo confirmo?"  │
│ TURN  e2e 1.9s ttft 1.2│                                                                          │
│                        │  [ whisper ]  [ take over ]  [ transfer ]  [ end ]                        │
└────────────────────────┴─────────────────────────────────────────────────────────────────────────┘

Calls · Live

Transcript, tools, y el estado como línea de tiempo: patient ← findPatient (seq 14). Muestra qué cambió y quién.

Sessions

Una llamada terminada, seq a seq, latencias por turno, el confirm.granted dibujado antes del book.

Evals

Los runs, cada métrica con su delta contra el anterior, los goldens legibles como cards, la matriz de modelos.

Pipeline

HEARS / DECIDES / SPEAKS: STT, LLM, TTS con sus medianas medidas, cambiables sin deploy.

Supervisor

Escuchar, susurrar, tomar la línea, transferir. Cada verbo queda en el log del caller.

Board

Las transacciones: qué se reservó, con qué sí. Lee solo el log; no hay tabla nueva.

11Open source que tomamos

capatomamoslicenciapor qué
SFU, SIP, tokens, grabación, dispatch, SDKs móvileslivekit-server · livekit-sipApache 2.0cubre WebRTC en todas las plataformas y cualquier trunk SIP. Cero líneas nuestras
sesión de vozlivekit-agents 1.8 + plugins anthropic · openai · soniox · deepgram · elevenlabs · silero · turn-detectorApache 2.0el pipeline, los turnos, ~90 plugins. Nuestras invariantes de 1.7.1 se re-verifican
estadoPostgres 17 · pgvector 0.8 · pg_textsearchPostgreSQLun contenedor: tenants, log, KB, memoria; BM25 real en español; RRF en una query
embeddingsbge-m3 · multilingual-e5-small · TEI · fastembedMIT · MIT · Apache · Apachemultilingüe permisivo, CPU o GPU con la misma imagen
reranker (gpu)bge-reranker-v2-m3 · FlashRankApache · Apacheentra al turno solo con GPU
chunkingchonkie (recipe markdown) o propioMITheading-aware; liviano
evalslivekit.agents.evals (agents 1.8) · ragasApache · ApacheJudge · JudgeGroup · session.run/expect/judge; nuestro PolicyJudge encima. DeepEval descartado el 2026-09-08: dos abstracciones de juez en un repo es una de más. retrieval en CI
observabilidadOTel GenAI semconv · LangfuseApache · MITel log exporta; Langfuse ya integra LiveKit
memoria (opcional)GraphitiApache 2.0bi-temporal, invalida contradicciones; requiere Neo4j
Pythonuv · hatchling · ruff · pyright · mypy · pytest · pydantic 2 · FastAPIhigiene 2026
NodeTypeScript · zod · vitest · tsup · livekit-clientlo que Pinecall ya usa

Lo que no tomamos, y por qué

descartadorazón
PipecatLiveKit cubre transporte y sesión; lo único distinto es su runner YAML de evals, y ahí convo ya va por encima
Mem0 OSSv2 quitó la consolidación y el grafo (Platform-only); spaCy en inglés en el pipeline
Letta · LangMem · Memary · A-MEMruntime propio / pre-1.0 sin releases desde 2025 / investigación
GraphRAG · LightRAG por turno15–25 s por query. Solo pre-call
jina-embeddings-v3 · jina-rerankerCC-BY-NC — no comercial
ParadeDB · ElasticsearchAGPL
los patrones de SDK cliente de anthropic-sdk-pythonson para un cliente HTTP; el runtime es un servidor. Tomamos su higiene, no sus patrones

12The art of Pinecall

Las convenciones. Las hacen cumplir los tests, no la disciplina.

400 líneas

Ningún archivo trackeado sobre 400. agent_activity.py de LiveKit tiene 4 874; session/manager.py del Pinecall actual, 2 242. Ese número es el síntoma.

Una línea de docstring

El código guarda el qué. El porqué, la medición y la historia van a docs/decisions/<módulo>.md. Dos excepciones: el docstring de una tool (el modelo lo lee) y el de una ruta (el cliente lo lee).

La cerca

protocol/ es generado. Todo lo demás lo escribe una persona. Un refactor sabe qué puede reescribir a ciegas.

Un vocabulario

Agent · state · tool · view · call · log · ring. Se fija el día uno y no se mueve.

Aislamiento

protocol/log/domain/lang no importan framework. El worker no importa el gateway. Solo providers/ nombra un vendor. El núcleo nunca importa un tenant.

Keys sentinela

Todo test unit corre con keys de provider muertas: construir funciona, una llamada real muere en segundos. El anillo unit es un gate — verde tres de tres o no significa nada.

Convención con salida

El default hace lo correcto sin configurar. Cada default tiene una puerta: props en el componente, mode: "tool" en docs, onTurn para tu propio LLM, el protocolo pelado en cualquier lenguaje.

Un comando por milestone

Y un ms-N.md que dice qué se quiso hacer, qué se hizo, qué se aprendió y qué sigue. Los goldens crecen desde el primero.

13Lo comercial

capagratisse cobra
framework, consola, evals, self-hostedApache 2.0 · docker compose up
runtime hosted voice.pinecall.iopor minuto de voz y por mensaje de texto; BYOK descuenta
consultoría“tu agente y su suite de evals en dos semanas”, sobre el framework abierto. Cada cliente es un ejemplo público más
verticalesclínica y tienda como ejemplosplantillas de contact center a medida
app hostingproducto posterior: Pinecall corre tu Node cuando no querés operar nada

El pitch al público no es el código: un agente, tres puertas — el widget, el número de WhatsApp, el teléfono — con memoria del cliente entre las tres. El pitch a quien construye es el repo y los reports: la venta es inbound.

14Milestones

Cada uno termina en un comando que corrés vos y un ms-N.md. Si uno no te convence, se para ahí.

msquécorrés
0 · esqueletomonorepo · protocol/ schema + generadores TS y Python · runtime con pyproject · compose (livekit, sip, redis, Postgres+pgvector+pg_textsearch, TEI) · test_layout · test_isolation · CIscripts/test · docker compose up · pinecall-runtime doctor
1 · el gateway hablalog/ sobre Postgres · apps.py + registry · sesión chat con LLM · calls.py SSE · @pinecall/sdk mínimopinecall chat contra un agente con dos tools
2 · la claseAgent con estado reactivo · @tool con docstring, when, preview, pii · views JSX + componentes · orden estático/historia/dinámico · confirm del lado del servidor · collapse · pinecall run terminalClínica Norte en texto, reserva con confirmación · --show-prompt
3 · voz localworker: session, bridge, tools proxy, render → update_instructions/tools · stt_gate, barge_in, clock, recordings, providers escritos de cero con lo que convo enseñó · re-verificar invariantes 1.8pinecall-runtime worker talk con micrófono
4 · teléfonoinfra/box escrito de cero con las lecciones de convo · trunk Twilio · routes · sip.py · transferllamás desde tu móvil · pinecall eval <id>
5 · consolashell + tokens · Calls/Live · Sessions · Pipeline. En ms-14 se le quitó al gateway (el webui) y se la devolvió al CLI: la sirve pinecall ui en 127.0.0.1 bajo un nonce, firmando con la key de la org (docs/decisions/console.md)pinecall ui · las puertas que lee: /v1/agents, /v1/calls, /v1/agents/{slug}/pipeline
6 · evalsel paquete de evals escrito de cero sobre livekit.agents.evals (DeepEval descartado) · POST /v1/evals/run · pinecall test · --voice · call.score al colgar (ring 4) · pinecall simulate con línea degradada · 10 goldens + nightly · pantalla Evals · promoción a golden · derivapinecall test con 10 goldens, dos modelos
7 · web@pinecall/web sobre livekit-client · tokens · widget · useCallllamás desde el navegador y desde el móvil por 3G
8 · supervisor + WhatsAppverbos por WS attach · pantalla Supervisor · text/whatsapp · pausa humanaescuchás una llamada y susurrás
9 · memoria + retrievalMemory Protocol · PgvectorMemory · restore/last · knowledge/ con bge-m3 · <Memory> <Retrieved> con props y render props · perfil gpu · doctor --benchla clínica responde desde docs/ y recuerda tu alergia en la segunda llamada
10 · ejemplos + READMETienda Sur · goldens de las dos · docs revisados · README de 30 segundos · ARCHITECTURE · decisions/git clone && pnpm i && pinecall run en una máquina limpia
11 · hostedorgs + keys · deploy a la box · TLS · un tenant externoun tenant externo se conecta con su key
12 · OTelel log → spans GenAI · Langfuseuna llamada aparece en Langfuse

Ritmo estimado: una noche por milestone para 0–3 (el material existe: protocolo, convo, drift); uno o dos días los demás.

15Decisiones a confirmar

  1. Repo: ~/pinecall/v2/, github.com/pinecall/pinecall, Apache 2.0.
  2. Nombres: npm pinecall + @pinecall/*; PyPI pinecall, import pinecall, CLI pinecall-runtime.
  3. LiveKit es el motor. Pipecat no entra.
  4. Postgres es el único servicio con estado, desde ms-0.
  5. Estado, no stages. El prompt es render(state); la view al final.
  6. Memoria: interfaz propia, PgvectorMemory default, Graphiti opcional después.
  7. Retrieval: híbrido; CPU por default, GPU como perfil.
  8. El juicio corre en el runtime; el CLI construye el caso.
  9. Las versiones y los tags los elegís vos, cuando toque.

16El CLI, verbo por verbo

Dos CLIs, dos usuarios. pinecall (Node) para quien construye el agente; pinecall-runtime (Python) para quien opera el runtime. Misma gramática: grupo verbo [objeto] [flags]; list · show · add · rm siempre igual; --json en todo; el agente sale de la carpeta donde estás salvo --agent. Ningún verbo del que construye requiere Python: el juicio, la memoria y el retrieval viven en el runtime y el CLI les habla por HTTP.

Crear

pinecall new <nombre> [--template clinic|shop|blank]     la app corriendo: agent.ts · views/ · knowledge/ · lib/ · test/ · .env
pinecall g tool <nombre>              un método @tool con docstring, tipos, when, y su test
pinecall g component <nombre>         views/components/<Nombre>.tsx
pinecall g golden <nombre>            test/<nombre>.json con un caso desde estado
pinecall g persona <nombre>           test/personas/<nombre>.ts — un caller sintético
pinecall g channel whatsapp|web       el campo en la clase + lo que falta en .env

Correr y hablar

pinecall run [agent.ts] [--ui] [--events] [--show-prompt]
    la app + los tres canales. El mismo proceso en dev y en prod: no abre ningún puerto ni sirve
    ninguna página. Al conectar imprime una línea:
        clinica-norte · connected to https://box.pinecall.io · tools 4 · doors phone +34 910 000 000, web
    no nombra ninguna página: el gateway es una API y no sirve ninguna (docs/decisions/console.md)
    --events es un pipe (una línea JSON por entrada) y --show-prompt una pregunta offline; ninguna
    de las dos es una manera de correr run. --ui dibuja la vista de la terminal y tampoco abre
    puerto; teclas: p pausa · c limpiar · e eventos · s prompt · q salir
pinecall chat [--state test/x.json] [--memory contact] [--show-prompt]
    texto desde la terminal. --state arranca en un estado; --memory carga los hechos de un contacto
pinecall ui [agente]                              la consola en 127.0.0.1, mientras dura el comando
    Talk (el micrófono del navegador entra a la sala del agente) · Calls (cada llamada mientras pasa,
    con listen-in y la grabación) · Sessions · Pipeline · Evals. Sin agente: la lista de los que tiene el gateway
    el micrófono es el del navegador, nunca el de este proceso: la página entra a la sala con un token, como el widget.
    La sirve el CLI bajo un path aleatorio y firma cada request con la key de la org, así que la key nunca llega
    al navegador. `talk` no es un verbo: es la primera pantalla de `ui`
pinecall call <número> [--persona x]              el agente te llama (outbound real); con --persona atiende una sintética
pinecall prompt [--state test/x.json] [--diff otro.json]
    el prompt exacto que vería el modelo en ese estado: las tres regiones, marcadas
pinecall state <call-id> [--set patient.name="Ana"] [--watch]     ver o forzar el estado de una llamada viva

Probar — los cuatro anillos

pinecall test [test/…] [--voice] [--model m…] [--grep x] [--watch] [--json] [--budget 2]
    ring 1 (texto) o ring 2 (--voice). Dos --model → la matriz. Exit 1 si falla. --watch re-corre al guardar
pinecall simulate --persona apurado [--goal "…"] [--listen] [--judge] [--record out.wav] [--turns 8]
    una persona llama de verdad. En vivo: transcript de los dos lados · eot · ttft · ttfb · e2e · interrupciones
    --judge: el call.score del corte — consent · grounded hoy; register · leakage cuando AgentConfig declare el registro y los strings ajenos
    --background-noise: un interferente mezclado; la latencia e2e del lado del caller va al reporte. --listen: el audio a tus parlantes
pinecall eval <call-id> [--voice] [--free]         ring 3: una llamada real, re-evaluada. --free: sin juez
pinecall runs list | show <id> | diff <a> <b> | promote <call-id> | drift [--window 7d --baseline 30d]
    historial con deltas; promote convierte una llamada real fallida en golden candidato (promoted_from)
    drift: held-rate por juez en dos ventanas y el delta; exit ≠ 0 bajo --threshold, imprime los seqs de las 3 últimas rotas
pinecall personas list | show <n> | try <n>          try: hablás con la persona por texto para calibrarla

Observar

pinecall sessions list [--live] [--since 24h] [--channel phone] [--failed]
pinecall sessions show <id> [--seq 20:40] [--types tool.*,state.*] [--raw]     seq a seq: turnos, tools, estado, confirm, latencias, costo
pinecall sessions tail [<id>] [--durable]         seguir una llamada en vivo — o la próxima que entre
pinecall sessions replay <id> [--speed 2]         la llamada al ritmo real: transcript + estado + tools
pinecall sessions recording <id> [-o out.ogg] [--play]
pinecall sessions show <id>                       …y al final el call.score: el veredicto de ring 4 como lo dejó el log; passed ausente = nadie juzgó, y not_judged dice por qué
pinecall sessions export <id> [--format json|md|srt]
pinecall observe [--agent a] [--types …] [--since <seq>]     el agent log, con cursor persistente
pinecall board [--days 7]                          las transacciones: qué se reservó, con qué sí, en qué llamada
pinecall costs [--since 7d] [--by agent|model|channel]

Supervisar

pinecall supervise [<call-id>]
    TUI: audio a tus parlantes · transcript en vivo · estado a la derecha
    w whisper "…"   s say "…"   t takeover / release   x transfer +34…   e end     — cada verbo queda en el log del caller
pinecall supervise whisper|say|takeover|release|transfer|end <call-id> [texto]     los mismos verbos, para scripts
pinecall pause <contact|call-id|--all> · pinecall resume <…>     humano al mando en WhatsApp/chat
pinecall send <call-id> "texto"                    contestar como humano mientras está pausado

Memoria

pinecall memory get <contact> [--as-of 2026-08-01]     los hechos vigentes, como los ve la view. --as-of: qué sabía ese día
pinecall memory history <contact>                      todos, incluidos los invalidados, con supersedes y desde/hasta
pinecall memory search "alergia" [--contact c] [-k 20]
pinecall memory recall <contact> "quería cambiar la hora" [--bench]     lo que recall() devolvería — y cuánto tardó
pinecall memory extract <call-id> [--dry-run]          re-correr remember(): qué ops produce, sin aplicarlas
pinecall memory forget <contact> [--yes]               el derecho al olvido
pinecall memory stats

Knowledge

pinecall knowledge push [knowledge/docs/**] [--kb id] [--reindex]     upsert por path; solo lo que cambió
pinecall knowledge ls · docs <kb> · show <kb> <doc> · rm <kb> <doc> · reindex <kb>
pinecall knowledge query "reset de contraseña" [--k 8] [--rerank] [--explain]     --explain: la rama léxica y la densa por separado
pinecall knowledge chunks <doc>                     cómo quedó partido: headings, tokens por chunk
pinecall knowledge bench [test/knowledge.json]      recall@5 y nDCG@10 sobre tu golden. Sin LLM. Corre en CI
pinecall knowledge tap <url> [kb] [--dry-run] · sync <kb>     el crawler y su re-tap incremental

Canales, acceso, cuenta

pinecall phones list · add <número> [--agent a] · rm · route <número> <agente>     un número es una ruta
pinecall whatsapp status · verify
pinecall tokens mint web|chat|observe|supervise [--metadata '{…}'] [--ttl 60]
pinecall agents list · inspect <a> · prompt <a>
pinecall login <gateway>                            la key se prueba en /v1/whoami y queda en ~/.pinecall/credentials (0600)
pinecall whoami                                     qué gateway, qué org, y de dónde salió la key de esta terminal
pinecall config [get|set] · doctor · logs [--follow] · version · upgrade · mcp install
pinecall deploy                                     después. Hoy: `node agent.ts` donde quieras

pinecall-runtime — el que opera

pinecall-runtime gateway [--host --port --reload] · worker dev|start|talk|download-files · migrate up|status [--schema]
pinecall-runtime doctor [--bench]        keys · livekit · sip · postgres · tei — y en ms: embedding, rerank, recall
pinecall-runtime chat --agent <slug> [--url] [--caller]     una llamada de texto desde la terminal del que opera
pinecall-runtime orgs list|add|rm|quota
pinecall-runtime orgs provider-key set|rm|list <org> <vendor>     la key propia de esa org, por stdin; sin fila, el env de la caja
pinecall-runtime keys issue|list|revoke                  issue la imprime una vez; list solo huellas, nunca la key
pinecall-runtime routes list|add <número> <agente> [--channel phone|whatsapp] [--org]|rm|seed
    una ruta es una puerta: el número de teléfono y el de WhatsApp entran por la misma tabla
    (el webhook de Meta es GET|POST /v1/whatsapp/webhook, firmado con el app secret)
pinecall-runtime sessions list|show|tail|eval|score|purge [--older-than 90d] · recordings ls|get|purge
pinecall-runtime evals nightly [--dry-run] [--only a] [--budget 2] [--deadline 900] · evals report <a> [--model m…]
pinecall-runtime scoring status|sweep [--window 24h]
pinecall-runtime knowledge stats|reindex|embed-model bge-m3|e5-small · memory stats|purge|export
pinecall-runtime otel test · version

Cuatro flujos

# el loop de desarrollo
pinecall new clinica-norte && cd clinica-norte
pinecall run                        # el agente conectado, sin puerto y sin página
pinecall ui                         # la consola en 127.0.0.1, mientras dura el comando: Talk, Calls, Sessions
pinecall prompt --state test/choose.json
pinecall test --watch

# QA de una llamada simulada, con evaluación en vivo
pinecall simulate --persona apurado --goal "cambiar la cita al martes" --listen --judge
  ▸ caller  hola quería cambiar la cita        eot 0.31 · ttft 1.1 · e2e 1.9
  ▸ agent   buenos días, ¿me dice su nombre…
  ✓ register  ✓ grounded  · consent: no write yet
  ⚡ interrupción a los 0.8 s → bot.interrupted
  ✓ consent: book tras "sí, confirmo" (seq 29 → 34)      $0.003 · 47 s · out.wav

# algo salió mal esta mañana
pinecall sessions list --failed --since 24h
pinecall sessions replay CA_8f4a --speed 3
pinecall eval CA_8f4a
pinecall runs promote CA_8f4a

# "el agente no me recuerda"
pinecall memory get +34600123456
pinecall memory recall +34600123456 "quiero la misma doctora de siempre" --bench
pinecall memory extract CA_8f4a --dry-run

17El protocolo

El Rack de la voz: la costura entre quien hace el tiempo real y la aplicación. Se escribe de cero, como JSON Schema en protocol/schema/, y genera los dos lados. Nada se copia de Pinecall v1: se recuerda su vocabulario y un nombre sobrevive solo si sigue siendo el mejor. Un solo vocabulario: el envelope del log es el evento (en v1 los eventos in-process y los type del call log eran dos). snake_case en el cable, camelCase en el SDK, y el codec generado es el único lugar que toca nombres de clave.

Cada turno lleva todas las métricas. Lo que livekit-agents 1.8 mide para un turno viaja entero y con sus propios nombres de campo: en turn.user (transcription_delay, end_of_turn_delay, stopped_speaking_at) y turn.agent (llm_node_ttft, tts_node_ttfb, playback_latency, e2e_latency); y un evento metrics.<bloque> por cada bloque tipado — llm (ttft, duration, prompt/completion/cached/cache-creation tokens, tokens_per_second, cancelled), stt, tts (ttfb, audio_duration, characters_count), vad, eou, eot, interruption, realtime — con todos sus campos, unidos al turno por speech_id. call.summary lleva las filas de usage por modelo y el costo. Una sesión de texto mide ella misma lo que puede de los mismos campos. Un test lee la lista de campos de la librería y la contrasta con el schema: nada se resume, nada se renombra.

// el envelope — un solo vocabulario, un solo seq
{ "seq": 29, "ts": 1786537584.38, "call": "CA_8f4a", "agent": "clinica-norte",
  "type": "confirm.granted", "ephemeral": false,
  "data": { "tool": "book", "audience": "sha256:…", "ttl_s": 120 } }
grupoeventos (servidor → app)
ciclo de llamadacall.ringing · started · ended · summary · dialing · error · forwarded · routed
habla del callerspeech.started · speech.ended · user.speaking · user.message
turnoeager.turn · turn.pause · turn.end · turn.resumed · turn.continued
habla del agentebot.speaking · bot.word · bot.finished · bot.interrupted · barge_in
tools y estadotool.call · tool.result · state.changed · confirm.request · confirm.granted · confirm.declined
memoria y retrievalmemory.ops · docs.sources
supervisiónsupervisor.said · supervisor.whispered · handoff.requested · handoff.active · handoff.released
control (log del agente)line.created · line.error · phone.added · phone.removed · config.updated · registered · error · pong
marcadoreslog.gap · log.caught_up · custom (lo que this.log() escribe)
grupocomandos (app → servidor)
registroregister · agent.configure · channel.add · channel.remove · session.configure {state}
hablarbot.say · bot.reply · bot.reply_stream · bot.cancel · bot.clear
tools y estadotool.result · prompt.set · tools.set · state.set · call.log
llamadacall.hangup · call.dial · call.forward · call.dtmf · call.hold · call.unhold · call.mute · call.unmute · call.route
humanosession.pause · session.resume · session.send
infraping · update_config · line.create · line.destroy
tokenpuede
talk (web · chat)conectarse a UN agente, una vez, 60 s; metadata sellada por el servidor del tenant, inforjable por el navegador
observeleer el log de los agentes de su set. Nada más
superviseleer y mandar los verbos por WS /v1/attach
participateobservar su propia llamada

Tres endpoints de lectura, y dos son la misma URL: GET /v1/calls/{id}/events?after= (JSON, o SSE con Accept: text/event-stream) · GET /v1/agents/{slug}/calls?after= · WS /v1/attach para los verbos. Reconectar es la misma URL con un after más fresco; Last-Event-ID es un alias. El cursor es el protocolo entero. El spec completo, con un JSON Schema por tipo, vive en protocol/schema/ y genera los dos lados.

18Cómo se trabaja el repo

Dos roles. El orquestador (Fable, en sesión) planifica, asigna, despacha, revisa y mergea. Los workers son sub-agentes en worktrees propios. Nada se despacha hasta que Bernardo dice go para ese milestone.

Model routing

Cada card lleva model:fable (juicio: seams, el bridge, confirm, los DAGs, la vista en vivo) o model:opus (mecánico: portes de convo, tests, docs, verbos del CLI, generadores). Se despacha con el modelo que dice la etiqueta, todos en un mensaje.

Seams primero, en serie

La primera card de cada milestone aterriza sola la superficie compartida: tipos, protocolos, registros, módulos vacíos. Las demás son after de ella. Nadie ramifica desde un punto donde el seam no existe.

Propiedad por path

El files de una card es lo ÚNICO que puede editar. Una necesidad afuera es un comentario en la card dueña, nunca un edit. Registros e índices se declaran union_files; nada más se mergea por unión.

Una card de integración cierra

Regenera protocol/, corre toda la suite, escribe ms-N.md (qué quisimos · qué hicimos · qué aprendimos · decisiones · dónde estamos · qué sigue) y dice el comando que corre el humano.

El gate humano

Un milestone mergea solo después de que Bernardo corrió su comando y dijo que sí. Las cards del hot path (worker, confirm, protocolo, log) son review=true y las revisa el orquestador en sesión.

Presupuesto

Haiku en todo test que llame a un modelo; una suite corre a lo sumo dos veces por card. Cada card cierra con los tests que corrió y un nvim -p para leer el cambio.

19Abierto y privado: hosting, auth, la API de operador

La decisión del open-core en una línea: todo lo que hace falta para self-hostear va en el runtime, abierto; todo lo que hace falta para cobrar va en una capa privada que habla con el runtime por una API de operador documentada. Sin fork, sin rama privada. La misma imagen corre en nuestra box y en la del cliente. Es el modelo de Supabase, PostHog y Sentry — y es la línea que v1 ya tenía por accidente entre sdk-server y playground; v2 la hace explícita.

 ┌──────────────────────────── ABIERTO · pinecall/pinecall ───────────────────────────────┐
 │  runtime                                                                               │
 │   orgs · api_keys (hasheadas, con scopes) · quotas (el MECANISMO) · usage (los HECHOS)  │
 │   provider keys por org (tabla; default = env) · routes · el log · todo lo demás         │
 │   GET /.well-known/pinecall     descubrimiento: version · ws · sse · region · features   │
 │   /v1/ops/*                     la API de operador — auth por una ops key del env        │
 └───────────────────────────────────────────▲────────────────────────────────────────────┘
                                             │ pública, documentada: cualquiera puede construir su control plane
 ┌───────────────────────────────────────────┴────────────────────────────────────────────┐
 │  PRIVADO · pinecall/cloud   (la evolución de playground + platform)                     │
 │   signup · planes · rates · créditos · Stripe                                           │
 │   vault de provider keys (managed vs BYOK) → las empuja al runtime por org              │
 │   provisioning de números (Twilio, trunks) → escribe routes en el runtime               │
 │   flota: qué runtime atiende a qué org, regiones, boxes · dashboard platform.pinecall.io │
 └────────────────────────────────────────────────────────────────────────────────────────┘

La API de operador

endpointqué hace
POST · GET · DELETE /v1/ops/orgsorganizaciones
POST · DELETE /v1/ops/orgs/{id}/keysuna key se devuelve una sola vez; se guarda hash + prefijo + scopes agent · observe · evals · admin
PUT /v1/ops/orgs/{id}/quotasminutos, mensajes, agentes, llamadas concurrentes. El runtime rechaza al conectar y emite credits.exhausted
PUT · DELETE /v1/ops/orgs/{id}/provider-keys/{vendor}la key de ElevenLabs/Soniox/… de esa org; sin fila, el runtime usa el env
POST · DELETE /v1/ops/orgs/{id}/routesnúmero → agente
GET /v1/ops/usage?after=<seq>minutos, mensajes, tokens, llamadas al juez — con seq, JSON o SSE. El mismo cursor del log

Un self-hoster corre pinecall-runtime migrate, obtiene una org default con su key impresa, y maneja lo suyo con pinecall-runtime orgs …. Nosotros corremos exactamente lo mismo; quien llama a la API de operador es el cloud. El runtime nunca sabe cuánto cobramos: emite hechos de uso y el cloud los tarifa. prices.py sigue poniendo el costo de proveedor en euros en el log, informativo.

Cómo se conecta y autentica el CLI

pinecall login                                        hosted: pegás la key (después, device flow en el navegador)
pinecall login --url https://voice.acme.internal --key pk_live_…      self-hosted
pinecall login --profile acme …                       varios perfiles; PINECALL_PROFILE o --profile elige
pinecall whoami                                       org · pk_live_ab12… · scopes · runtime · versión · features

Lo que toca dinero, en cada modo

self-hostedhosted
provider keysenv del boxmanaged (créditos) o BYOK: pinecall keys add elevenlabs sk_… → vault del cloud → runtime por org
númerosel operador configura su trunk; phones add escribe la rutael cloud provisiona en Twilio y escribe la ruta; phones add verifica que el número es de la org
límitesquotas que fija el operadorel cloud fija quotas desde el saldo
usousage local, para tu contabilidadel cloud lee /v1/ops/usage con cursor
la app del tenantsu infrasu infra hoy; el app hosting — el cloud corre su Node — es el producto de después

Si orgs y keys fueran privados, "self-hosted" sería mentira y el claim de HN muere. Si el billing fuera abierto, regalamos el modelo y cargamos al self-hoster con Stripe. La línea correcta es la que ya existía entre sdk-server y playground: v2 la documenta y el playground se convierte en el cloud. Dos cards más en ms-11 (la API de operador y el login con perfiles); pinecall/cloud es otro board, privado, y arranca después.