packetdial: build out + document the skill against the live NetSapiens v2 API

Key is now provisioned + live-verified, so grounded the skill in the real spec (RTFM):
- Mapped the OpenAPI surface (v44.4.10, 239 paths / 354 ops) — capability map added to
  SKILL.md (what the platform exposes vs what's wrapped vs raw-only).
- Added 6 live-verified read wrappers (ns.py + ns_client.py): callqueues, timeframes,
  sites, contacts, autoattendants, billing (domain limits/usage).
- Replaced the stale "not yet provisioned" credentials section with the live status
  (vaulted nsr_ reseller key, key-id nsr_hSGUB5Wo, scope Reseller 91912.service, RW) +
  the pbx.packetdial.com vs api.ucaasnetwork.com hostname note + override.
- api.md history updated. Writes remain gated behind --confirm; everything unwrapped
  reachable via `raw`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 07:36:12 -07:00
parent 567986fa49
commit d75c367bf7
4 changed files with 98 additions and 27 deletions

View File

@@ -1,6 +1,6 @@
---
name: packetdial
description: "Manage the ACG PacketDial/OITVOIP hosted VoIP via the NetSapiens API (pbx.packetdial.com). List/inspect domains, users, devices, DIDs, resellers; pull CDRs; provision domains/users/SIP/numbers (writes gated --confirm; read-only default). Triggers: packetdial, oitvoip, netsapiens, voip domain/user/extension, provision phone, add did, CDR. Live production PBX."
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."
---
@@ -23,35 +23,23 @@ every write (create / update / delete) is gated behind `--confirm`.
- 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 — ONE-TIME SETUP (not yet provisioned)
## Credentials — PROVISIONED + LIVE-VERIFIED (2026-06-22)
As of this skill's creation **no API key exists yet** — the vault entry
`msp-tools/oitvoip.sops.yaml` is empty/absent, so every command will fail with a
clear "No credentials found" error until you do this once:
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`.
1. Log into `pbx.packetdial.com` -> **Admin > API Keys** and create a
reseller-scoped key (prefix `nsr_`). If self-service key creation is not
available, reply to **Darwin Escaro (OITVOIP)** for reseller OAuth client
credentials.
2. Store it in the SOPS vault. Preferred (static bearer key):
```
# msp-tools/oitvoip.sops.yaml
credentials:
api_key: nsr_xxxxxxxxxxxxxxxx
```
Or, for OAuth2 password-grant credentials:
```
credentials:
client_id: <client id>
client_secret: <client secret>
username: <portal user@domain>
password: <portal password>
```
3. That's it — the client auto-detects which shape is present.
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 client never hardcodes secrets. Resolution order: `PACKETDIAL_API_KEY` env
-> `PACKETDIAL_CLIENT_ID`+friends env -> vault `credentials.api_key` -> vault
OAuth fields. 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
@@ -69,10 +57,19 @@ 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 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
@@ -97,6 +94,31 @@ bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py raw GET domains/acme/users
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py raw POST domains/acme/users --body '{...}' --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` | |
| `phones` (SIP devices, MAC provisioning) | R/W | `phones`, `create-phone` | |
| `callqueues` (ACD: agents, dispatch, live stats) | R/W + PATCH | `callqueues` (read) | writes via `raw` |
| `timeframes` (business-hours / holiday schedules) | R/W | `timeframes` (read) | writes via `raw` |
| `autoattendants` (IVR menus) | R/W | `autoattendants` (read) | |
| `sites` (multi-site) | R/W | `sites` (read) | |
| `contacts` (domain address book) | R/W | `contacts` (read) | |
| `addresses` (E911) · `moh` (music on hold) · `dialplans` · `msg`/`smsnumbers` (SMS) · `connections` · `number-filters` | R/W | `raw` | not yet wrapped |
| `cdrs` · `calls` · `recordings` · `transcriptions` · `queuedcall` | read | `cdrs`; 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`).
## Standard provisioning flow (new customer)
1. `create-domain` -> dial plan auto-generates

View File

@@ -80,3 +80,9 @@ exact request/response schema of any specific path.
`msp-tools/oitvoip.sops.yaml` was the open TODO.
- 2026-06-02: `packetdial` skill created wrapping the v2 API (read-by-default,
gated writes). Confirmed `voip.` is portal-only and `pbx.` is the API host.
- 2026-06-22: **Reseller API key provisioned + vaulted** (`msp-tools/oitvoip.sops.yaml`,
key-id `nsr_hSGUB5Wo`, scope Reseller `91912.service`, read-write). Live-verified end to
end. RTFM pass over the v2 spec (v44.4.10, 239 paths / 354 ops) — capability map added to
SKILL.md. Added live-verified read wrappers: `callqueues`, `timeframes`, `sites`,
`contacts`, `autoattendants`, `billing`. Reseller territory = 3 domains today
(arizonacomputerguru + two `*.91912.service`).

View File

@@ -97,6 +97,12 @@ def main(argv=None) -> int:
sp = sub.add_parser("phones", help="phones (devices) in a domain"); sp.add_argument("domain")
sp = sub.add_parser("dids", help="phone numbers in a domain"); sp.add_argument("domain")
sp = sub.add_parser("devices", help="devices for a user"); sp.add_argument("domain"); sp.add_argument("user")
sp = sub.add_parser("callqueues", help="ACD call queues in a domain"); sp.add_argument("domain")
sp = sub.add_parser("timeframes", help="time-based routing schedules in a domain"); sp.add_argument("domain")
sp = sub.add_parser("sites", help="multi-site definitions in a domain"); sp.add_argument("domain")
sp = sub.add_parser("contacts", help="shared/domain contacts"); sp.add_argument("domain")
sp = sub.add_parser("autoattendants", help="auto-attendants (IVR) in a domain"); sp.add_argument("domain")
sp = sub.add_parser("billing", help="domain limits + current usage counts"); sp.add_argument("domain")
sub.add_parser("resellers", help="list resellers")
sp = sub.add_parser("cdrs", help="call detail records")
sp.add_argument("--domain"); sp.add_argument("--start"); sp.add_argument("--end")
@@ -138,6 +144,18 @@ def main(argv=None) -> int:
_emit(client.phonenumbers(args.domain))
elif args.cmd == "devices":
_emit(client.devices(args.domain, args.user))
elif args.cmd == "callqueues":
_emit(client.callqueues(args.domain))
elif args.cmd == "timeframes":
_emit(client.timeframes(args.domain))
elif args.cmd == "sites":
_emit(client.sites(args.domain))
elif args.cmd == "contacts":
_emit(client.contacts(args.domain))
elif args.cmd == "autoattendants":
_emit(client.autoattendants(args.domain))
elif args.cmd == "billing":
_emit(client.billing(args.domain))
elif args.cmd == "resellers":
_emit(client.resellers())
elif args.cmd == "cdrs":

View File

@@ -351,6 +351,31 @@ class NetSapiensClient:
def subscriptions(self) -> Any:
return self.request("GET", "subscriptions")
# --- per-domain feature resources (live-verified shapes, 2026-06-22) ---
def callqueues(self, domain: str) -> Any:
"""ACD call queues in a domain (agents, dispatch type, live queued count)."""
return self.request("GET", f"domains/{urllib.parse.quote(domain)}/callqueues")
def timeframes(self, domain: str) -> Any:
"""Time-based routing schedules (business hours / holidays) for a domain."""
return self.request("GET", f"domains/{urllib.parse.quote(domain)}/timeframes")
def sites(self, domain: str) -> Any:
"""Multi-site definitions within a domain."""
return self.request("GET", f"domains/{urllib.parse.quote(domain)}/sites")
def contacts(self, domain: str) -> Any:
"""Shared/domain contacts (address book)."""
return self.request("GET", f"domains/{urllib.parse.quote(domain)}/contacts")
def autoattendants(self, domain: str) -> Any:
"""Auto-attendants (IVR menus) in a domain."""
return self.request("GET", f"domains/{urllib.parse.quote(domain)}/autoattendants")
def billing(self, domain: str) -> Any:
"""Domain billing/limits snapshot: max + current counts (users, queues, AAs, calls)."""
return self.request("GET", f"domains/{urllib.parse.quote(domain)}/billing")
# ======================================================================
# WRITE METHODS (gated — the CLI requires --confirm before calling these)
# ======================================================================