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>
16 KiB
name, description
| name | description |
|---|---|
| packetdial | 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 targetspbx.packetdial.com/ns-api/v2and the key works there directly. They are the same NetSapiens platform behind two white-label hostnames — if a future endpoint 403s onpbx., override withPACKETDIAL_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.
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 "$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)
# 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>).
# 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 "$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.
# 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).
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):
{
"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)
create-domain-> dial plan auto-generatescreate-userper extensioncreate-phoneper SIP device (MAC-provisioned)create-didto attach DIDs and route them to users- Log the work back to the Syncro ticket
Notes
- This is the LIVE production reseller PBX. A bad
create-domainordelete-useraffects real customers — confirm the target domain first with a read command before any write. - CDR queries can be large; always pass
--start/--endand a--limit. - Reference detail (auth shapes, full endpoint inventory) lives in
references/api.md.