Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
abfdc34
feat(calls)!: add unified call commands
purpshell Sep 16, 2026
88ef849
chore(deps): pin the SDK commit that reports call capabilities
purpshell Sep 16, 2026
d993756
feat(calls): manage SIP trunks and route incoming calls
purpshell Sep 16, 2026
9096990
chore(deps): pin the SDK commit with case-insensitive trunk scoping
purpshell Sep 16, 2026
2ea8bbd
feat(calls): refuse settings changes that lost to another update
purpshell Sep 16, 2026
47169d3
feat(calls): send only the call settings given
purpshell Sep 16, 2026
551d230
feat(calls): turn calling off for a session
purpshell Sep 16, 2026
730f9bd
test(calls): exit 3 when calling is turned off for the session
purpshell Sep 16, 2026
abfea4c
chore(deps): pin SDK efd96e7
purpshell Sep 16, 2026
d8cbead
Merge origin/dev into t3code/calls-unified-clients
purpshell Sep 17, 2026
632f54a
fix(calls): stop calls media when its output file fails
purpshell Sep 17, 2026
4d74e3f
fix(calls): close the calls media output before reporting completion
purpshell Sep 17, 2026
3546cf7
fix(calls): guard settings changes by revision and leave cleanly from…
purpshell Sep 17, 2026
6ef9ef9
chore(deps): pin SDK b62cc8d
purpshell Sep 17, 2026
05449b5
fix(calls): protect and preflight the calls media input file
purpshell Sep 17, 2026
3b4cd9b
feat(calls): set Cloud API call hosting from calls settings
purpshell Sep 17, 2026
0a34d6a
fix(calls): end calls placed after Ctrl-C and release standard input
purpshell Sep 17, 2026
4369941
chore(deps): pin the SDK to 65bd14f
purpshell Sep 17, 2026
2cabb83
chore(deps): pin the SDK to 7c0dc9b
purpshell Sep 17, 2026
85a2756
chore(deps): pin the SDK to 81c4e6e
purpshell Sep 17, 2026
bc958ac
chore(deps): pin the SDK to 3ddefd8
purpshell Sep 17, 2026
b5a1e67
chore(deps): pin the SDK to f8e107b
purpshell Sep 17, 2026
660385b
chore(deps): pin the SDK to 1cadd54
purpshell Sep 17, 2026
12c87a3
chore(deps): pin the SDK to 12cdbd4
purpshell Sep 17, 2026
5fbc617
chore(deps): pin the SDK to 109a95c
purpshell Sep 17, 2026
09c7be2
chore(deps): pin the SDK to d85fd5c
purpshell Sep 17, 2026
6802886
chore(deps): pin the SDK to cb7bfdb
purpshell Sep 17, 2026
67820a1
chore(deps): pin the SDK to 4ba6271
purpshell Sep 17, 2026
c6345bc
chore(deps): pin the SDK to 25b68cf
purpshell Sep 17, 2026
dded484
chore(deps): pin the SDK to fc6968c
purpshell Sep 18, 2026
733efa8
build(deps)!: take the Calls client from @polymorfa/sdk/calls and req…
purpshell Sep 18, 2026
a1c62f2
chore(deps): pin the SDK to 589618b
purpshell Sep 18, 2026
e4cfbc0
feat(calls)!: replace self audio with conference mode and pin SDK 94b…
purpshell Sep 18, 2026
7c8d6c1
chore(deps): regenerate the lockfile for SDK 94bfd09
purpshell Sep 18, 2026
045a66e
fix(calls): report standard input read errors and failed cancel cleanup
purpshell Sep 18, 2026
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
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ jobs:
fail-fast: false
matrix:
node-version:
- 20
- 22
steps:
- uses: actions/checkout@v6
Expand Down
138 changes: 133 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ access until `@polymorfa/cli` is released publicly.

## Requirements

- Node.js 20 or newer
- Node.js 22 or newer
- A Polymorfa organization API key or exact-project token for developer requests
- A server API key or short-lived client token for Messaging requests

Expand Down Expand Up @@ -443,10 +443,138 @@ request. Lists are complete arrays because the source exposes no contact
pagination. Block and unblock are reversible actions and accept
`--idempotency-key`; they are not available to browser client tokens.

## Calls and stable identities
## Calls

Three focused Linked Device commands cover the exact Calls, LIDs, and Users
source tags:
The `calls` commands place, answer, join, decline, leave, and end calls, invite
participants, follow a session's calls, and carry call audio:

```shell
polymorfa calls place +15550100 --session support --exclusive
polymorfa calls accept call_123 --exclusive --participant agent-7
polymorfa calls reject call_123
polymorfa calls leave call_123 --connection-id conn_0123456789
polymorfa calls end call_123
polymorfa calls invite call_123 +15550101

polymorfa calls watch --session support --json
polymorfa calls media --session support --place +15550100 \
--input prompt.pcm --leave-on-eof --output reply.pcm

polymorfa calls settings get --session support
polymorfa calls settings set --session support --no-conference-mode
polymorfa calls settings set --session support --calls-enabled=false \
--expected-revision 3
polymorfa calls settings set --session support \
--inbound-route sip_trunk --sip-trunk 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10
```

Every inbound call rings until a participant answers or declines it. The CLI
never answers or declines on its own.

- **Participants.** A server API key acts as `server:<participant>`. Pass
`--participant` (1 to 128 characters from `A-Z`, `a-z`, `0-9`, `.`, `_`, `:`,
`@`, and `-`) to name it; the API uses `default` otherwise. A client token
from `POLYMORFA_CLIENT_TOKEN` acts as its own participant, so the CLI rejects
`--participant` with a client token before sending a request.
- **Sessions.** Server API keys name the session with `--session` for
`calls place`, `calls watch`, and `calls media`. Client tokens use the
session they are bound to; `calls watch` and `calls media` reject
`--session` with a client token.
- **Claims.** `calls accept --exclusive` claims the call. Other participants
then receive `call_claimed`, and the command exits with code 4 and prints
`code: "call_claimed"` under `--json`. Without a claim, a later accept joins
the answered call. `calls reject` exits with code 4 and
`code: "call_not_ringing"` when the call was already answered, declined, or
ended. Declining ends the call for every participant.
- **Connections.** `calls leave` closes one connection, named by an 8 to 64
character ID from `A-Z`, `a-z`, `0-9`, `_`, and `-`. The call continues.
`calls end` ends the call for everyone.
- **Client token actions.** Client tokens can place, accept, decline, leave,
end, invite, watch, and carry audio when the session's client rules allow
it. The API decides; the CLI reports its errors unchanged.
- **Settings.** `calls settings` uses the Platform API and requires a server
API key. `--calls-enabled=false` turns calling off for the session: placing,
answering and joining calls then exit with code 3 (`calls_disabled`),
incoming calls are declined, and calls in progress continue.
Conference mode is on by default: every participant you connect to a call
(apps, bots, and SIP trunk callers) hears the WhatsApp party and each other.
`--no-conference-mode` turns it off, so each participant hears only the
WhatsApp party; `--conference-mode` turns it back on. The WhatsApp party
always hears all of your participants, and nobody hears their own audio.
`--inbound-route sip_trunk` with `--sip-trunk` also sends incoming calls to a
SIP trunk; `--sip-claim` controls whether the trunk's answer claims the
call. On a Cloud API session, `--host-cloud-api-calls=true` has Polymorfa
Calls answer incoming calls; with the default `false`, your Graph API
integration answers them. `calls settings set` sends only the flags you pass; the other
settings keep their values. `calls settings get` and `set` print the
settings `revision`, which increases on every change and is `0` while the
session uses the defaults. Pass `--expected-revision <n>` to apply the
change only if the settings still have that revision; if someone changed
them first, the command exits with code 4 (`state_conflict`) and changes
nothing.

`calls watch` streams inbound calls and their progress: `incoming`, `claim`,
`state`, `participant_joined`, `participant_state`, `participant_left`, and
`ended`. Human output prints one tab-separated line per update and status on
standard error. `--json` prints one JSON object per line, including `ready`,
`disconnected`, `notice`, and a final `stopped` line. `--count <n>` exits
after `n` updates; Ctrl-C stops cleanly with exit code 0. Watching reconnects
with backoff after network loss, and exits with code 3 when the API stops
authorizing the credential. It reports calls that ring after it connects, not
calls placed by other participants.

`calls media` carries one call's audio as raw PCM: signed 16-bit
little-endian, mono, at the call's sample rate (16 kHz by default). With
`--place <target>` it places a call and connects when it is answered. Without
it, the command waits for an inbound call that rings after it starts (the
given call ID, or the next call), then answers it, or joins it when another
participant answered without a claim. `--input <file|->` sends audio in real
time, `--output <file|->` receives audio (standard output by default, never a
terminal), and `--leave-on-eof` leaves after the input is sent. Status goes to
standard error, as JSON lines with `--json`. Ctrl-C leaves the call, or ends a
placed call that is still ringing. If the `--output` file cannot be written,
or the `--input` file cannot be read, the command leaves the call and exits
with code 2. It exits with code 2 before connecting when the `--input` file
cannot be opened or when `--input` and `--output` name the same file. The
command carries audio only.

Convert audio with any tool that writes raw PCM, for example:

```shell
sox greeting.wav -t raw -r 16000 -e signed -b 16 -c 1 - | \
polymorfa calls media --session support --place +15550100 \
--input - --leave-on-eof > reply.pcm
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

### SIP trunks

SIP trunks connect a PBX to WhatsApp calls. They are a beta: changes fail with
exit code 3 until your team is enrolled. Commands require a server API key.

```shell
polymorfa sip-trunk list --project 018f0000-0000-7000-8000-000000000002
polymorfa sip-trunk view 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10
polymorfa sip-trunk create --project 018f0000-0000-7000-8000-000000000002 --input trunk.json
polymorfa sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \
--disable --expected-revision 3
polymorfa sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --input changes.json
polymorfa sip-trunk rotate-credentials 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes
polymorfa sip-trunk delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes
```

`create` and `update` read the request body from a file or stdin (`-`), so
passwords never appear in the command line. `create` refuses a body that names
`projectId`; use `--project`. A trunk that accepts calls from your PBX, and
`rotate-credentials`, print the password once. `update --expected-revision`
exits with code 4 when the trunk changed; `delete` exits with code 4 while a
session routes calls to the trunk.

## Session call rejection and stable identities

`call reject` is a separate command for the Linked Device session route. It
rejects an incoming call by session, call ID, and caller ID. Use
`calls reject` for the unified Calls API. Three focused Linked Device commands
cover the exact session call rejection, LIDs, and Users source tags:

```shell
polymorfa call reject call_123 \
Expand Down Expand Up @@ -887,7 +1015,7 @@ npm run verify

The verification gate checks formatting, lint, strict TypeScript, tests,
production build, npm package contents, and the production dependency audit on
Node.js 20 and 22 in hosted CI.
Node.js 22 in hosted CI.

### Guided session creation and configuration

Expand Down
8 changes: 6 additions & 2 deletions docs/command-research/calls-lids-users.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ payload.

## Explicit gaps

The source exposes no call list, retrieve, accept, placement, history, watch, or
stream route. It exposes no bulk LID resolution, identity directory, search,
The unified Calls API is a separate surface with its own command family,
`calls`; see [calls.md](calls.md). `call reject` remains the Linked Device
session route and its help text points to `calls reject`.

The session route exposes no call list, retrieve, accept, placement, history,
watch, or stream operation. It exposes no bulk LID resolution, identity directory, search,
pagination, or retained mapping history. It exposes no security-code list,
cache, history, refresh, verification submission, or private QR operation. The
CLI does not invent these workflows or cross credential boundaries with raw
Expand Down
134 changes: 134 additions & 0 deletions docs/command-research/calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Calls command research

- **Status:** Implemented against Calls contract revision 1, unpublished
- **Reviewed:** 2026-09-17
- **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls`
subpath) at `polymorfa/sdks` commit
`94bfd09825817f842434e206e6a77adb5cc0c414`

## Sources reviewed

- Integrated Calls contract, revision 1 (September 16, 2026), in the monorepo
plan for unified call clients.
- Messaging OpenAPI operations `voipPlaceCall`, `voipAcceptCall`,
`voipRejectCall`, `voipLeaveCall`, `voipTeardown`, and `voipAddParticipant`,
and the Platform operations `getCallSettings` and `updateCallSettings`.
- The API lifecycle socket handler: first-frame authentication, allowed
`session` and `participant` query parameters, 60-second re-validation, and
the 4401, 4409, 4429, 4400, and 1013 close codes.
- The public guide for Calls.
- `MessagingClient.voip` and the public `@polymorfa/sdk/calls` entry point
(`CallsClient`, `Call`, and the Calls error classes).

## Shipped command tree

```text
calls
place <to> [--session] [--video] [--exclusive] [--participant]
accept <callId> [--exclusive] [--video|--no-video] [--participant]
reject <callId> [--participant]
leave <callId> --connection-id <id> [--participant]
end <callId>
invite <callId> <target>
watch [--session] [--participant] [--count <n>]
media [callId] [--session] [--place <to>] [--exclusive] [--participant]
[--input <file|->] [--output <file|->] [--leave-on-eof] [--video]
settings
get --session <id>
set --session <id> [--[no-]conference-mode] [--calls-enabled=<true|false>] ...
```

## Operation mapping

| Command | Operation | SDK surface |
| ------------------------------ | ------------------------------------------------ | ----------------------------------- |
| `calls place` | `POST /messaging/voip/calls` | `voip.place` |
| `calls accept` | `POST /messaging/voip/calls/{id}/accept` | `voip.accept` |
| `calls reject` | `POST /messaging/voip/calls/{id}/reject` | `voip.reject` |
| `calls leave` | `POST /messaging/voip/calls/{id}/leave` | `voip.leave` |
| `calls end` | `DELETE /messaging/voip/calls/{id}` | `voip.end` |
| `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` |
| `sip-trunk list` | `GET /platform/sip-trunks` | `sipTrunks.list` |
| `sip-trunk view` | `GET /platform/sip-trunks/{id}` | `sipTrunks.retrieve` |
| `sip-trunk create` | `POST /platform/sip-trunks` | `sipTrunks.create` |
| `sip-trunk update` | `PATCH /platform/sip-trunks/{id}` | `sipTrunks.update` |
| `sip-trunk delete` | `DELETE /platform/sip-trunks/{id}` | `sipTrunks.delete` |
| `sip-trunk rotate-credentials` | `POST /platform/sip-trunks/{id}/credentials` | `sipTrunks.rotateCredentials` |
| `calls watch` | Lifecycle socket | `CallsClient` events |
| `calls media` | Place or accept, then the media socket | `CallsClient`, `Call`, `call.audio` |

The CLI uses only the neutral public calling surface. It does not expose
offers, candidates, session descriptions, or media framing, and it imports no
internal SDK module.

## Credential rules

- Server API keys act as `server:<participant>`. `--participant` must match
`[A-Za-z0-9._:@-]{1,128}`; the API defaults to `default`.
- Client tokens (`POLYMORFA_CLIENT_TOKEN`) act as their own participant. The
CLI rejects `--participant` with a client token before any request. The API
enforces client rules (`voip_place`, `voip_answer`, `voip_signal`); the CLI
reports its errors unchanged.
- `calls place`, `calls watch`, and `calls media` require `--session` with a
server API key. Client tokens use their bound session. `calls watch` and
`calls media` reject `--session` with a client token because the socket never
sends it for client tokens and the CLI cannot prove it matches.
- `calls settings` resolves Platform credentials, so client tokens fail with
the standard Platform credential error. The SDK exposes the operation on the
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.
- Sockets carry the credential in their first frame. The CLI requires an HTTPS
base URL, or HTTP on a loopback host, before opening any Calls connection.

## Command decisions

- **Two reject commands.** `call reject` stays on the Linked Device session
route, which needs the session, call ID, and caller ID. `calls reject` is the
unified API decline. Each command's help names the other.
- **Conflicts.** `call_claimed`, `call_not_ringing`, and `connection_limit`
get specific messages. They exit with code 4, and `--json` errors include the
stable `code`. The CLI adds `code` to presented conflict errors generally.
- **No idempotency flags.** The contract defines no idempotency behavior for
these routes, and the SDK retries keyed POST requests on 409 and 5xx.
Retrying a placement could place a second call, so the CLI does not offer
`--idempotency-key`. `calls end` succeeds when repeated.
- **No confirmation for `calls end`.** Ending a call is the explicit purpose
of the command, like hanging up. Declining with `calls reject` also ends the
call for everyone; both help texts say so.
- **Validation.** Call targets must match the contract pattern
`^(?:\+[1-9]\d{1,14}|[1-9][0-9]{0,18})$`. Connection IDs must match
`^[A-Za-z0-9_-]{8,64}$`. Call IDs must be nonempty. All checks run before
transport.
- **`calls watch`.** The public `CallsClient` reports inbound calls and their
claim, state, roster, and end updates; it does not surface calls placed by
other participants or raw lifecycle frames. The command streams exactly what
the client reports. A 4401 close, or a 4400 refusal, is terminal: exit code 3
or 2. Other socket problems are printed as `notice` lines while the SDK
reconnects. Ctrl-C and `SIGTERM` stop with exit code 0.
- **`calls media`.** The public client creates calls only from placement or
from a ring it observes, so the command either places a call or waits for an
inbound ring (optionally a specific call ID). It answers the call, or joins
it when another participant answered without a claim. Input is paced in real
time in 20 ms frames because the service buffers only a short window;
an input underrun restarts the clock instead of causing a burst. Received
audio is written as little-endian bytes regardless of host byte order.
Output never goes to a terminal. Ctrl-C leaves, or ends a placed call that is
still ringing. The CLI cannot choose the connection ID because the public
client generates it; status output reports it.
- **Runtime.** Socket commands need a global `WebSocket` (Node.js 22 or
newer). On older runtimes they fail with a configuration error before
connecting. The CLI as a whole requires Node.js 22 or newer.

## Explicit gaps

- No call list, retrieve, or history command: the public contract has no such
customer operation.
- `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.
- `@polymorfa/sdk` (with its `@polymorfa/sdk/calls` subpath) is not
published; development pins an exact Git commit. The CLI cannot release
until `@polymorfa/sdk` is published with a compatible semver range.
11 changes: 8 additions & 3 deletions docs/command-research/customers.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ explicit project ID.
| `customer status` | Check project enablement |
| `customer enable` | Enable Customers and assign existing Numbers to the default Customer |
| `customer list` | List and filter Customer summaries with cursor metadata |
| `customer create` | Create a Customer with optional name, phone, and external ID |
| `customer create` | Create a Customer with optional name and external ID |
| `customer view` | Retrieve one Customer |
| `customer update` | Update or clear Customer profile fields |
| `customer archive` | Archive a non-default Customer |
Expand All @@ -23,5 +23,10 @@ explicit project ID.

The CLI preserves `Idempotency-Key` headers for every operation that requires
them. It also keeps list cursor metadata in JSON output. Human output masks
phone numbers returned by list and Number endpoints; full Customer retrieval
returns the API response unchanged under `--json`.
phone numbers returned by Number endpoints; full Customer retrieval returns the
API response unchanged under `--json`.

Customer profiles carry no phone number, and pairing-link creation accepts no
page locale or theme. The pinned contract removed those fields, so the CLI no
longer offers `--phone`, `--clear-phone`, `--locale`, or `--theme`. Pairing
pages take appearance from saved QuickLink settings.
3 changes: 2 additions & 1 deletion docs/development/bansafe-sdk-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ The BanSafe CLI commands depend on the matching typed SDK revision pinned in
`package.json`. During coordinated SDK and CLI development, verify both
worktrees without replacing that immutable dependency in either manifest:

The pinned SDK revision is `polymorfa/sdks@8803f0554314214519971cb3306e0c0efe21cb76`.
The pinned SDK revision is `polymorfa/sdks@94bfd09825817f842434e206e6a77adb5cc0c414`
(the Calls client ships in the same package as `@polymorfa/sdk/calls`).
Its coverage ledger is reconciled against the API contracts at
`polymorfa/polymorfa@aca849cda44ad8582d7ae87489404d2483173a53` (Platform
snapshot SHA-256
Expand Down
Loading
Loading