pinecall
Build voice and chat agents as Python classes. Annotated fields are the agent's state, @tool
methods are what the model can call, docstrings are what it reads about them, and a Jinja view
renders the part of the prompt that changes during the call.
from typing import Literal
from pinecall import Agent, state, tool
from clinica import agenda # your own system: a CRM, an API, a database
class ClinicaNorte(Agent):
"""Eres la recepción de Clínica Norte. Hablas de usted, con frases cortas."""
stage: Literal["identify", "book"] = "identify"
patient: dict[str, str] | None = state(None, pii=True)
slots: list[dict[str, str]] = []
@tool(stage="identify", pii=("name", "phone"))
def find_patient(self, name: str, phone: str) -> dict[str, str] | None:
"""Busca al paciente por nombre y teléfono. Pide los dos antes de llamarla."""
self.patient = agenda.buscar(name, phone)
if self.patient:
self.stage = "book"
return self.patient
@tool(stage="book", preview=2)
def free_slots(self, day: str) -> list[dict[str, str]]:
"""Horas libres de un día."""
self.slots = agenda.libres(day)
return self.slots
views/clinica-norte.jinja, beside the class, renders the prompt from that state:
{% if stage == "identify" %}
Saluda y pide nombre y teléfono. Nada más hasta identificar al paciente.
{% endif %}
{% if remembers("médico habitual") %}
Ofrece primero las horas de su médico habitual.
{% endif %}
{% if slots %}
## Horas libres, en orden
{% for hueco in slots %}
{{ hueco.cuando }} con {{ hueco.doctor }}
{% endfor %}
{% endif %}
The prompt has four named blocks: identity, knowledge and tools are static and cached
before the history; view comes after it and is replaced every turn. A block is re-sent only when
its text changes.
Memory and knowledge-base results never enter the prompt. The platform runs recall and search
and returns their results to the model as tool results, so retrieved text is treated as data, not
instructions. A view can ask remembers("médico habitual") and write its own sentence about the
answer.
Five minutes
uv add pinecall, or pip install pinecall. PyPI pinecall 0.1.0 to 0.1.5 are the runtime's
releases, before it moved to pinecall-runtime: a project pins pinecall>=0.1.6.
This package is a library: the verbs are the one pinecall CLI's, the same for every language. In
a project laid out as agents/<name>/agent.py, with this package in its .venv:
npm i -g pinecall # the CLI: Node, whatever the agent is written in
pinecall link # this folder tied to your org: its key, in ./.env
pinecall prompt --state test/<name>/goldens/a.json # the exact prompt a state produces. No gateway.
pinecall chat # the agent served from this terminal, and a caller against it
pinecall test # ring 1: the goldens, scored by the gateway
pinecall start # registered and answering: the process you deploy
The CLI never loads your class: for prompt, chat, test and start it runs this package's
serve entry, .venv/bin/python -m pinecall.serve, and talks to it through the gateway. The
console, the knowledge bases, memory, keys and every other verb are the CLI's. How a server runs
the agent, with the CLI or inside your own Python process, is docs/production.md.
Architecture
This package is the application side. It never talks to a model vendor or handles audio: it sends
commands and reads log entries over one WebSocket, in the runtime's own wire (pinecall.wire),
held to the runtime's golden call log. Tools and hooks are the only code that may change state.
| you want | you use |
|---|---|
| the class and the view | pinecall.Agent, pinecall.bridge.mount |
| the agent held inside your own process | await pinecall.hold(ClinicaNorte, url=…, api_key=…) |
| the socket alone, your own way of deciding what to answer | pinecall.Client |
| a suite with no network, no key and no model | pinecall.testing |
import asyncio
import os
from pinecall import Client
from pinecall.wire.events import UserTurnEnded
async def main() -> None:
pc = Client("https://cloud.pinecall.io", os.environ["PINECALL_KEY"])
agent = pc.agent("clinica-norte")
def heard(event, call): # every turn.user of every call this socket serves
if isinstance(event, UserTurnEnded):
call.say(f"Le he oído: {event.text}")
agent.on("turn.user", heard)
await pc.connect()
await asyncio.Event().wait()
asyncio.run(main())
Testing it
Agent tests run with no network, key, model or gateway, in a plain pytest:
from pinecall.testing import Gateway, load
ClinicaNorte = load("agents/clinica-norte/agent.py")
with Gateway() as pc:
pc.mount(ClinicaNorte)
call = pc.call_started(from_="+34600123456")
call.tool("find_patient", name="Marta Ruiz", phone="600123456")
assert "Saluda y pide nombre" not in call.prompt
assert call.tools == ["free_slots"]
Where the rest is
| what | where |
|---|---|
| tutorial: an agent with knowledge and memory, from an empty directory | docs/tutorial.md |
| every file, entity and rule | ARCHITECTURE.md |
| how to write an agent, step by step | docs/writing-an-agent.md |
| the view, and the blocks of a prompt | docs/the-view.md |
| how to test one | docs/testing-an-agent.md |
| running it in production | docs/production.md |
| the CLI, verb by verb | the one CLI's reference, at docs.pinecall.io |
| a complete example agent | examples/clinica_norte |
| the wire itself | src/pinecall/wire/, the runtime's shapes this package speaks |
Developing
uv sync
make check # ruff, pyright strict, deptry, the suite and the example's own
Requirements
Python 3.11+. Runtime dependencies: pydantic, Jinja2, websockets and httpx. The pinecall CLI is
a Node program (Node 24+): the verbs need it, the library does not.
License
Apache-2.0.
Metadata
Release files for pinecall 0.1.6
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pinecall-0.1.6.tar.gz | 82.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pinecall-0.1.6-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 183.9 kB
Release files / pinecall-0.1.6.tar.gz
| Download URL | pinecall-0.1.6.tar.gz |
|---|---|
| Size | 82.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
181f9dcb0ac71575a8719302428ed003b95bba0f9b946030db854428341e5af9
|
|
BLAKE2b-256 checksum How to use checksums |
72b9dffa48c9ba3c91341cfad1d814b3ca45e29ca703c3570391f6ec583688ff
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / pinecall-0.1.6-py3-none-any.whl
| Download URL | pinecall-0.1.6-py3-none-any.whl |
|---|---|
| Size | 101.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bf9523b3a588dd075e31d25c60378fcf36213a3d2ba2e8dc9c0cc1a0710d3429
|
|
BLAKE2b-256 checksum How to use checksums |
3a5036c5e23fca64107990b1b5ba148c52b78035b35450201d8f47d7762e0ad6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|