Skip to content

Commit 92a5327

Browse files
authored
Merge pull request #5 from Bandwidth/feature/build-cli-improvements
Build account awareness across the CLI
2 parents c1406ec + 670b327 commit 92a5327

20 files changed

Lines changed: 498 additions & 80 deletions

File tree

AGENTS.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,16 @@ band auth profiles # list all stored profiles
4646
band auth use admin # switch the active profile
4747
```
4848

49-
If a credential's `acct_scope` is "All" (system-scope), it can access any account but the CLI will show guidance about passing `--account-id`. Always pass `--account-id` explicitly with system-scope credentials.
49+
If your credentials are not bound to a specific account, the CLI will prompt you to pass `--account-id` explicitly. Always pass `--account-id` on every command in that case.
50+
51+
### Account Type and Capabilities
52+
53+
`band auth status --plain` returns structured JSON describing what the active account can do. The two fields agents care about most:
54+
55+
- **`build: true`** — this is a Bandwidth Build account. Voice-only, credit-based. Messaging, number ordering, sub-accounts, VCPs, 10DLC, and toll-free verification are not available; commands targeting those exit with code 4 and a clear message pointing at the upgrade path.
56+
- **`capabilities`** — a derived map (`voice`, `messaging`, `numbers`, `vcp`, `campaign_management`, `tfv`, `app_management`) flipping `true`/`false` based on the credential's roles. Use this to gate work locally rather than discovering limits via 4xx errors.
57+
58+
Branch on these before attempting feature-gated work. The CLI also fails fast at the moment you try a restricted command, but checking capabilities up front avoids wasted setup.
5059

5160
### Account Hint
5261

@@ -236,7 +245,7 @@ band auth status # confirm
236245

237246
After calling `band account register`, stop and tell the user they need to complete setup in their browser. Do not attempt to poll or wait — the next CLI step (`band auth login`) requires credentials that are only available after the human finishes the browser flow.
238247

239-
**After login, the account already has a voice app and a phone number.** Build accounts ship with both pre-provisioned. Run `band app list --plain` and `band number list --plain` to discover them — do **not** call `app create` or `number order` on a fresh Build account, you already have what you need to make a call.
248+
**After login, the account already has a voice app and a phone number.** Build accounts ship with both pre-provisioned. Run `band app list --plain` to discover the voice app — do **not** call `app create` or `number order` on a fresh Build account, you already have what you need to make a call. (`band number list` doesn't work on Build yet; the pre-provisioned number is reachable via the account portal and already wired to the default voice app.)
240249

241250
---
242251

@@ -460,10 +469,11 @@ band number list --plain # → all numbers on account
460469
|------|---------|------|
461470
| 0 | Success | Command completed |
462471
| 1 | General error | Missing flags, invalid input, unexpected failures |
463-
| 2 | Auth/permission error | 401/403 — bad credentials, token expired, or credential lacks a required role (e.g., VCP, Campaign Management, TFV). An agent's branching logic should treat exit code 2 as "try a different path or escalate" rather than only "re-authenticate" |
472+
| 2 | Auth error | 401 — bad credentials or token expired. Re-authenticate. |
464473
| 3 | Not found | 404 — resource doesn't exist |
465-
| 4 | Conflict | 409 — duplicate resource or feature not enabled |
474+
| 4 | Conflict / feature limit / payment required | 402, 409, or 403 due to a plan/role gate (e.g., Build account trying to message, missing VCP/Campaign Management/TFV role, out of credits, declined card). Non-retryable — stop and escalate to the user. |
466475
| 5 | Timeout | `--wait` exceeded `--timeout` |
476+
| 7 | Rate limited / quota exceeded | 429 or concurrent-resource ceiling. Back off and retry. |
467477

468478
**Use exit codes for control flow, not string parsing.**
469479

@@ -475,9 +485,13 @@ band number list --plain # → all numbers on account
475485
| "account ID not set" | 1 | No active account | `band auth switch <id>` or pass `--account-id` |
476486
| "credential verification failed" | 2 | Bad client ID or secret | Check credentials |
477487
| "API error 401" | 2 | Token expired or invalid | Re-run `band auth login` |
478-
| "API error 403" | 2 | Credential lacks permission | Check roles — VCP role for UP voice, Campaign Management role for `tendlc`, TFV role for `tfv`. Could also mean the account doesn't have the Registration Center feature enabled. Escalate to account manager if unclear |
488+
| "...isn't available on Bandwidth Build accounts" | 4 | Build account hit a feature outside its plan (messaging, numbers, VCPs, 10DLC, TFV) | Stop and tell the user — non-retryable. Upgrade path: https://www.bandwidth.com/talk-to-an-expert/ |
489+
| "credential lacks the X role" | 4 | Credential lacks a role on a non-Build account | Escalate to the user's Bandwidth account manager to assign the role |
490+
| "API error 402" / "Insufficient credits" | 4 | Out of credits, declined card, or no payment method on file | Stop and tell the user — non-retryable; they need to top up or fix billing |
491+
| "API error 403" | 2 | True auth failure (token expired or invalid). Feature/role 403s now surface as exit 4 with a tailored message — see the rows above. | Re-run `band auth login` |
479492
| "API error 404" | 3 | Resource doesn't exist | Verify the ID; check you're on the right account |
480493
| "API error 409" | 4 | Conflict / duplicate | Use `--if-not-exists`; or feature not enabled on account |
494+
| "API error 429" | 7 | Rate limited or quota exceeded | Back off and retry — eventually retryable |
481495
| "HTTP voice feature is required" | 4 | Legacy voice not available | Try VCP path (UP account) or contact support |
482496
| "required flag not set" | 1 | Missing a required flag | Check `--help` for required flags |
483497

@@ -685,6 +699,7 @@ band message send --from +19195551234 --to +15559876543 --app-id abc-123 --text
685699

686700
## Limitations
687701

702+
- **Bandwidth Build accounts are voice-only.** Detect via `band auth status --plain` (`build: true`). On a Build account, only voice and app-management commands work — `message send`, `number search`/`order`, `vcp *`, `subaccount *`, `tendlc *`, `tfv *` all exit 4 with a Build-aware message and an upgrade link. Pre-provisioned voice app and number ship with the account; `band number list` doesn't work yet (the number is reachable via the account portal). Build also has runtime limits not surfaced in `auth status` — verified-number-only outbound on Free Trial, a 30-min cap per call, a 5-concurrent-call ceiling. See [dev.bandwidth.com](https://dev.bandwidth.com/docs/voice/programmable-voice/build-free-trial) for current pricing and limits; treat any 402 (exit 4) as "out of credits, escalate" and any 429 (exit 7) as "back off and retry."
688703
- **No real-time call control.** The CLI can initiate calls and query state, but cannot receive or respond to mid-call callbacks. Dynamic call control requires a separate callback-handling server.
689704
- **No message delivery confirmation.** The CLI verifies your setup is correct before sending (app-location link, callback URL, campaign), but it cannot confirm whether a message was actually delivered. Delivery status (`message-delivered`, `message-failed`) arrives via webhooks on your callback server. The CLI's `message get` and `message list` return metadata only — not delivery status.
690705
- **No message content retrieval.** Bandwidth does not store message bodies. After sending, the message text is gone forever. `message get` and `message list` return timestamps, direction, and segment counts only.

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,9 @@ Then complete setup in your browser:
9797

9898
Once your credentials are ready, run `band auth login` and you're off.
9999

100-
**What you get:** Every Build account ships with a voice application and a phone number already provisioned — no need to create them yourself. After login, run `band app list` and `band number list` to see them, and skip straight to [make a call](#make-a-call).
100+
**What you get:** Every Build account ships with a voice application and a phone number already provisioned. Run `band auth status` to confirm your account type and capabilities, then `band app list` to see your pre-provisioned voice app — then you're ready to [make a call](#make-a-call). (Your pre-provisioned number is also visible in the Bandwidth App.)
101101

102-
**Important note**: a Bandwidth Build account is for our Voice API **only**. Usage limits and terms and conditions apply. If you would like to send
103-
messages, order numbers, and more, you will need a full Bandwidth Account. [Talk to an expert](https://www.bandwidth.com/talk-to-an-expert/) to start
104-
your onboarding process today.
102+
**Build is voice-only.** Messaging, number ordering, sub-accounts, VCPs, 10DLC, and toll-free verification all require a full Bandwidth account. If you try one of those commands on a Build account, the CLI fails fast (exit code 4) and points you at the upgrade path. For current Build pricing, credit costs, and trial limits, see [dev.bandwidth.com](https://dev.bandwidth.com/docs/voice/programmable-voice/build-free-trial). [Talk to an expert](https://www.bandwidth.com/talk-to-an-expert/) when you're ready to upgrade.
105103

106104
---
107105

@@ -528,8 +526,9 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
528526
| 1 | Bad input or unexpected error |
529527
| 2 | Authentication or permission problem |
530528
| 3 | Resource not found |
531-
| 4 | Conflict (duplicate resource or missing feature) |
529+
| 4 | Conflict, feature limit, or payment required (duplicate resource, missing role, plan limit, out of credits) |
532530
| 5 | Timed out waiting |
531+
| 7 | Rate limited or quota exceeded (back off and retry) |
533532

534533
---
535534

@@ -564,7 +563,7 @@ This CLI is agent-native — not just "agent-compatible." The design principles:
564563
- **`--plain` everywhere.** Flat, stable JSON output. Auto-enabled when stdout is piped, so agents in pipelines don't need the flag.
565564
- **`--if-not-exists` for idempotency.** Create commands can be retried safely without duplicating resources.
566565
- **`--wait` for async operations.** Agents can't poll. `--wait` blocks until the number is active, the call completes, or the transcription is ready.
567-
- **Structured exit codes.** 0 success, 2 auth, 3 not found, 4 conflict, 5 timeout. Use exit codes for control flow, not string parsing.
566+
- **Structured exit codes.** 0 success, 2 auth, 3 not found, 4 conflict/feature limit, 5 timeout, 7 rate limit. Use exit codes for control flow, not string parsing.
568567
- **Env-var-driven auth.** `BW_CLIENT_ID` + `BW_CLIENT_SECRET` — no interactive prompts required.
569568

570569
For the full agent reference — dependency chains, provisioning workflows, error patterns, and copy-pasteable scripts — see [AGENTS.md](AGENTS.md).

cmd/auth/auth_test.go

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,83 @@ func TestTokenURLForEnvironment(t *testing.T) {
4343
}
4444
}
4545

46+
func TestCapabilities(t *testing.T) {
47+
tests := []struct {
48+
name string
49+
roles []string
50+
want map[string]bool
51+
}{
52+
{
53+
name: "build account roles",
54+
roles: []string{"HTTP Application Management", "HttpVoice", "brtcAccessRole"},
55+
want: map[string]bool{
56+
"voice": true,
57+
"app_management": true,
58+
"messaging": false,
59+
"numbers": false,
60+
"vcp": false,
61+
"campaign_management": false,
62+
"tfv": false,
63+
},
64+
},
65+
{
66+
name: "no roles",
67+
roles: nil,
68+
want: map[string]bool{
69+
"voice": false,
70+
"app_management": false,
71+
"messaging": false,
72+
"numbers": false,
73+
"vcp": false,
74+
"campaign_management": false,
75+
"tfv": false,
76+
},
77+
},
78+
{
79+
name: "messaging and voice",
80+
roles: []string{"Messaging", "HttpVoice"},
81+
want: map[string]bool{
82+
"voice": true,
83+
"app_management": false,
84+
"messaging": true,
85+
"numbers": false,
86+
"vcp": false,
87+
"campaign_management": false,
88+
"tfv": false,
89+
},
90+
},
91+
{
92+
name: "campaign and tfv",
93+
roles: []string{"Campaign Management", "TFV"},
94+
want: map[string]bool{
95+
"voice": false,
96+
"app_management": false,
97+
"messaging": false,
98+
"numbers": false,
99+
"vcp": false,
100+
"campaign_management": true,
101+
"tfv": true,
102+
},
103+
},
104+
}
105+
106+
for _, tt := range tests {
107+
t.Run(tt.name, func(t *testing.T) {
108+
got := Capabilities(tt.roles)
109+
for k, want := range tt.want {
110+
if got[k] != want {
111+
t.Errorf("Capabilities[%q] = %v, want %v (roles=%v)", k, got[k], want, tt.roles)
112+
}
113+
}
114+
})
115+
}
116+
}
117+
46118
func TestParseJWTClaims(t *testing.T) {
47119
claims := map[string]any{
48-
"accounts": []string{"9900001", "9900002"},
49-
"acct_scope": "9900001",
50-
"roles": []string{"admin"},
120+
"accounts": []string{"9900001", "9900002"},
121+
"roles": []string{"admin"},
122+
"express": true,
51123
}
52124
payload, _ := json.Marshal(claims)
53125
encoded := base64.RawURLEncoding.EncodeToString(payload)
@@ -57,12 +129,15 @@ func TestParseJWTClaims(t *testing.T) {
57129
if err != nil {
58130
t.Fatalf("unexpected error: %v", err)
59131
}
60-
if parsed.AcctScope != "9900001" {
61-
t.Errorf("AcctScope = %q, want %q", parsed.AcctScope, "9900001")
62-
}
63132
if len(parsed.Accounts) != 2 || parsed.Accounts[0] != "9900001" {
64133
t.Errorf("Accounts = %v, want [9900001 9900002]", parsed.Accounts)
65134
}
135+
if !parsed.Build {
136+
t.Errorf("Build = false, want true")
137+
}
138+
if len(parsed.Roles) != 1 || parsed.Roles[0] != "admin" {
139+
t.Errorf("Roles = %v, want [admin]", parsed.Roles)
140+
}
66141
}
67142

68143
func TestParseJWTClaimsInvalidFormat(t *testing.T) {

cmd/auth/login.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,15 @@ func runLogin(cmd *cobra.Command, args []string) error {
128128
}
129129
ui.Successf("Credentials verified")
130130

131-
// Step 2: Extract accounts and scope from JWT
131+
// Step 2: Extract accounts from JWT
132132
claims, err := parseJWTClaims(token)
133133
if err != nil {
134134
return fmt.Errorf("reading token claims: %w", err)
135135
}
136136
accounts := claims.Accounts
137137

138-
if len(accounts) == 0 && claims.AcctScope != "" {
139-
ui.Infof("Credential scope: %s (access to all accounts)", claims.AcctScope)
138+
if len(accounts) == 0 {
139+
ui.Infof("Your credentials are not bound to a specific account.")
140140
ui.Infof("Use --account-id on commands to target a specific account.")
141141
}
142142

@@ -160,6 +160,8 @@ func runLogin(cmd *cobra.Command, args []string) error {
160160
ClientID: clientID,
161161
Accounts: accounts,
162162
Environment: environment,
163+
Roles: claims.Roles,
164+
Build: claims.Build,
163165
}
164166

165167
// Step 5: Select active account
@@ -231,9 +233,9 @@ func selectAccount(cmd *cobra.Command, accounts []string) string {
231233
}
232234

233235
type jwtClaims struct {
234-
Accounts []string `json:"accounts"`
235-
AcctScope string `json:"acct_scope"`
236-
Roles []string `json:"roles"`
236+
Accounts []string `json:"accounts"`
237+
Roles []string `json:"roles"`
238+
Build bool `json:"express"`
237239
}
238240

239241
func parseJWTClaims(token string) (*jwtClaims, error) {

0 commit comments

Comments
 (0)