Skip to content
25 changes: 20 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,16 @@ band auth profiles # list all stored profiles
band auth use admin # switch the active profile
```

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.
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.

### Account Type and Capabilities

`band auth status --plain` returns structured JSON describing what the active account can do. The two fields agents care about most:

- **`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.
- **`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.

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.

### Account Hint

Expand Down Expand Up @@ -236,7 +245,7 @@ band auth status # confirm

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.

**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.
**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.)

---

Expand Down Expand Up @@ -460,10 +469,11 @@ band number list --plain # → all numbers on account
|------|---------|------|
| 0 | Success | Command completed |
| 1 | General error | Missing flags, invalid input, unexpected failures |
| 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" |
| 2 | Auth error | 401 — bad credentials or token expired. Re-authenticate. |
| 3 | Not found | 404 — resource doesn't exist |
| 4 | Conflict | 409 — duplicate resource or feature not enabled |
| 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. |
| 5 | Timeout | `--wait` exceeded `--timeout` |
| 7 | Rate limited / quota exceeded | 429 or concurrent-resource ceiling. Back off and retry. |

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

Expand All @@ -475,9 +485,13 @@ band number list --plain # → all numbers on account
| "account ID not set" | 1 | No active account | `band auth switch <id>` or pass `--account-id` |
| "credential verification failed" | 2 | Bad client ID or secret | Check credentials |
| "API error 401" | 2 | Token expired or invalid | Re-run `band auth login` |
| "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 |
| "...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/ |
| "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 |
| "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 |
| "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` |
| "API error 404" | 3 | Resource doesn't exist | Verify the ID; check you're on the right account |
| "API error 409" | 4 | Conflict / duplicate | Use `--if-not-exists`; or feature not enabled on account |
| "API error 429" | 7 | Rate limited or quota exceeded | Back off and retry — eventually retryable |
| "HTTP voice feature is required" | 4 | Legacy voice not available | Try VCP path (UP account) or contact support |
| "required flag not set" | 1 | Missing a required flag | Check `--help` for required flags |

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

## Limitations

- **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."
- **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.
- **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.
- **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.
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,9 @@ Then complete setup in your browser:

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

**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).
**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.)

**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
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
your onboarding process today.
**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.

---

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

---

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

For the full agent reference — dependency chains, provisioning workflows, error patterns, and copy-pasteable scripts — see [AGENTS.md](AGENTS.md).
Expand Down
87 changes: 81 additions & 6 deletions cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,83 @@ func TestTokenURLForEnvironment(t *testing.T) {
}
}

func TestCapabilities(t *testing.T) {
tests := []struct {
name string
roles []string
want map[string]bool
}{
{
name: "build account roles",
roles: []string{"HTTP Application Management", "HttpVoice", "brtcAccessRole"},
want: map[string]bool{
"voice": true,
"app_management": true,
"messaging": false,
"numbers": false,
"vcp": false,
"campaign_management": false,
"tfv": false,
},
},
{
name: "no roles",
roles: nil,
want: map[string]bool{
"voice": false,
"app_management": false,
"messaging": false,
"numbers": false,
"vcp": false,
"campaign_management": false,
"tfv": false,
},
},
{
name: "messaging and voice",
roles: []string{"Messaging", "HttpVoice"},
want: map[string]bool{
"voice": true,
"app_management": false,
"messaging": true,
"numbers": false,
"vcp": false,
"campaign_management": false,
"tfv": false,
},
},
{
name: "campaign and tfv",
roles: []string{"Campaign Management", "TFV"},
want: map[string]bool{
"voice": false,
"app_management": false,
"messaging": false,
"numbers": false,
"vcp": false,
"campaign_management": true,
"tfv": true,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Capabilities(tt.roles)
for k, want := range tt.want {
if got[k] != want {
t.Errorf("Capabilities[%q] = %v, want %v (roles=%v)", k, got[k], want, tt.roles)
}
}
})
}
}

func TestParseJWTClaims(t *testing.T) {
claims := map[string]any{
"accounts": []string{"9900001", "9900002"},
"acct_scope": "9900001",
"roles": []string{"admin"},
"accounts": []string{"9900001", "9900002"},
"roles": []string{"admin"},
"express": true,
}
payload, _ := json.Marshal(claims)
encoded := base64.RawURLEncoding.EncodeToString(payload)
Expand All @@ -57,12 +129,15 @@ func TestParseJWTClaims(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if parsed.AcctScope != "9900001" {
t.Errorf("AcctScope = %q, want %q", parsed.AcctScope, "9900001")
}
if len(parsed.Accounts) != 2 || parsed.Accounts[0] != "9900001" {
t.Errorf("Accounts = %v, want [9900001 9900002]", parsed.Accounts)
}
if !parsed.Build {
t.Errorf("Build = false, want true")
}
if len(parsed.Roles) != 1 || parsed.Roles[0] != "admin" {
t.Errorf("Roles = %v, want [admin]", parsed.Roles)
}
}

func TestParseJWTClaimsInvalidFormat(t *testing.T) {
Expand Down
14 changes: 8 additions & 6 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,15 @@ func runLogin(cmd *cobra.Command, args []string) error {
}
ui.Successf("Credentials verified")

// Step 2: Extract accounts and scope from JWT
// Step 2: Extract accounts from JWT
claims, err := parseJWTClaims(token)
if err != nil {
return fmt.Errorf("reading token claims: %w", err)
}
accounts := claims.Accounts

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

Expand All @@ -160,6 +160,8 @@ func runLogin(cmd *cobra.Command, args []string) error {
ClientID: clientID,
Accounts: accounts,
Environment: environment,
Roles: claims.Roles,
Build: claims.Build,
}

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

type jwtClaims struct {
Accounts []string `json:"accounts"`
AcctScope string `json:"acct_scope"`
Roles []string `json:"roles"`
Accounts []string `json:"accounts"`
Roles []string `json:"roles"`
Build bool `json:"express"`
}

func parseJWTClaims(token string) (*jwtClaims, error) {
Expand Down
Loading
Loading