pinecallDocs
Operating / The runtime CLI

The runtime CLI

pinecall-runtime: the gateway, the worker, the orgs, the keys.

The operator's terminal: the two processes, the database, the tenants and the fleet. One verb per group; pinecall-runtime --help prints them all, <group> --help that group's verbs. The tenant's terminal is pinecall, the CLI repo's (pinecall/cli) docs/the-cli.md, and they never overlap: nothing here writes an agent, nothing there issues a key.

What each group speaks to#

group speaks to
gateway · worker · runner · doctor · providers · local this machine: its settings, its database, its LiveKit; local brings all three up in Docker
migrate · sessions · memory · retention · traceback · facts · drift · usage · vault Postgres, straight, over DATABASE_URL (vault with PINECALL_VAULT_KEY too)
init · orgs · keys · routes · fleet · fence · sip a running gateway, over /v1/ops/* with PINECALL_OPS_KEY (Operator API); keys fleet and keys runner alone are minted on the database, before any gateway answers
load a running gateway's sandbox, over the worker's own call endpoints with the sandbox fleet's key (PINECALL_WORKER_KEY)

gateway · worker start · worker overflow · runner start#

gateway serves both environments: in a pod on the address PINECALL_GATEWAY_LISTEN names, behind the cluster's load balancer; elsewhere on the loopback address PINECALL_GATEWAY_URL names, and a URL that is not loopback is refused in one sentence. worker start is a worker of the fleet PINECALL_FLEET names, until told to stop or cordoned; worker overflow the one that answers when the fleet is full. runner start keeps the hosted apps of the environment its PINECALL_RUNNER_KEY opens running, one gVisor pod each: the runner is itself a pod of the hosting cluster (charts/hosting), outside the runtime's own cluster (On Kubernetes, "Hosting"). Every variable they read is Environment variables.

local#

pinecall-runtime local [--dir <path>] up [--services-only]
pinecall-runtime local [--dir <path>] down [--volumes]
pinecall-runtime local [--dir <path>] init --email <address> --person "<name>" [--org <slug>] [--name "…"]
pinecall-runtime local [--dir <path>] env

The runtime whole on one machine, for developing an agent against it: no cloud account, no key from anywhere. up writes the Docker Compose files the package ships into --dir (~/.pinecall-runtime/local unless named), brings up Postgres (17, with pgvector and pg_textsearch, built once), Redis and LiveKit on loopback ports of their own, migrates the schema, draws the machine's secrets once into <dir>/env (0600: the vault key, the ops key, the token key, the sandbox fleet's key), then runs the gateway on http://127.0.0.1:8080 — the console included, the sandbox at /sandbox/ — and a worker of the sandbox fleet, their lines prefixed gateway | and worker |. Ctrl-C stops the two; the services stay. --services-only stops after the schema, for a checkout that runs the two from its sources (make local-gateway, make local-worker). Run up again any time: it keeps the secrets and the database.

down stops the services and keeps the database; --volumes deletes it. init is init below, run on the local settings, so the first org and person need no shell line. env prints that line, set -a; . <dir>/env; set +a, for any other verb against the local runtime. It needs Docker with the compose plugin: Docker Desktop on macOS and Windows, Docker Engine on Linux; without it up says so in one sentence. A phone call needs the SIP bridge on the host's network, which Docker on macOS does not have: docker compose -f <dir>/compose.yaml --profile phone up -d sip, on Linux.

init#

pinecall-runtime init [--org <slug>] --email <address> --person "<name>" [--name "…"] [--role admin]

The first org and the first person on a runtime nobody has used yet: the org made (or found, run twice), its first admin invited, that person made an operator of this deployment, and the invitation link printed once with the two lines to type next.

orgs#

orgs list · orgs add <slug> [--name] · orgs rm <org>
orgs invite <org> <email> --name "…" [--role] · orgs operator <org> <email> [--revoke] · orgs remove-member <org> <email>
orgs move <agent> <org>
orgs quota <org> --env production|sandbox [--minutes n] [--messages n] [--agents n] [--concurrent-calls n]
           [--memory-facts n] [--knowledge-chunks n] [--numbers n] [--seats n] [--llm-tokens n]
           [--budget-usd n] [--lends vendor[/model],… | --lends none]
orgs dialling <org> [--dial-anywhere | --no-dial-anywhere] [--per-minute n] [--per-day n] [--max-duration-s n]
orgs sso <org> [--off]
orgs provider-key set|rm|list <org> <vendor>        # set reads the key from stdin

<org> is an id or a slug. quota replaces the whole set for one environment; dialling the whole set of guards; sso --off is the break-glass; provider-key set reads from stdin because argv is what ps shows.

keys#

keys issue [--org <org>] --env production|sandbox [--label "…"] [--scope <scope>]… [--subject <member>] [--name "…"]
keys list [--org <org>] · keys revoke <fingerprint> · keys fleet production|sandbox
keys runner production|sandbox

issue prints the key once; the table keeps the fingerprint. fleet mints an environment's fleet key on the database and prints it once: in a cluster nobody types it, since the chart's install Job runs it for both environments into the secret pinecall-fleet-keys, and on a laptop make local writes the sandbox's into .local/env. runner mints the key of the environment's runner the same way, put once in Secret Manager for the hosting cluster (Hosted apps, On Kubernetes, "Hosting").

routes#

routes list [--org] [--env] · routes add <number> <agent> [--channel phone|whatsapp] [--org] [--env]
routes rm <number> [--org] · routes seed [--file infra/seed/routes.json]

seed reads a JSON list of routes, each the body routes add sends (number, agent, channel, org, env), and adds them one by one. Its default, infra/seed/routes.json, is not in the repository: name your own file with --file.

fence#

fence export

The networks 5060 opens to, Twilio's first, then the carriers the operator admits and the addresses he approved (Telephony, "The fence, twice"), as Terraform reads them: {"sip_sources": [...]}. Written to infra/terraform/environments/<env>/sip_sources.auto.tfvars.json (the orgs' addresses, never committed), make tf-plan and make tf-apply bring the cloud's firewall level with the platform.

sip#

sip repoint [--from <world>=<name>]…

Every trunk of the platform's Twilio account and of each org's that still sends an environment's calls to the platform's name, or to a name --from says the environment was reached at before (--from production=sip.old.example.com), sent on to its SIP name (PINECALL_SIP_DOMAIN, Environment variables), a line each: the account, the trunk, the environment, where it pointed and where it points now. Once, when the platform's SIP moves off its names or a name is retired; run again, it sends nothing on.

fleet#

fleet list [--fleet <name>] · fleet cordon <worker> · fleet uncordon <worker>

list is the roster the gateway hears: each worker, what it holds, its seats, load, standing (accepting, failing, full, draining, cordoned, gone) and when it was heard, then each fleet summed, with its rooms waiting for a worker when there are any (a call LiveKit or a worker dropped, offered again by the gateway: Scaling, "Who takes a call"). cordon tells a worker, on its next heartbeat, to take no new call, finish what it holds and leave; uncordon takes that back while it has not left. How many workers a fleet runs is the cluster's: Scaling, "The burst"

load#

pinecall-runtime load --org <id> --agent <slug> --script <call-log.json> --calls <n> --ramp <seconds> --minutes <m>

Synthetic calls held against the gateway at PINECALL_GATEWAY_URL exactly as workers hold them, so the control plane is measured without LiveKit, audio or a vendor. Each call knocks the endpoints a worker knocks, through the worker's own client: it opens a call in the sandbox environment (there is no other), queues the script's entries a worker writes at the script's own gaps on the worker's own writer, which sends what is queued as one batch while the last is out, then its call.ended, and seals it once all were answered; the entries the gateway writes itself (call.ringing, call.attached, tool.call, tool.result, call.summary, call.score, memory and sources) are left out, and so are tool round-trips, which need an app socket. A call that ends before the run does is followed by a fresh one, so the number held stays at the top; a refusal ends that call only, and its slot opens another a second later. --script is a call's log as JSON, the shape of tests/wire/golden/call-log.json (125 entries over 53.5 s); --calls is how many at once at the top, reached linearly over --ramp seconds and held --minutes.

PINECALL_GATEWAY_URL=https://<staging's sandbox name> PINECALL_WORKER_KEY=<its sandbox fleet key> \
  pinecall-runtime load --org org_load --agent load --script tests/wire/golden/call-log.json \
  --calls 500 --ramp 60 --minutes 5

It runs against a cluster made for it (staging, make deploy ENV=staging), never production's: the calls are real calls of the org, logged, counted against its quotas and sealed. The org is one made for the run, with its sandbox quotas (orgs quota --env sandbox) above the run and its hang-up judging off (PUT /v1/org/judging), or every seal asks the platform's judge model. Each call at once has a client and a connection of its own, as a worker's job has, so ulimit -n must be above --calls. At the end it prints, a line each:

line what it is
calls opened · calls sealed calls the gateway opened, and those sealed after their call.ended
calls open at the end calls still writing past the run's end and a grace of 60 s, cut
most held at once the most calls open together
entries sent appends the gateway answered, and how many of them it keeps (durable)
entries a second at the top appends answered between the end of the ramp and the end of the run, per second
append ms · seal ms from an entry queued to the log holding it, what the call waits, p50/p95/p99; and the time one seal took, p50/p99
entries per batch how many entries each batch carried, p50/p99: 1 while calls are idle, more as the gateway slows
refusals the calls a refusal ended, by the status the gateway answered (unreachable for none)
logs verified · logs found wrong · logs unread each sealed call's log read back: every durable entry sent, once each, in order, and seqs that rose; unread counts the reads refused, by status
loop lag ms the p99 lag of the generator's own event loop; over 50 ms a warning: line follows, since a saturated generator measures itself

sessions · memory · retention · traceback · facts · usage · drift · migrate · providers · doctor#

sessions list [--agent] [--limit], sessions show <call> [--json], sessions tail [<call>], sessions recording <call>: the log read back off Postgres, every tenant's; each read of a call is a row of its org's access log (reader: operator), and so is each org a traceback showed. memory reembed embeds every fact another model wrote under the platform's embedder. retention due lists the sealed calls past their org's retention_days (PUT /v1/org/policy), oldest first; retention run erases them, each through the erasure path with retention as who asked, 5 000 a run at most, then forgets the detail records of erased phone calls, and the dials, older than 24 months, and the WhatsApp message ids claimed more than 7 days ago (WhatsApp); then makes the log's days a week ahead (call_log is partitioned by UTC day; a row no day holds lands in call_log_default, and a day the default already holds rows of is named and not made) and drops each past day nothing is left in; the CronJob pinecall-retention runs it at 04:00 UTC every night. doctor's days line names rows in the default and fewer than two days made ahead. traceback <number> [--since YYYY-MM-DD] answers a carrier's traceback: every phone call with the number, still kept or erased with its record, and every dial to it placed or refused, with the org, the environment, the number shown and who asked — 24 months back unless --since says a day. migrate up applies what the database lacks, migrate status says what it lacks (exit 1 while behind), migrate plan names every migration on the disk. providers list [--does llm|stt|tts] lists every vendor this build runs and whether the platform holds its key; providers seed <file> writes the providers row a deployment starts from, once: after it, the console edits it at /v1/ops/providers. providers prices <file.csv> [--apply] says what a prices file changes in the row's rates (new, changed, the same, and the models only the platform holds, which it keeps) and writes nothing until --apply; the repository ships infra/seed/prices.csv. facts rebuild [--call <call>] [--org <org id>] [--since YYYY-MM-DD] folds each call's facts row (what the lists, the inbox and the insights read) again from its log, every call's when no flag is given, and writes the row where it differs: one call at a time, each in a transaction of its own under the lock its appends take, so a live call waits milliseconds and nothing holds a long transaction; it prints how many calls it read, how many rows it rewrote, and how many it left as they were: a log holding an entry this release's wire refuses is never refolded, because the fold would pass the entry by and lose what the release that wrote it folded from it (doctor's sample passes those by too). usage rebuild folds every org's usage totals (what admission counts, a row per org, environment and month) again from the summaries in the log, as the usage feed folds each one, and rewrites the table in one transaction that holds it: a summary written meanwhile waits and is counted after; it prints how many summaries and rows. doctor asks each thing the platform needs one question, a line each, and exits 1 when one is missing. Its facts line examines the 20 newest calls and 20 heads from a random point of the call ids (never every head: on a deployment that is a probe per call), names each whose head gave out fewer seqs than its rows hold and how many it examined, and refolds 20 sealed calls from a random point of the call ids, naming each whose stored facts differ and the columns that do: facts rebuild --call mends one. Its offers line names the rooms a caller joined over a minute ago that are still kept: three offers 12 s apart let every room go before then, so one still kept is a room no gateway is sweeping. drift rebuild [--org <org id>] [--since YYYY-MM-DD] forgets the drift of the days the flags name (every org's and every day's when none), each stage's histogram and each judge's count that /v1/insights and /v1/insights/drift read, and counts every sealed call of them again from its log, each in a transaction of its own; it prints how many sealed calls it read and how many it counted. A seal whose count broke says so in the gateway's journal, and this is what mends it.

vault rotate#

Every secret the platform keeps is sealed under the first key of PINECALL_VAULT_KEY, and any key the list holds opens it. To turn the vault: put a new key in front of the list (in a cluster, a new version of the secret pinecall-<world>-vault-key in Secret Manager holding <new>,<old>, which External Secrets brings to the pods within the hour) and restart the gateways, so every secret written from then on is sealed under the new one; then vault rotate re-seals every sealed value of the schema under the first key, one line per column:

box_settings.ciphertext: 3 re-sealed, 0 under the first key already, 0 opened by no key listed

The columns are the vendors' keys (the org's and the platform's), the carriers, the mailboxes, the identity providers, the hosted apps' tokens, the org's secrets, the private values of calls' logs, the recordings' keys and the one-use words of sign-in (a terminal's key while it waits). Each row is its own write, guarded by the value it still holds, so a secret the gateway rewrites meanwhile is left to it; a run cut short is run again, and finds what it did under the first key. It exits 1 while a value opens under no key listed: that value was sealed under a key the list no longer holds, and the old key comes out of the list only once every line says 0 there.