onboard-domain runs POST /domains -> addresses/validate (gen E911 pidflo) -> addresses/create from one JSON body (domain fields + optional `emergency` block), gated --confirm. Reverse- engineered from the OITVOIP wizard screenshots; live-created the real client domain vwp.91912.service (Valley Wide Plastering) + E911 address, and proved the wrapper with a throwaway create->delete (no leftovers, vwp intact). Documented GUI->API mapping + the two manual gaps (voicemail user-defaults, email-send-from-address pending the packetdial.com mailbox) + the domain-type "no"-on-create quirk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
259 lines
16 KiB
Markdown
259 lines
16 KiB
Markdown
---
|
|
name: packetdial
|
|
description: "Manage the ACG PacketDial/OITVOIP hosted VoIP via the NetSapiens API v2 (pbx.packetdial.com). List/inspect domains, users, devices, DIDs, call queues, time frames, sites, auto-attendants, contacts, and domain billing/limits; pull CDRs; provision domains/users/SIP/numbers (writes gated --confirm; read-only default). Reseller-scoped key live + vaulted. Triggers: packetdial, oitvoip, netsapiens, voip domain/user/extension, call queue, auto attendant, provision phone, add did, CDR. Live production PBX."
|
|
|
|
---
|
|
|
|
# PacketDial / NetSapiens (OITVOIP) Skill
|
|
|
|
Standalone CLI client for the NetSapiens SNAPsolution **API v2** that backs
|
|
ACG's hosted-VoIP offering through OITVOIP / PacketDial. Read-only by default;
|
|
every write (create / update / delete) is gated behind `--confirm`.
|
|
|
|
## The two hostnames (important)
|
|
|
|
| Host | What it is | API? |
|
|
|---|---|---|
|
|
| `voip.packetdial.com` | Customer-facing white-label portal / UC & fax dashboard (e.g. Cascades fax account **28598**). Login-gated UI. | **No** |
|
|
| `pbx.packetdial.com` | Reseller PBX platform — NetSapiens v44.4. | **Yes** — this skill targets it |
|
|
|
|
- API base: `https://pbx.packetdial.com/ns-api/v2`
|
|
- Token endpoint: `https://pbx.packetdial.com/ns-api/v2/tokens`
|
|
- Live OpenAPI spec: `https://pbx.packetdial.com/ns-api/webroot/openapi/openapi.json`
|
|
- Live Swagger UI: `https://pbx.packetdial.com/ns-api/openapi`
|
|
- Vendor docs: https://docs.ns-api.com/ (login) and https://voipdocs.io/oitvoip-access-platform-apis
|
|
|
|
## Credentials — PROVISIONED + LIVE-VERIFIED (2026-06-22)
|
|
|
|
The reseller API key is vaulted at **`msp-tools/oitvoip.sops.yaml`** →
|
|
`credentials.api_key` (static `nsr_` bearer key). Live-verified via `ns.py whoami`:
|
|
key-id `nsr_hSGUB5Wo`, **user-scope Reseller** (`91912.service`), `user/domain: *`
|
|
(sees every domain under the ACG reseller territory), **`readonly: no`** (read-write —
|
|
so the `--confirm` gate on writes matters), `can-create-keys: no`.
|
|
|
|
The client never hardcodes secrets. Resolution order: `PACKETDIAL_API_KEY` env →
|
|
`PACKETDIAL_CLIENT_ID`+friends env → vault `credentials.api_key` → vault OAuth fields
|
|
(`client_id`/`client_secret`/`username`/`password`). Env overrides exist for quick
|
|
testing without touching the vault.
|
|
|
|
> The key's own docs reference `https://api.ucaasnetwork.com/ns-api/...`; the skill
|
|
> targets `pbx.packetdial.com/ns-api/v2` and the key works there directly. They are the
|
|
> same NetSapiens platform behind two white-label hostnames — if a future endpoint 403s
|
|
> on `pbx.`, override with `PACKETDIAL_API_BASE_URL=https://api.ucaasnetwork.com/ns-api/v2`.
|
|
|
|
## Running the CLI
|
|
|
|
This machine's Python launcher is `py` (per identity.json); `python` / `python3`
|
|
also work. Run from the scripts dir so the two modules resolve.
|
|
|
|
```bash
|
|
cd C:/claudetools/.claude/skills/packetdial/scripts
|
|
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py status # API version + authenticated key identity
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py domains # list all domains
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py domain <domain> # one domain's config
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py users <domain> # users / extensions in a domain
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py user <domain> <user>
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py phones <domain> # SIP devices registered in a domain
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py dids <domain> # phone numbers (DIDs) on a domain
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py devices <domain> <user>
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py callqueues <domain> # ACD call queues (agents, live queued count)
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py timeframes <domain> # time-based routing (business hours / holidays)
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py sites <domain> # multi-site definitions
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py autoattendants <domain> # IVR menus
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py contacts <domain> # shared address book
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py billing <domain> # limits + current usage counts
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py addresses <domain> # E911 addresses
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py smsnumbers <domain> # SMS numbers (needs SMS-provisioned domain)
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py blocked-numbers <domain># blocked-number filters
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py moh <domain> # music-on-hold entries
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py dialrules <domain> <dialplan>
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py recording <domain> <callid>
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py transcriptions <domain>
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py cdrs --domain <domain> --start 2026-06-01 --end 2026-06-02
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py resellers
|
|
```
|
|
|
|
All read wrappers above are **live-verified** against the production reseller key
|
|
(2026-06-22).
|
|
|
|
## Writes (gated)
|
|
|
|
Every mutating command prints a `[DRY RUN]` line and exits non-zero unless you
|
|
pass `--confirm`. Bodies are raw JSON matching the NetSapiens v2 schema.
|
|
|
|
```bash
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py create-domain --body '{"domain":"acme","description":"Acme Inc"}' --confirm
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py create-user acme --body '{"user":"101","name-first-name":"Jane"}' --confirm
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py create-phone acme --body '{...}' --confirm
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py create-did acme --body '{"phonenumber":"15205551234"}' --confirm
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py update-user acme 101 --body '{"name-last-name":"Doe"}' --confirm
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py delete-user acme 101 --confirm
|
|
```
|
|
|
|
### Call queue edits (live-verified create→update→delete, 2026-06-22)
|
|
|
|
```bash
|
|
# create — body MUST include synchronous + callqueue (the queue extension/id)
|
|
ns.py create-callqueue acme --body '{"synchronous":"yes","callqueue":"8000","description":"Support"}' --confirm
|
|
ns.py update-callqueue acme 8000 --body '{"description":"Support (2nd shift)","callqueue-dispatch-type":"Round-robin"}' --confirm
|
|
ns.py delete-callqueue acme 8000 --confirm
|
|
# agents on a queue
|
|
ns.py add-agent acme 8000 --body '{"callqueue-agent-id":"101@acme"}' --confirm
|
|
ns.py update-agent acme 8000 101@acme --body '{"callqueue-agent-dispatch-order-ordinal":1}' --confirm
|
|
ns.py remove-agent acme 8000 101@acme --confirm
|
|
```
|
|
|
|
### Time frame edits (live-verified create→update→delete, 2026-06-22)
|
|
|
|
Timeframes are **body-discriminated** — same path, the server picks the operation from the
|
|
body. The id arg is the `timeframe-id` (from `ns.py timeframes <domain>`).
|
|
|
|
```bash
|
|
# create: timeframe-type = always | days-of-week | specific-dates | holiday | custom
|
|
ns.py create-timeframe acme --body '{"synchronous":"yes","timeframe-name":"After Hours","timeframe-type":"always"}' --confirm
|
|
# update: the body carries the type-specific array OR a type-convert:
|
|
ns.py update-timeframe acme <timeframe-id> --body '{"update-only":"yes","timeframe-days-of-week-array":[...]}' --confirm
|
|
ns.py update-timeframe acme <timeframe-id> --body '{"type":"days-of-week"}' --confirm # convert type
|
|
# delete: no body = delete the whole timeframe; {"child_id":N} = delete one entry
|
|
ns.py delete-timeframe acme <timeframe-id> --confirm
|
|
```
|
|
|
|
**Gotcha:** a `days-of-week` (or other entry-bearing) timeframe rejects creation without its
|
|
entry array (`HTTP 400 "...should have at most 1 days of week entry"`) — create `always` first
|
|
and convert, or include the array in the create body. The **`arizonacomputerguru` domain is the
|
|
sanctioned test bed** (not in production use) for exercising these writes safely.
|
|
|
|
## Raw escape hatch (any of the 239 v2 paths)
|
|
|
|
The named commands cover the common surface; for anything else, hit the path
|
|
directly. Non-GET methods still require `--confirm`.
|
|
|
|
```bash
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py raw GET domains/acme/users/101/answerrules
|
|
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py raw POST domains/acme/users --body '{...}' --confirm
|
|
```
|
|
|
|
### Additional resource edits (gated)
|
|
|
|
All `--confirm`-gated. Verification legend: **[V]** lifecycle-verified on the
|
|
`arizonacomputerguru` test domain; **[P]** plumbed per the OpenAPI spec but not
|
|
lifecycle-verified (test domain lacks the feature / needs a special body) — exercise
|
|
on a real domain or via `raw` and confirm.
|
|
|
|
```bash
|
|
# DIDs — complete CRUD (create-did exists; add update/delete) [P]
|
|
ns.py update-did <domain> <number> --body '{"dial-rule-translation-destination-user":"101"}' --confirm
|
|
ns.py delete-did <domain> <number> --confirm
|
|
# per-user SIP devices [P]
|
|
ns.py create-device <domain> <user> --body '{"device":"sip:101a@<domain>"}' --confirm
|
|
ns.py update-device <domain> <user> <device> --body '{...}' --confirm
|
|
ns.py delete-device <domain> <user> <device> --confirm
|
|
# contacts (domain address book) [V]
|
|
ns.py create-contact <domain> --body '{"name-first-name":"Jane","name-last-name":"Doe","email":"j@x.com"}' --confirm
|
|
ns.py update-contact <domain> <unique-id> --body '{...}' --confirm # id = the contact's "unique-id"
|
|
ns.py delete-contact <domain> <unique-id> --confirm
|
|
# E911 addresses [P] (create needs address-formatted-pidflo -> POST .../addresses/validate first via raw)
|
|
ns.py create-address <domain> --body '{...}' --confirm
|
|
ns.py update-address <domain> <emergency-address-id> --body '{...}' --confirm
|
|
ns.py delete-address <domain> <emergency-address-id> --confirm
|
|
# sites / auto-attendants [P]
|
|
ns.py create-site <domain> --body '{...}' --confirm
|
|
ns.py update-site <domain> <site> --body '{...}' --confirm
|
|
ns.py create-autoattendant <domain> --body '{"attendant-name":"Main","user":"3000"}' --confirm
|
|
# SMS numbers [P] (needs SMS-provisioned domain)
|
|
ns.py create-smsnumber <domain> --body '{"number":"1520...","application":"..."}' --confirm
|
|
ns.py update-smsnumber <domain> <smsnumber> --body '{...}' --confirm
|
|
ns.py delete-smsnumber <domain> <smsnumber> --confirm
|
|
# blocked-number filters [P] (on the empty test domain add 202'd but didn't persist; verify on a live domain)
|
|
ns.py block-numbers <domain> --body '{"phone-numbers-to-reject":["15205550199"]}' --confirm
|
|
ns.py unblock-numbers <domain> --body '{"phone-numbers-to-reject":["15205550199"]}' --confirm
|
|
# music on hold (TTS create + delete; file UPLOAD is multipart -> use raw) [P]
|
|
ns.py create-moh <domain> --body '{"synchronous":"yes","script":"Thanks for holding"}' --confirm
|
|
ns.py delete-moh <domain> <index> --confirm
|
|
```
|
|
|
|
## API capability map (RTFM — spec v44.4.10, mapped 2026-06-22)
|
|
|
|
The live OpenAPI spec is **NetSapiens API v2 v44.4.10** — **239 paths / 354 operations**
|
|
(GET 139 · POST 87 · PUT 68 · DELETE 50 · PATCH 10). Almost everything is nested under
|
|
`/domains/{domain}/...`. What the platform exposes, and how this skill surfaces it:
|
|
|
|
| Resource (`/domains/{domain}/…`) | Ops | Skill wrapper | Notes |
|
|
|---|---|---|---|
|
|
| `users` (+ `/devices`, answerrules, voicemail, contacts under a user) | R/W, biggest surface | `users`, `user`, `devices`, `create/update/delete-user` | per-user devices live at `users/{u}/devices` |
|
|
| `phonenumbers` (DIDs + dial-rule routing) | R/W | `dids`, `create-did`, `update-did`, `delete-did` | CRUD wrapped [P] |
|
|
| `phones` (SIP devices, MAC provisioning) | R/W | `phones`, `create-phone`; per-user `create/update/delete-device` | |
|
|
| `callqueues` (ACD: agents, dispatch, live stats) | R/W + PATCH | `callqueues` + `create/update/delete-callqueue`, `add/update/remove-agent` | full CRUD wrapped [V]; agent login/logout PATCH via `raw` |
|
|
| `timeframes` (business-hours / holiday schedules) | R/W | `timeframes` + `create/update/delete-timeframe` | body-discriminated; CRUD wrapped [V] |
|
|
| `autoattendants` (IVR menus) | R/W | `autoattendants`, `create-autoattendant` | per-prompt update via `raw` [P] |
|
|
| `sites` (multi-site) | R/W | `sites`, `create-site`, `update-site` | [P] |
|
|
| `contacts` (domain address book) | R/W | `contacts`, `create/update/delete-contact` | full CRUD wrapped [V]; id = `unique-id` |
|
|
| `addresses` (E911) | R/W | `addresses`, `create/update/delete-address` | create needs `validate` for pidflo [P] |
|
|
| `smsnumbers` (SMS) | R/W | `smsnumbers`, `create/update/delete-smsnumber` | needs SMS-provisioned domain [P] |
|
|
| `number-filters` (blocked numbers) | R/W | `blocked-numbers`, `block-numbers`, `unblock-numbers` | [P] (test-domain add didn't persist) |
|
|
| `moh` (music on hold) | R/W | `moh`, `create-moh` (TTS), `delete-moh` | file upload (multipart) via `raw` [P] |
|
|
| `dialplans` · `msg` · `connections` | R/W | `dialrules` (read); writes via `raw` | |
|
|
| `cdrs` · `calls` · `recordings` · `transcriptions` · `queuedcall` | read | `cdrs`, `recording`, `transcriptions`; rest via `raw` | call data |
|
|
| `billing` (limits + current counts) | read | `billing` | per-domain quota snapshot |
|
|
| Top-level: `domains`, `resellers`, `apikeys`, `subscriptions`, `routes`, `configurations`, `certificates`, `templates`, `jwt`, `tokens` | R/W | `domains`/`domain`/`resellers`; rest via `raw` | platform/admin |
|
|
|
|
**Everything not wrapped is reachable via `raw <METHOD> <path>`** (non-GET gated by
|
|
`--confirm`). The local spec copy used for this map: `.claude/tmp/ns-openapi.json` (refetch
|
|
from `pbx.packetdial.com/ns-api/webroot/openapi/openapi.json`).
|
|
|
|
## Onboard a new client domain (`onboard-domain`)
|
|
|
|
One gated command that mirrors the OITVOIP "Add a Domain" GUI wizard (Basic + Defaults +
|
|
Limitations + Emergency) and runs the **3-call flow**: `POST /domains` → `POST
|
|
.../addresses/validate` (generates the E911 `pidflo`) → `POST .../addresses`. Body = the
|
|
`POST /domains` fields **plus an optional `emergency` sub-object** (the E911 address). Verified
|
|
end-to-end on the production reseller (created vwp.91912.service for real; throwaway
|
|
create→delete to prove the wrapper).
|
|
|
|
```bash
|
|
ns.py onboard-domain --body-file new-client.json --confirm # --body '<json>' also works
|
|
```
|
|
`new-client.json` (the vwp example — note `domain` is the FULL name incl. the reseller suffix):
|
|
```json
|
|
{
|
|
"domain": "vwp.91912.service", "reseller": "91912.service",
|
|
"description": "Valley Wide Plastering", "domain-type": "Standard",
|
|
"dial-policy": "US and Canada", "time-zone": "America/Phoenix", "area-code": 480,
|
|
"caller-id-name": "Valley Wide Plastering", "caller-id-number": 4807059500,
|
|
"caller-id-number-emergency": 4807059500,
|
|
"single-sign-on-enabled": "no", "music-on-hold-randomized-enabled": "no",
|
|
"emergency": {
|
|
"address-name": "Valley Wide Plastering", "caller-name": "ValleyWidePlastering",
|
|
"address-line-1": "301 N 56TH ST", "address-country-abbreviation": "US",
|
|
"address-state-province-abbreviation": "AZ", "address-city": "CHANDLER",
|
|
"address-postal-code": "85226", "address-location-description": "Main"
|
|
}
|
|
}
|
|
```
|
|
GUI→API mapping: Name+suffix → `domain`; Dial Permission → `dial-policy`; Limitations →
|
|
`limits-max-*` (omit for "unlimited"); Emergency tab → the `emergency` block. Omitting `dial-plan`
|
|
auto-generates one named after the domain. **Two known gaps vs. the GUI** (still manual): the
|
|
Voicemail user-defaults (Enable/Transcription/Message) aren't in `POST /domains`, and
|
|
`email-send-from-address` (e.g. `voicemail@packetdial.com`) is left blank until that mailbox
|
|
exists — set it after with `raw PUT domains/<domain> --body '{"email-send-from-address":"..."}'`.
|
|
`domain-type` occasionally stores as `"no"` on create — re-`PUT` it if so. Undo a bad onboard:
|
|
`raw DELETE domains/<domain> --confirm`.
|
|
|
|
## Standard provisioning flow (new customer)
|
|
|
|
1. `create-domain` -> dial plan auto-generates
|
|
2. `create-user` per extension
|
|
3. `create-phone` per SIP device (MAC-provisioned)
|
|
4. `create-did` to attach DIDs and route them to users
|
|
5. Log the work back to the Syncro ticket
|
|
|
|
## Notes
|
|
|
|
- This is the LIVE production reseller PBX. A bad `create-domain` or
|
|
`delete-user` affects real customers — confirm the target domain first with a
|
|
read command before any write.
|
|
- CDR queries can be large; always pass `--start`/`--end` and a `--limit`.
|
|
- Reference detail (auth shapes, full endpoint inventory) lives in
|
|
`references/api.md`.
|