diff --git a/AGENTS.md b/AGENTS.md index 4880831..bb3317a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,8 @@ This is stderr only — it won't break piped output parsing. |----------|---------| | `BW_CLIENT_ID` | OAuth2 client ID | | `BW_CLIENT_SECRET` | OAuth2 client secret | +| `OPENAI_API_KEY` | OpenAI API key for the `live-assistant` sample | +| `TRANSFER_TO` | Transfer destination (E.164) for the `live-assistant` sample | | `BW_ACCOUNT_ID` | Override active account | | `BW_ENVIRONMENT` | API environment: `prod` (default), `test` | | `BW_API_URL` | Override API base URL (overrides environment-based default) | @@ -366,13 +368,15 @@ For full flag/argument reference, use `band --help`. This section cove - **`band sample run` is helpful for demoing what a developer can accomplish with Bandwidth.** It clones a repo, starts a local process, launches ngrok, and blocks for the lifetime of the app. If you need to deploy a sample for a user, use the step-by-step [Agent Workflows](#agent-workflows) for the underlying provisioning. - **`band sample list` is safe for discovery.** Run it to enumerate available samples and supported languages before telling a user which command to run. -- **Credentials are auto-wired.** `band sample run` reads the active band profile and writes `BW_ACCOUNT_ID`, `BW_CLIENT_ID`, and `BW_CLIENT_SECRET` to a `.env` file in the clone directory — the user never needs to copy credentials manually. Extra credentials (like `OPENAI_API_KEY` for the `live-assistant` sample) must be supplied via CLI flags or set as environment variables beforehand. +- **Credentials are auto-wired.** `band sample run` reads the active band profile and writes `BW_ACCOUNT_ID`, `BW_CLIENT_ID`, and `BW_CLIENT_SECRET` to a `.env` file in the clone directory — the user never needs to copy credentials manually. Sample-specific configuration (like `OPENAI_API_KEY` and `TRANSFER_TO` for the `live-assistant` sample) is read from the environment and must be exported before running. There are no flags for these — run the sample without them and the error names each one. - **The `live-assistant` sample is the primary voice AI demo.** It pairs Bandwidth's PSTN infrastructure (dialing, audio streaming, webhook routing) with the OpenAI Realtime API. The command to give a user: ```bash + export OPENAI_API_KEY=sk-... + export TRANSFER_TO=+19195550100 + band sample run live-assistant \ --language python \ - --openai-key sk-... \ --call-to +15559876543 ``` diff --git a/README.md b/README.md index b8257ef..ec8f6f8 100644 --- a/README.md +++ b/README.md @@ -618,6 +618,8 @@ All five share the same filters: `--to`/`--from` (comma-separated E.164), `--dir | `BW_API_URL` | Override the API base URL | | `BW_VOICE_URL` | Override the Voice API base URL | | `BW_MESSAGING_URL` | Override the Messaging API base URL. Messaging is production-only (no test host), so `--environment`/`BW_ENVIRONMENT` does not change it; use this for local proxies or the internal lab. | +| `OPENAI_API_KEY` | OpenAI API key for the `live-assistant` sample (`band sample run`) | +| `TRANSFER_TO` | Transfer destination in E.164 for the `live-assistant` sample (`band sample run`) | --- @@ -702,19 +704,23 @@ Want to see Bandwidth in action without writing any boilerplate? The CLI ships w ```sh band sample list # see what's available -band sample run live-assistant --language python --openai-key sk-... +OPENAI_API_KEY=sk-... TRANSFER_TO=+19195550100 band sample run live-assistant --language python ``` `band sample run` handles everything: clones the GitHub repo, creates a virtual environment, installs dependencies, starts an ngrok tunnel, wires your Bandwidth credentials into a `.env` file, creates or reuses a voice application, and launches the app. Pass `--call-to` and it dials a number automatically once the app is healthy. +Samples that need their own configuration read it from the environment, the same way `BW_CLIENT_ID` and `BW_CLIENT_SECRET` do. Run a sample without them and the CLI tells you which variables it expects. + ### Try the OpenAI live assistant The flagship sample is a real-time AI voice assistant powered by the OpenAI Realtime API. It's the fastest way to see what Bandwidth's infrastructure can do: Bandwidth owns the PSTN layer — dialing, audio streaming, and webhook routing — while your AI provider handles the intelligence. You supply the API key; Bandwidth supplies the phone network. ```sh +export OPENAI_API_KEY=sk-... +export TRANSFER_TO=+19195550100 + band sample run live-assistant \ --language python \ - --openai-key sk-... \ --call-to +15559876543 ``` @@ -732,7 +738,7 @@ band sample run live-assistant \ 5. The Python app starts and is ready to accept calls 6. If you passed `--call-to`, the CLI dials that number as soon as the health check passes — answer and talk to the assistant -**Want to transfer calls?** Pass `--transfer-to +1XXXXXXXXXX` to give the assistant a number to hand callers off to. +**Want to transfer calls?** Set `TRANSFER_TO=+1XXXXXXXXXX` to give the assistant a number to hand callers off to. --- diff --git a/cmd/bxml/transfer.go b/cmd/bxml/transfer.go index 7f0a81d..f4978f5 100644 --- a/cmd/bxml/transfer.go +++ b/cmd/bxml/transfer.go @@ -18,8 +18,8 @@ var transferCmd = &cobra.Command{ Use: "transfer ", Short: "Generate a Transfer BXML verb", Long: "Generates a Response containing a Transfer verb to the given phone number. Use --caller-id to set the caller ID presented on the transferred leg.", - Example: ` band bxml transfer +19195551234 - band bxml transfer +19195551234 --caller-id +19198675309`, + Example: ` band bxml transfer +19195550100 + band bxml transfer +19195550100 --caller-id +19195550101`, Args: cobra.ExactArgs(1), RunE: runTransfer, } diff --git a/cmd/sample/catalog.go b/cmd/sample/catalog.go index 889cda5..173a2f0 100644 --- a/cmd/sample/catalog.go +++ b/cmd/sample/catalog.go @@ -3,9 +3,8 @@ package sample // envPrompt describes an environment variable that a sample app requires beyond // the standard Bandwidth credentials (which are auto-populated from band auth). type envPrompt struct { - Key string // env var name written to .env - Flag string // CLI flag name (without --) - Description string // shown in --help + Key string // env var name, read from the environment and written to .env + Description string // shown when the variable is missing } // langSetup describes how to install dependencies and run the app for a given language. @@ -41,8 +40,8 @@ var catalog = map[string]*SampleEntry{ "python": "https://github.com/Bandwidth-Samples/openai-live-websockets-python", }, ExtraEnv: []envPrompt{ - {Key: "OPENAI_API_KEY", Flag: "openai-key", Description: "OpenAI API key (must have Realtime API access)"}, - {Key: "TRANSFER_TO", Flag: "transfer-to", Description: "Phone number to transfer calls to (E.164, e.g. +19195551234)"}, + {Key: "OPENAI_API_KEY", Description: "OpenAI API key with Realtime API access"}, + {Key: "TRANSFER_TO", Description: "Phone number to transfer calls to (E.164, e.g. +19195550100)"}, }, Setup: map[string]langSetup{ "python": { diff --git a/cmd/sample/run.go b/cmd/sample/run.go index 2f04a9b..fa86b48 100644 --- a/cmd/sample/run.go +++ b/cmd/sample/run.go @@ -21,11 +21,9 @@ import ( ) var ( - runLanguage string - runCallTo string - runPort int - runOpenAIKey string - runTransferTo string + runLanguage string + runCallTo string + runPort int ) var runCmd = &cobra.Command{ @@ -34,9 +32,14 @@ var runCmd = &cobra.Command{ Long: `Clones the sample app, wires up Bandwidth credentials from your active band profile, starts an ngrok tunnel, and launches the app. +Samples that need their own configuration read it from the environment, the same +way BW_CLIENT_ID and BW_CLIENT_SECRET work. Run the command without them to see +which variables a given sample expects. + Pass --call-to to automatically dial a number once the app is ready.`, - Example: ` band sample run live-assistant --language python --openai-key sk-... - band sample run live-assistant --language python --openai-key sk-... --call-to +13367499393`, + Example: ` band sample run live-assistant --language python + OPENAI_API_KEY=sk-... TRANSFER_TO=+19195550100 band sample run live-assistant --language python + OPENAI_API_KEY=sk-... TRANSFER_TO=+19195550100 band sample run live-assistant --language python --call-to +19195550101`, Args: cobra.ExactArgs(1), RunE: runSample, } @@ -45,8 +48,6 @@ func init() { runCmd.Flags().StringVarP(&runLanguage, "language", "l", "", "Language variant to run (required)") runCmd.Flags().StringVar(&runCallTo, "call-to", "", "Phone number to call after the app is ready (E.164)") runCmd.Flags().IntVar(&runPort, "port", 0, "Local port (default: from catalog)") - runCmd.Flags().StringVar(&runOpenAIKey, "openai-key", "", "OpenAI API key (for AI-powered samples)") - runCmd.Flags().StringVar(&runTransferTo, "transfer-to", "", "Phone number to transfer calls to (E.164)") _ = runCmd.MarkFlagRequired("language") } @@ -83,7 +84,7 @@ func runSample(cmd *cobra.Command, args []string) error { } // ── Extra env var validation ───────────────────────────────────────────── - extraEnv, err := collectExtraEnv(name, entry) + extraEnv, err := collectExtraEnv(name, runLanguage, entry) if err != nil { return err } @@ -241,28 +242,30 @@ func runSample(cmd *cobra.Command, args []string) error { // ── Helpers ────────────────────────────────────────────────────────────────── -// collectExtraEnv maps flag values and falls back to env vars for each extra -// env var the sample requires. Returns an error if a required value is missing. -func collectExtraEnv(name string, entry *SampleEntry) (map[string]string, error) { - flagMap := map[string]string{ - "OPENAI_API_KEY": runOpenAIKey, - "TRANSFER_TO": runTransferTo, - } +// collectExtraEnv reads each extra environment variable the sample requires. +// Values come from the environment rather than flags so that secrets are not +// exposed in the process argument list. Returns an error if any are missing. +func collectExtraEnv(name, lang string, entry *SampleEntry) (map[string]string, error) { result := make(map[string]string) - var missing []string + var missing, missingKeys []string for _, p := range entry.ExtraEnv { - val := flagMap[p.Key] + val := os.Getenv(p.Key) if val == "" { - val = os.Getenv(p.Key) - } - if val == "" { - missing = append(missing, fmt.Sprintf(" --%s (%s)", p.Flag, p.Description)) - } else { - result[p.Key] = val + missing = append(missing, fmt.Sprintf(" %s (%s)", p.Key, p.Description)) + missingKeys = append(missingKeys, p.Key+"=...") + continue } + result[p.Key] = val } if len(missing) > 0 { - return nil, fmt.Errorf("the %q sample requires additional flags:\n%s", name, strings.Join(missing, "\n")) + return nil, fmt.Errorf( + "the %q sample requires these environment variables:\n%s\n\nset them in the environment, for example:\n %s band sample run %s --language %s", + name, + strings.Join(missing, "\n"), + strings.Join(missingKeys, " "), + name, + lang, + ) } return result, nil } diff --git a/cmd/sample/run_test.go b/cmd/sample/run_test.go new file mode 100644 index 0000000..e76df94 --- /dev/null +++ b/cmd/sample/run_test.go @@ -0,0 +1,63 @@ +package sample + +import ( + "strings" + "testing" +) + +func TestCollectExtraEnvReadsEnvironment(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "sk-test") + t.Setenv("TRANSFER_TO", "+19195550100") + + got, err := collectExtraEnv("live-assistant", "python", catalog["live-assistant"]) + if err != nil { + t.Fatalf("collectExtraEnv: %v", err) + } + if got["OPENAI_API_KEY"] != "sk-test" { + t.Errorf("OPENAI_API_KEY = %q, want %q", got["OPENAI_API_KEY"], "sk-test") + } + if got["TRANSFER_TO"] != "+19195550100" { + t.Errorf("TRANSFER_TO = %q, want %q", got["TRANSFER_TO"], "+19195550100") + } +} + +func TestCollectExtraEnvReportsMissingByEnvVarName(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "sk-test") + t.Setenv("TRANSFER_TO", "") + + _, err := collectExtraEnv("live-assistant", "python", catalog["live-assistant"]) + if err == nil { + t.Fatal("expected an error when TRANSFER_TO is unset, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "TRANSFER_TO") { + t.Errorf("error should name the missing variable, got: %s", msg) + } + if strings.Contains(msg, "OPENAI_API_KEY (") { + t.Errorf("error should not list variables that are set, got: %s", msg) + } + // Extra configuration is environment-only; the message must not send users + // back to a flag that no longer exists. + for _, gone := range []string{"--openai-key", "--transfer-to"} { + if strings.Contains(msg, gone) { + t.Errorf("error should not suggest removed flag %s, got: %s", gone, msg) + } + } + // The hint should name the variable that is actually missing. + if !strings.Contains(msg, "TRANSFER_TO=...") { + t.Errorf("hint should show the missing variable, got: %s", msg) + } + if strings.Contains(msg, "OPENAI_API_KEY=...") { + t.Errorf("hint should not include variables that are already set, got: %s", msg) + } +} + +func TestCollectExtraEnvNoRequirements(t *testing.T) { + got, err := collectExtraEnv("voice-gather", "python", catalog["voice-gather"]) + if err != nil { + t.Fatalf("collectExtraEnv: %v", err) + } + if len(got) != 0 { + t.Errorf("got %v, want empty map", got) + } +} diff --git a/cmd/sample/sample.go b/cmd/sample/sample.go index 4ac8f4d..0e483cb 100644 --- a/cmd/sample/sample.go +++ b/cmd/sample/sample.go @@ -13,8 +13,8 @@ wired from your active band profile. Examples: band sample list - band sample run live-assistant --language python --openai-key sk-... - band sample run live-assistant --language python --openai-key sk-... --call-to +19195551234`, + band sample run live-assistant --language python + band sample run live-assistant --language python --call-to +19195550101`, } func init() {