Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,9 @@ pagination. Block and unblock are reversible actions and accept
## Calls

The `calls` commands place, answer, join, decline, leave, and end calls, invite
participants, follow a session's calls, and carry call audio:
participants, follow a session's calls, and carry call audio. `calls stats` and
`calls export` report on past calls; see
[Call statistics and export](#call-statistics-and-export). For example:

```shell
polymorfa calls place +15550100 --session support --exclusive
Expand Down Expand Up @@ -775,6 +777,55 @@ sox greeting.wav -t raw -r 16000 -e signed -b 16 -c 1 - | \
--input - --leave-on-eof > reply.pcm
```

### Call statistics and export

`calls stats` summarizes calls and `calls export` writes call detail records.
Both accept an organization API key or an exact-project token with the
`sessions:read` scope. Pass `--project` to read one project. Without it, an
organization API key reads every project in the organization. A project token
reads only its own project and requires `--project`.

```shell
polymorfa calls stats
polymorfa calls stats --project 018f0000-0000-7000-8000-000000000002 \
--group-by session
polymorfa calls stats --since 2026-09-01T00:00:00Z --until 2026-09-02T00:00:00Z \
--group-by hour --timezone Europe/Lisbon --heatmap

polymorfa calls export --since 2026-09-01T00:00:00Z --output calls.csv
polymorfa calls export --format ndjson --outcome missed > missed.ndjson
polymorfa calls export --max-pages 1 --limit 100
polymorfa calls export --since 2026-09-01T00:00:00Z --cursor <cursor> \
--output rest.csv
```

Both commands filter with `--session`, `--direction` (`inbound` or
`outbound`), `--upstream` (`linked_device` or `cloud_api`), `--outcome`
(`answered`, `missed`, `declined`, `failed`, or `in_progress`), `--since`, and
`--until` (RFC 3339 times; a call matches by its start time).

- **Statistics.** The range defaults to the last 7 days. `--group-by` accepts
`day` (the default, up to 366 days), `hour` (up to 31 days), `session`, or
`outcome`. Day and hour groups, and the `--heatmap` grid of calls by day of
week and hour, use `--timezone`, an IANA name such as `Europe/Lisbon` (UTC by
default). Human output prints the totals, with the answer rate as a
percentage and durations as hours, minutes, and seconds, and then one row per
group. `--json` prints the complete response, including the heatmap. Grouping
by session lists at most 500 numbers; the totals still count every call.
- **Export.** `--format` is `csv` (the default) or `ndjson`. The command requests
pages of `--limit` records (1 to 1,000; default 1,000), newest first, until
none remain, and writes one CSV header. Records go to standard output, or to
the `--output` file, which must not exist unless you pass `--force`. The file
is created, or emptied with `--force`, when the first page arrives, even if
no records match. Progress,
the final summary, and the next cursor go to standard error, as JSON lines
with `--json`. `--max-pages` stops early and prints the cursor for the rest;
`--cursor` resumes from it with the same filters. If a page fails, the
command keeps the records already written, prints the cursor that resumes
from the failed page, and exits with the API error's exit code. The other
party appears only as `peerRef`, a team-specific pseudonym, never a phone
number.

### SIP trunks

SIP trunks connect a PBX to WhatsApp calls. They are part of Calls and need no
Expand Down
44 changes: 41 additions & 3 deletions docs/command-research/calls.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Calls command research

- **Status:** Implemented against Calls contract revision 1, unpublished
- **Reviewed:** 2026-09-17
- **Reviewed:** 2026-09-19
- **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls`
subpath) `0.1.0-dev.20260922115749` from npm, published from `polymorfa/sdks`
commit `8bc00778b3b3aaf05b09369c3f7840eb24cf6a56` by
Expand All @@ -21,6 +21,11 @@
`session` and `participant` query parameters, 60-second re-validation, and
the 4401, 4409, 4429, 4400, and 1013 close codes.
- The public guide for Calls.
- Platform operations `getCallStats` (`GET /platform/calls/stats`) and
`exportCallRecords` (`GET /platform/calls/export`) from the management
OpenAPI document at `polymorfa/polymorfa` commit
`4b75534812dc25e839fda3c2ba231e46afe39534`, and the export route's
`Polymorfa-Next-Cursor` header and CSV encoding.
- `MessagingClient.voip` and the public `@polymorfa/sdk/calls` entry point
(`CallsClient`, `Call`, and the Calls error classes).

Expand All @@ -37,6 +42,11 @@ calls
watch [--session] [--participant] [--count <n>]
media [callId] [--session] [--place <to>] [--exclusive] [--participant]
[--input <file|->] [--output <file|->] [--leave-on-eof] [--video]
stats [--session] [--direction] [--upstream] [--outcome] [--since] [--until]
[--group-by day|hour|session|outcome] [--timezone <iana>] [--heatmap]
export [--session] [--direction] [--upstream] [--outcome] [--since] [--until]
[--format csv|ndjson] [--output <file|->] [--force] [--limit <1-1000>]
[--cursor <cursor>] [--max-pages <n>]
settings
get --session <id>
set --session <id> [--[no-]conference-mode] [--calls-enabled=<true|false>] ...
Expand All @@ -54,6 +64,8 @@ calls
| `calls invite` | `POST /messaging/voip/calls/{id}/participants` | `voip.addParticipant` |
| `calls settings get` | `GET /platform/sessions/{session}/call-settings` | `voip.retrieveCallSettings` |
| `calls settings set` | `PUT /platform/sessions/{session}/call-settings` | `voip.updateCallSettings` |
| `calls stats` | `GET /platform/calls/stats` | `calls.stats` |
| `calls export` | `GET /platform/calls/export` | `calls.export` |
| `calls retention get` | `GET /platform/call-retention` | `callRetention.retrieve` |
| `calls retention set` | `PUT /platform/call-retention` | `callRetention.update` |
| `sip-trunk endpoint` | `GET /platform/sip/endpoint` | `sipTrunks.endpoint` |
Expand Down Expand Up @@ -95,6 +107,11 @@ internal SDK module.
Messaging client; the CLI builds that client from the Platform environment.
`calls settings set --expected-revision` sends `expectedRevision`; a stale
revision returns 409 `state_conflict`, which exits with code 4.
- `calls stats` and `calls export` accept an organization API key or an
exact-project token (`sessions:read`), resolved like the developer
commands. `--project` (or a project token, which requires it) reads one
project; an organization key without `--project` reads the whole
organization. `--owner` still overrides the inferred owner.
- `calls retention` resolves a team API key or a project token
(`resolveTeamSettingEnvironment`), from the environment or a profile, with
no project context, and the API decides what each principal may do.
Expand Down Expand Up @@ -153,10 +170,31 @@ internal SDK module.
newer). On older runtimes they fail with a configuration error before
connecting. The CLI as a whole requires Node.js 22 or newer.

- **Call statistics.** Filters and range limits (366 days for `day`, 31 for
`hour`, IANA `--timezone`) are checked before the request; the API default
range of 7 days applies when `--since` or `--until` is omitted. Human output
prints totals, a group table, and, with `--heatmap`, a 7-by-24 grid. `--json`
prints the standard read envelope with the complete response.
- **Call export.** The command follows `Polymorfa-Next-Cursor` until it is
absent and writes the CSV header from the first page only; a later page with
different columns, or a repeated cursor, stops the export with a server
error. Records go to standard output or `--output`, which is created on the
first page (even an empty one, so a zero-record export truncates a
`--force` target) and refused when it exists unless `--force`. Progress, the
summary, and resume cursors go to standard error only (JSON lines with
`--json`). `--cursor` is sent with the filters because the API expects the
same filters on every page. A failed page keeps earlier records, reports
the cursor for the failed page, and exits with the API error's exit code.
- **SDK publication gate.** `src/services/call-analytics.ts` uses typed
`calls.stats` and `calls.export`. The published SDK pin in `package.json`
predates those methods. Verify this branch with the local SDK package until
the exact analytics SDK revision is published, then pin that version before
merging the CLI change.

## Explicit gaps

- No call list, retrieve, or history command: the public contract has no such
customer operation.
- No call list or retrieve command. `GET /platform/calls` exists; `calls
export --format ndjson` covers the same records for scripts.
- `calls media` does not attach to a call that was already ringing or answered
before it started, and carries no video.
- `calls watch` does not report outbound calls placed by other participants.
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@modelcontextprotocol/server": "2.0.0",
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "0.1.0-dev.20260923115544",
"@polymorfa/sdk": "0.1.0-dev.20260923124417",
"jq-wasm": "3.0.0-jq-1.8.2",
"zod": "4.2.0"
},
Expand Down Expand Up @@ -106,7 +106,7 @@
"description": "Reject incoming calls through the Linked Device session route."
},
"calls": {
"description": "Place, answer, join, decline, leave, end, and watch calls, and carry call audio."
"description": "Place, answer, join, decline, leave, end, and watch calls, carry call audio, and report on past calls."
},
"calls:retention": {
"description": "Inspect and change how long Polymorfa keeps your team's call data."
Expand Down
2 changes: 2 additions & 0 deletions skills/polymorfa/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Use `polymorfa quicklink create --project PROJECT --input request.json` with an
- Use `polymorfa mcp serve` for an agent that needs structured tools. Inspect the exposed surface with `polymorfa mcp tools` and diagnose setup with `polymorfa mcp doctor`.
- MCP `listen` start/status/stop controls streams owned by that MCP server process. It does not create a detached daemon.

Commands that stream data, such as `polymorfa calls export`, write records to standard output and progress, summaries, and resume cursors to standard error. Keep the streams separate and resume with the reported `--cursor` and the same filters.

Use `--json` when another program consumes the result. Treat `schemaVersion`, `context`, `request`, pagination metadata, mutation receipts, and symbolic errors as the stable automation contract. Do not scrape human tables or prose.

## Mutate safely
Expand Down
30 changes: 30 additions & 0 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {

import {
resolveCommandEnvironment,
resolveDeveloperEnvironment,
resolveListenerEnvironment,
resolveTeamSettingEnvironment,
type DeveloperEnvironment,
type TeamSettingEnvironment,
type ResolvedEnvironment,
} from './context/environment.js'
Expand Down Expand Up @@ -99,6 +101,34 @@ export abstract class BaseCommand extends Command {
return session
}

/**
* Resolve an organization API key or exact-project token without building a
* typed client, for commands that span projects. With `inferOwner`, --project
* or a project token reads one project and an organization key otherwise
* reads the whole organization.
*/
protected async developerEnvironment(
flags: GlobalFlags,
options: {readonly inferOwner?: boolean} = {},
): Promise<DeveloperEnvironment> {
const environment = await resolveDeveloperEnvironment({
env: process.env,
...(flags['api-version'] === undefined
? {}
: {apiVersion: flags['api-version']}),
...(options.inferOwner === undefined
? {}
: {inferOwner: options.inferOwner}),
...(flags.owner === undefined ? {} : {owner: flags.owner}),
...(flags.profile === undefined ? {} : {profile: flags.profile}),
...(flags.project === undefined ? {} : {project: flags.project}),
...(flags.timeout === undefined ? {} : {timeoutMs: flags.timeout}),
})
this.registerSecret(environment.credential)
this.errorContext = environment.context
return environment
}

protected async messagingClient(
flags: GlobalFlags,
options: {readonly serverApiKeyOnly?: string} = {},
Expand Down
Loading