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.
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.
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.
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.
Lo que aprendimos construyendo Pinecall dos años y convo en cuatro días. Cada regla tiene una medición detrás.
sha(tool + args) con TTL. La plataforma rechaza lo irreversible sin él. El modelo puede prometer; solo la plataforma ejecuta.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.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
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.
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é | nombre | por qué |
|---|---|---|
| PyPI | pinecall | una distribución; lo pesado va en extras: pip install pinecall[runtime] |
| import | pinecall | pinecall.protocol · pinecall.gateway · pinecall.worker; un futuro SDK Python para apps es pinecall.sdk |
| CLI | pinecall-runtime | no 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é
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.
[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.
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
pinecall, por dentropackages/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
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
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" } } ]
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
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.
“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.
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.
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.
<Memory>, <Retrieved>, <Caller of={patient}/> son funciones con props y render props. Tres niveles: default · props · {facts => …}.
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.
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.
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]
| adapter | qué es | cuándo |
|---|---|---|
PgvectorMemory | tabla 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 |
GraphitiMemory | razonamiento 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.
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.
| capa | default (CPU) | perfil gpu |
|---|---|---|
| store + léxico | Postgres 17 + pgvector 0.8 (HNSW, halfvec) + pg_textsearch (BM25 real, spanish) | igual |
| embedding | bge-m3 (MIT) vía TEI cpu · lite: multilingual-e5-small | TEI cuda, 5–15 ms |
| fusión | RRF k=60, 30 candidatos por rama, top-8 al modelo | + rescoring ColBERT de bge-m3 |
| reranker | ninguno en el turno | bge-reranker-v2-m3 (Apache) sobre top-20, entra al turno |
| grader (CRAG) | — | Qwen3 chico con vLLM: “no recuperé nada” > “recuperé basura” |
| chunking | H2/H3, tope 300–400 tokens, ruta de headings prefijada al texto | igual |
| ingesta background (opt-in) | contextual chunks con LLM · late chunking con bge-m3 | igual, local |
| presupuesto por turno | ~70–120 ms (bge-m3) · 25–50 (lite) | ~30–60 con reranker |
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.
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.
| anillo | de dónde viene la conversación | quién lo corre | costo |
|---|---|---|---|
| 1 · goldens | un estado + una línea del caller, por chat | pinecall test, CI | ~0.01 USD la suite; 0 jueces en el camino feliz |
| 2 · voz | una persona sintética llama de verdad — STT, TTS, interrupciones | pinecall test --voice, el nightly en la box | el minuto de voz |
| 3 · una llamada real | el log de una sesión, re-evaluado con las mismas métricas | pinecall eval <id> | 0–1 juez |
| 4 · cada llamada | al colgar, sin que nadie lo pida: call.score es la entrada terminal del log | el 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
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.
| capacidad | Coval · Hamming · Cekura | Pipecat evals · LiveKit testing | Pinecall v2 |
|---|---|---|---|
| simulación de personas, texto y voz | sí, pago | YAML · pytest | sí |
| política determinista sobre acciones irreversibles | no | no — aserciones a mano | derivada de confirm:. Nadie más lo tiene |
| goldens por fase desde un estado | no | no | { state, input, expect } |
| cada llamada real puntuada al colgar | monitoreo pago | no | ring 4 |
| re-evaluar una llamada real | replay (Roark) | no | ring 3 |
| matriz de modelos sobre los mismos goldens | no | no | sí |
ledger auditable con seq, replay, cursor | spans | no | sí, y exporta a OTel GenAI |
| código abierto | no | sí | Apache 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.
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ágina — runtime/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 ] │
└────────────────────────┴─────────────────────────────────────────────────────────────────────────┘
Transcript, tools, y el estado como línea de tiempo: patient ← findPatient (seq 14). Muestra qué cambió y quién.
Una llamada terminada, seq a seq, latencias por turno, el confirm.granted dibujado antes del book.
Los runs, cada métrica con su delta contra el anterior, los goldens legibles como cards, la matriz de modelos.
HEARS / DECIDES / SPEAKS: STT, LLM, TTS con sus medianas medidas, cambiables sin deploy.
Escuchar, susurrar, tomar la línea, transferir. Cada verbo queda en el log del caller.
Las transacciones: qué se reservó, con qué sí. Lee solo el log; no hay tabla nueva.
| capa | tomamos | licencia | por qué |
|---|---|---|---|
| SFU, SIP, tokens, grabación, dispatch, SDKs móviles | livekit-server · livekit-sip | Apache 2.0 | cubre WebRTC en todas las plataformas y cualquier trunk SIP. Cero líneas nuestras |
| sesión de voz | livekit-agents 1.8 + plugins anthropic · openai · soniox · deepgram · elevenlabs · silero · turn-detector | Apache 2.0 | el pipeline, los turnos, ~90 plugins. Nuestras invariantes de 1.7.1 se re-verifican |
| estado | Postgres 17 · pgvector 0.8 · pg_textsearch | PostgreSQL | un contenedor: tenants, log, KB, memoria; BM25 real en español; RRF en una query |
| embeddings | bge-m3 · multilingual-e5-small · TEI · fastembed | MIT · MIT · Apache · Apache | multilingüe permisivo, CPU o GPU con la misma imagen |
| reranker (gpu) | bge-reranker-v2-m3 · FlashRank | Apache · Apache | entra al turno solo con GPU |
| chunking | chonkie (recipe markdown) o propio | MIT | heading-aware; liviano |
| evals | livekit.agents.evals (agents 1.8) · ragas | Apache · Apache | Judge · 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 |
| observabilidad | OTel GenAI semconv · Langfuse | Apache · MIT | el log exporta; Langfuse ya integra LiveKit |
| memoria (opcional) | Graphiti | Apache 2.0 | bi-temporal, invalida contradicciones; requiere Neo4j |
| Python | uv · hatchling · ruff · pyright · mypy · pytest · pydantic 2 · FastAPI | — | higiene 2026 |
| Node | TypeScript · zod · vitest · tsup · livekit-client | — | lo que Pinecall ya usa |
| descartado | razón |
|---|---|
| Pipecat | LiveKit cubre transporte y sesión; lo único distinto es su runner YAML de evals, y ahí convo ya va por encima |
| Mem0 OSS | v2 quitó la consolidación y el grafo (Platform-only); spaCy en inglés en el pipeline |
| Letta · LangMem · Memary · A-MEM | runtime propio / pre-1.0 sin releases desde 2025 / investigación |
| GraphRAG · LightRAG por turno | 15–25 s por query. Solo pre-call |
| jina-embeddings-v3 · jina-reranker | CC-BY-NC — no comercial |
| ParadeDB · Elasticsearch | AGPL |
| los patrones de SDK cliente de anthropic-sdk-python | son para un cliente HTTP; el runtime es un servidor. Tomamos su higiene, no sus patrones |
Las convenciones. Las hacen cumplir los tests, no la disciplina.
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.
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).
protocol/ es generado. Todo lo demás lo escribe una persona. Un refactor sabe qué puede reescribir a ciegas.
Agent · state · tool · view · call · log · ring. Se fija el día uno y no se mueve.
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.
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.
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.
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.
| capa | gratis | se cobra |
|---|---|---|
| framework, consola, evals, self-hosted | Apache 2.0 · docker compose up | — |
runtime hosted voice.pinecall.io | — | por 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 |
| verticales | clínica y tienda como ejemplos | plantillas de contact center a medida |
| app hosting | — | producto 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.
Cada uno termina en un comando que corrés vos y un ms-N.md. Si uno no te convence, se para ahí.
| ms | qué | corrés |
|---|---|---|
| 0 · esqueleto | monorepo · protocol/ schema + generadores TS y Python · runtime con pyproject · compose (livekit, sip, redis, Postgres+pgvector+pg_textsearch, TEI) · test_layout · test_isolation · CI | scripts/test · docker compose up · pinecall-runtime doctor |
| 1 · el gateway habla | log/ sobre Postgres · apps.py + registry · sesión chat con LLM · calls.py SSE · @pinecall/sdk mínimo | pinecall chat contra un agente con dos tools |
| 2 · la clase | Agent 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 terminal | Clínica Norte en texto, reserva con confirmación · --show-prompt |
| 3 · voz local | worker: 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.8 | pinecall-runtime worker talk con micrófono |
| 4 · teléfono | infra/box escrito de cero con las lecciones de convo · trunk Twilio · routes · sip.py · transfer | llamás desde tu móvil · pinecall eval <id> |
| 5 · consola | shell + 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 · evals | el 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 · deriva | pinecall test con 10 goldens, dos modelos |
| 7 · web | @pinecall/web sobre livekit-client · tokens · widget · useCall | llamás desde el navegador y desde el móvil por 3G |
| 8 · supervisor + WhatsApp | verbos por WS attach · pantalla Supervisor · text/whatsapp · pausa humana | escuchás una llamada y susurrás |
| 9 · memoria + retrieval | Memory Protocol · PgvectorMemory · restore/last · knowledge/ con bge-m3 · <Memory> <Retrieved> con props y render props · perfil gpu · doctor --bench | la clínica responde desde docs/ y recuerda tu alergia en la segunda llamada |
| 10 · ejemplos + README | Tienda 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 · hosted | orgs + keys · deploy a la box · TLS · un tenant externo | un tenant externo se conecta con su key |
| 12 · OTel | el log → spans GenAI · Langfuse | una 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.
~/pinecall/v2/, github.com/pinecall/pinecall, Apache 2.0.pinecall + @pinecall/*; PyPI pinecall, import pinecall, CLI pinecall-runtime.render(state); la view al final.PgvectorMemory default, Graphiti opcional después.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.
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
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
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
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]
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
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
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
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
# 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
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 } }
| grupo | eventos (servidor → app) |
|---|---|
| ciclo de llamada | call.ringing · started · ended · summary · dialing · error · forwarded · routed |
| habla del caller | speech.started · speech.ended · user.speaking · user.message |
| turno | eager.turn · turn.pause · turn.end · turn.resumed · turn.continued |
| habla del agente | bot.speaking · bot.word · bot.finished · bot.interrupted · barge_in |
| tools y estado | tool.call · tool.result · state.changed · confirm.request · confirm.granted · confirm.declined |
| memoria y retrieval | memory.ops · docs.sources |
| supervisión | supervisor.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 |
| marcadores | log.gap · log.caught_up · custom (lo que this.log() escribe) |
| grupo | comandos (app → servidor) |
|---|---|
| registro | register · agent.configure · channel.add · channel.remove · session.configure {state} |
| hablar | bot.say · bot.reply · bot.reply_stream · bot.cancel · bot.clear |
| tools y estado | tool.result · prompt.set · tools.set · state.set · call.log |
| llamada | call.hangup · call.dial · call.forward · call.dtmf · call.hold · call.unhold · call.mute · call.unmute · call.route |
| humano | session.pause · session.resume · session.send |
| infra | ping · update_config · line.create · line.destroy |
| token | puede |
|---|---|
talk (web · chat) | conectarse a UN agente, una vez, 60 s; metadata sellada por el servidor del tenant, inforjable por el navegador |
observe | leer el log de los agentes de su set. Nada más |
supervise | leer y mandar los verbos por WS /v1/attach |
participate | observar 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.
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.
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.
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.
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.
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.
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.
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.
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 │
└────────────────────────────────────────────────────────────────────────────────────────┘
| endpoint | qué hace |
|---|---|
POST · GET · DELETE /v1/ops/orgs | organizaciones |
POST · DELETE /v1/ops/orgs/{id}/keys | una key se devuelve una sola vez; se guarda hash + prefijo + scopes agent · observe · evals · admin |
PUT /v1/ops/orgs/{id}/quotas | minutos, 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}/routes | nú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.
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
/.well-known/pinecall; el CLI aprende dónde está el WS y qué features hay. La clase degrada lo que el runtime no tiene con un aviso (<Retrieved rerank/> sin GPU).PINECALL_URL, PINECALL_API_KEY, PINECALL_PROFILE) → .env del proyecto → ~/.pinecall/credentials. Un proyecto lleva su .env; una persona, sus perfiles.pk_live_… / pk_test_…: el prefijo dice el entorno. Hasheada en el servidor, con scopes. Va en Authorization: Bearer y como primer mensaje del WS de la app — nunca en la URL. Los únicos tokens en query string son los de observación del navegador: cortos y de solo lectura.pinecall keys create · revoke; una key revocada se rechaza en el próximo frame.org en cada fila; los slugs de agente se namespacean por org; cada lectura del log filtra por org; los stream tokens sellan org + agentes.| self-hosted | hosted | |
|---|---|---|
| provider keys | env del box | managed (créditos) o BYOK: pinecall keys add elevenlabs sk_… → vault del cloud → runtime por org |
| números | el operador configura su trunk; phones add escribe la ruta | el cloud provisiona en Twilio y escribe la ruta; phones add verifica que el número es de la org |
| límites | quotas que fija el operador | el cloud fija quotas desde el saldo |
| uso | usage local, para tu contabilidad | el cloud lee /v1/ops/usage con cursor |
| la app del tenant | su infra | su 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.