Files
claudetools/.claude/skills/packetdial/SKILL.md
Mike Swanson d1d1302d55 packetdial: add onboard-domain wrapper (GUI Add-a-Domain -> 3-call API flow)
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>
2026-06-22 09:34:48 -07:00

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

Credentials — PROVISIONED + LIVE-VERIFIED (2026-06-22)

The reseller API key is vaulted at msp-tools/oitvoip.sops.yamlcredentials.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.

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.10239 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 /domainsPOST .../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)

  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.