diff --git a/Cargo.lock b/Cargo.lock index 10950b8..2650c9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bland-wasm" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "switchboard-guest-sdk", +] + [[package]] name = "itoa" version = "1.0.18" diff --git a/README.md b/README.md index 36e0b2a..c9a7d63 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A home for integrations that make more sense as standalone WASM modules than as | Plugin | Tools | Description | |--------|-------|-------------| +| [bland](plugins/bland/) | 30 | Bland.ai voice AI: calls, transcripts, voices, pathways, inbound numbers, knowledge bases, org management, billing, audit logs | | [looker](plugins/looker/) | 23 | Looker BI: dashboards, Looks, LookML models, inline analytics queries, SQL Runner | Prebuilt binaries live in [`dist/`](dist/) and are referenced by [`manifest.json`](manifest.json). diff --git a/dist/bland.wasm b/dist/bland.wasm new file mode 100755 index 0000000..bee2f78 Binary files /dev/null and b/dist/bland.wasm differ diff --git a/manifest.json b/manifest.json index 83051e9..6bca8bd 100644 --- a/manifest.json +++ b/manifest.json @@ -3,6 +3,25 @@ "name": "daltoniam-plugins", "description": "Third-party Switchboard WASM plugins by @daltoniam", "plugins": [ + { + "name": "bland", + "description": "Bland.ai voice AI integration — calls, transcripts, voices, pathways, inbound numbers, knowledge bases, org management, billing, audit logs.", + "author": "daltoniam", + "homepage": "https://github.com/daltoniam/switchboard_plugins", + "license": "MIT", + "versions": [ + { + "version": "0.1.0", + "abi_min": 1, + "abi_max": 1, + "url": "https://raw.githubusercontent.com/daltoniam/switchboard_plugins/main/dist/bland.wasm", + "sha256": "29927a10b27fdd9d44de2834771719743487d5028e3010b4e53ad5b266f7a3c1", + "size": 223601, + "released_at": "2026-05-18T17:08:37Z", + "changelog": "Initial release. 30 tools across calls, transcripts, voices, pathways, inbound numbers, knowledge bases, org management, billing, service versions, and audit logs." + } + ] + }, { "name": "looker", "description": "Looker BI integration — dashboards, Looks, LookML models, inline analytics queries, SQL Runner.", diff --git a/plugins/bland/Cargo.toml b/plugins/bland/Cargo.toml new file mode 100644 index 0000000..710f3c9 --- /dev/null +++ b/plugins/bland/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bland-wasm" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +switchboard-guest-sdk = { git = "https://github.com/daltoniam/switchboard.git" } +serde.workspace = true +serde_json.workspace = true diff --git a/plugins/bland/README.md b/plugins/bland/README.md new file mode 100644 index 0000000..3dbe385 --- /dev/null +++ b/plugins/bland/README.md @@ -0,0 +1,45 @@ +# Bland.ai Switchboard plugin + +Bland.ai voice AI integration for calls, transcripts, voices, pathways, inbound numbers, knowledge bases, account details, org management, billing, service versions, and audit logs. + +## Credentials + +| Key | Required | Description | +|-----|----------|-------------| +| `api_key` | yes | Bland.ai API key used as the `Authorization` header | +| `org_id` | no | Optional organization ID sent as `x-bland-org-id` for org-scoped endpoints | + +## Tools + +| Tool | Description | +|------|-------------| +| `bland_list_calls` | List call history and filter by date, number, status, batch, direction, or duration | +| `bland_list_active_calls` | List currently active calls | +| `bland_get_call` | Get call details, summary, transcript, analysis, variables, and metadata | +| `bland_send_call` | Start an outbound AI phone call using `task` or `pathway_id` | +| `bland_stop_call` | Stop an active call | +| `bland_analyze_call` | Run AI analysis over a completed call transcript | +| `bland_list_voices` | List available voices | +| `bland_get_voice` | Get voice details | +| `bland_list_pathways` | List conversational pathways | +| `bland_get_pathway` | Get pathway configuration | +| `bland_list_numbers` | List inbound phone numbers | +| `bland_get_number` | Get inbound number details | +| `bland_list_knowledge_bases` | List knowledge bases | +| `bland_get_knowledge_base` | Get knowledge base details | +| `bland_get_me` | Get account details, balance, and total call count | +| `bland_create_org` | Create an organization | +| `bland_get_org` | Get organization details | +| `bland_delete_org` | Delete an organization with slug confirmation | +| `bland_update_org_properties` | Update organization display name and preferences | +| `bland_list_org_members` | List organization members and permissions | +| `bland_update_org_members` | Add or remove organization members/invites | +| `bland_update_org_member_permissions` | Add, remove, reset, or set member permissions | +| `bland_list_my_org_memberships` | List organizations for the current user | +| `bland_leave_org` | Leave an organization | +| `bland_get_org_billing` | Get organization billing balance and refill settings | +| `bland_get_org_billing_refill` | Get organization billing refill threshold | +| `bland_get_org_current_version` | Get current org service version for `api_server` or `ws_server` | +| `bland_list_org_versions` | List org service versions for `api_server` or `ws_server` | +| `bland_update_org_version` | Update org service version | +| `bland_list_audit_logs` | List enterprise audit log events | diff --git a/plugins/bland/src/handlers.rs b/plugins/bland/src/handlers.rs new file mode 100644 index 0000000..a9d2cc7 --- /dev/null +++ b/plugins/bland/src/handlers.rs @@ -0,0 +1,614 @@ +use std::collections::HashMap; +use switchboard_guest_sdk as sdk; + +use crate::{bland_delete, bland_get, bland_patch, bland_post, path_escape, query_escape}; + +macro_rules! call { + ($call:expr) => { + match $call { + Ok(v) => v, + Err(e) => return sdk::err_result(&e), + } + }; +} + +fn json_result(v: &serde_json::Value) -> sdk::ToolResult { + match serde_json::to_string(v) { + Ok(s) => sdk::raw_result(s), + Err(e) => sdk::err_result(&format!("encode response: {e}")), + } +} + +fn required( + args: &HashMap, + key: &str, +) -> Result { + let value = sdk::arg_str(args, key); + if value.is_empty() { + Err(sdk::err_result(&format!("{key} is required"))) + } else { + Ok(value) + } +} + +fn add_str_param( + args: &HashMap, + params: &mut Vec<(String, String)>, + arg: &str, + key: &str, +) { + let value = sdk::arg_str(args, arg); + if !value.is_empty() { + params.push((key.to_string(), value)); + } +} + +fn add_int_param( + args: &HashMap, + params: &mut Vec<(String, String)>, + arg: &str, + key: &str, +) { + if let Some(value) = sdk::arg_int(args, arg) { + params.push((key.to_string(), value.to_string())); + } +} + +fn add_bool_param( + args: &HashMap, + params: &mut Vec<(String, String)>, + arg: &str, + key: &str, +) { + if let Some(value) = sdk::arg_bool(args, arg) { + params.push((key.to_string(), value.to_string())); + } +} + +fn append_query(path: &str, params: &[(String, String)]) -> String { + if params.is_empty() { + return path.to_string(); + } + let query = params + .iter() + .map(|(key, value)| format!("{}={}", query_escape(key), query_escape(value))) + .collect::>() + .join("&"); + format!("{path}?{query}") +} + +fn insert_string_body( + args: &HashMap, + body: &mut serde_json::Map, + key: &str, +) { + let value = sdk::arg_str(args, key); + if !value.is_empty() { + body.insert(key.to_string(), serde_json::json!(value)); + } +} + +fn insert_i64_body( + args: &HashMap, + body: &mut serde_json::Map, + key: &str, +) { + if let Some(value) = sdk::arg_int(args, key) { + body.insert(key.to_string(), serde_json::json!(value)); + } +} + +fn insert_f64_body( + args: &HashMap, + body: &mut serde_json::Map, + key: &str, +) -> Result<(), String> { + match args.get(key) { + Some(serde_json::Value::Number(n)) => { + if let Some(value) = n.as_f64() { + body.insert(key.to_string(), serde_json::json!(value)); + } + } + Some(serde_json::Value::String(s)) if !s.is_empty() => { + let value = s + .parse::() + .map_err(|e| format!("{key} must be a number: {e}"))?; + body.insert(key.to_string(), serde_json::json!(value)); + } + _ => {} + } + Ok(()) +} + +fn insert_bool_body( + args: &HashMap, + body: &mut serde_json::Map, + key: &str, +) { + if let Some(value) = sdk::arg_bool(args, key) { + body.insert(key.to_string(), serde_json::json!(value)); + } +} + +fn insert_json_body( + args: &HashMap, + body: &mut serde_json::Map, + key: &str, +) -> Result<(), String> { + match args.get(key) { + Some(serde_json::Value::String(s)) if !s.is_empty() => { + let parsed: serde_json::Value = + serde_json::from_str(s).map_err(|e| format!("{key} must be valid JSON: {e}"))?; + body.insert(key.to_string(), parsed); + } + Some(v) if !v.is_null() => { + body.insert(key.to_string(), v.clone()); + } + _ => {} + } + Ok(()) +} + +pub fn list_calls(args: HashMap) -> sdk::ToolResult { + let mut params: Vec<(String, String)> = vec![( + "limit".into(), + sdk::arg_int(&args, "limit") + .unwrap_or(20) + .clamp(1, 100) + .to_string(), + )]; + add_int_param(&args, &mut params, "from", "from"); + add_int_param(&args, &mut params, "to", "to"); + add_bool_param(&args, &mut params, "ascending", "ascending"); + add_str_param(&args, &mut params, "sort_by", "sort_by"); + add_str_param(&args, &mut params, "start_date", "start_date"); + add_str_param(&args, &mut params, "end_date", "end_date"); + add_str_param(&args, &mut params, "batch_id", "batch_id"); + add_str_param(&args, &mut params, "answered_by", "answered_by"); + add_bool_param(&args, &mut params, "inbound", "inbound"); + add_bool_param(&args, &mut params, "completed", "completed"); + add_str_param(&args, &mut params, "from_number", "from_number"); + add_str_param(&args, &mut params, "to_number", "to_number"); + add_int_param(&args, &mut params, "duration_gt", "duration_gt"); + add_int_param(&args, &mut params, "duration_lt", "duration_lt"); + + let v = call!(bland_get(&append_query("/calls", ¶ms))); + json_result(&v) +} + +pub fn list_active_calls(_args: HashMap) -> sdk::ToolResult { + let v = call!(bland_get("/calls/active")); + json_result(&v) +} + +pub fn get_call(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "call_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&format!("/calls/{}", path_escape(&id)))); + json_result(&v) +} + +pub fn send_call(args: HashMap) -> sdk::ToolResult { + let phone_number = match required(&args, "phone_number") { + Ok(v) => v, + Err(e) => return e, + }; + let task = sdk::arg_str(&args, "task"); + let pathway_id = sdk::arg_str(&args, "pathway_id"); + if task.is_empty() && pathway_id.is_empty() { + return sdk::err_result("task or pathway_id is required"); + } + + let mut body = serde_json::Map::new(); + body.insert("phone_number".into(), serde_json::json!(phone_number)); + if !task.is_empty() { + body.insert("task".into(), serde_json::json!(task)); + } + if !pathway_id.is_empty() { + body.insert("pathway_id".into(), serde_json::json!(pathway_id)); + } + + for key in [ + "voice", + "first_sentence", + "model", + "language", + "from", + "webhook", + "transfer_phone_number", + "timezone", + "background_track", + ] { + insert_string_body(&args, &mut body, key); + } + for key in ["max_duration", "interruption_threshold"] { + insert_i64_body(&args, &mut body, key); + } + for key in ["wait_for_greeting", "record"] { + insert_bool_body(&args, &mut body, key); + } + if let Err(e) = insert_f64_body(&args, &mut body, "temperature") { + return sdk::err_result(&e); + } + for key in ["metadata", "dynamic_data", "tools"] { + if let Err(e) = insert_json_body(&args, &mut body, key) { + return sdk::err_result(&e); + } + } + + let v = call!(bland_post("/calls", &serde_json::Value::Object(body))); + json_result(&v) +} + +pub fn stop_call(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "call_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_post( + &format!("/calls/{}/stop", path_escape(&id)), + &serde_json::json!({}) + )); + json_result(&v) +} + +pub fn analyze_call(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "call_id") { + Ok(v) => v, + Err(e) => return e, + }; + let goal = match required(&args, "goal") { + Ok(v) => v, + Err(e) => return e, + }; + let questions = match args.get("questions") { + Some(serde_json::Value::String(s)) if !s.is_empty() => { + match serde_json::from_str::(s) { + Ok(v) => v, + Err(e) => return sdk::err_result(&format!("questions must be valid JSON: {e}")), + } + } + Some(v) if !v.is_null() => v.clone(), + _ => return sdk::err_result("questions is required"), + }; + if !questions.is_array() { + return sdk::err_result("questions must be a JSON array"); + } + + let body = serde_json::json!({ + "goal": goal, + "questions": questions, + }); + let v = call!(bland_post( + &format!("/calls/{}/analyze", path_escape(&id)), + &body + )); + json_result(&v) +} + +pub fn list_voices(_args: HashMap) -> sdk::ToolResult { + let v = call!(bland_get("/voices")); + json_result(&v) +} + +pub fn get_voice(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "voice_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&format!("/voices/{}", path_escape(&id)))); + json_result(&v) +} + +pub fn list_pathways(_args: HashMap) -> sdk::ToolResult { + let v = call!(bland_get("/pathway")); + json_result(&v) +} + +pub fn get_pathway(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "pathway_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&format!("/pathway/{}", path_escape(&id)))); + json_result(&v) +} + +pub fn list_numbers(args: HashMap) -> sdk::ToolResult { + let mut params: Vec<(String, String)> = Vec::new(); + add_str_param(&args, &mut params, "encrypted_key", "encrypted_key"); + let v = call!(bland_get(&append_query("/inbound", ¶ms))); + json_result(&v) +} + +pub fn get_number(args: HashMap) -> sdk::ToolResult { + let phone_number = match required(&args, "phone_number") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&format!( + "/inbound/{}", + path_escape(&phone_number) + ))); + json_result(&v) +} + +pub fn list_knowledge_bases(args: HashMap) -> sdk::ToolResult { + let mut params: Vec<(String, String)> = vec![ + ( + "page".into(), + sdk::arg_int(&args, "page").unwrap_or(1).max(1).to_string(), + ), + ( + "limit".into(), + sdk::arg_int(&args, "limit") + .unwrap_or(20) + .clamp(1, 100) + .to_string(), + ), + ]; + add_str_param(&args, &mut params, "status", "status"); + let v = call!(bland_get(&append_query("/knowledge", ¶ms))); + json_result(&v) +} + +pub fn get_knowledge_base(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "knowledge_base_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&format!("/knowledge/{}", path_escape(&id)))); + json_result(&v) +} + +pub fn get_me(_args: HashMap) -> sdk::ToolResult { + let v = call!(bland_get("/me")); + json_result(&v) +} + +pub fn create_org(args: HashMap) -> sdk::ToolResult { + let name = match required(&args, "name") { + Ok(v) => v, + Err(e) => return e, + }; + let body = serde_json::json!({ "name": name }); + let v = call!(bland_post("/orgs/create", &body)); + json_result(&v) +} + +pub fn get_org(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "org_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&format!("/orgs/{}", path_escape(&org_id)))); + json_result(&v) +} + +pub fn delete_org(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "org_id") { + Ok(v) => v, + Err(e) => return e, + }; + let delete_confirm = match required(&args, "delete_confirm") { + Ok(v) => v, + Err(e) => return e, + }; + let body = serde_json::json!({ "delete_confirm": delete_confirm }); + let v = call!(bland_delete( + &format!("/orgs/{}", path_escape(&org_id)), + Some(&body) + )); + json_result(&v) +} + +pub fn update_org_properties(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "org_id") { + Ok(v) => v, + Err(e) => return e, + }; + let mut updates = serde_json::Map::new(); + insert_string_body(&args, &mut updates, "org_display_name"); + + let mut preferences = serde_json::Map::new(); + insert_bool_body(&args, &mut preferences, "use_bland_url"); + insert_i64_body(&args, &mut preferences, "recording_lifespan_days"); + if !preferences.is_empty() { + updates.insert("preferences".into(), serde_json::Value::Object(preferences)); + } + if let Err(e) = insert_json_body(&args, &mut updates, "preferences") { + return sdk::err_result(&e); + } + if updates.is_empty() { + return sdk::err_result("at least one update field is required"); + } + + let body = serde_json::json!({ "updates": updates }); + let v = call!(bland_patch( + &format!("/orgs/{}/properties", path_escape(&org_id)), + &body + )); + json_result(&v) +} + +pub fn list_org_members(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "org_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&format!( + "/orgs/{}/members", + path_escape(&org_id) + ))); + json_result(&v) +} + +pub fn update_org_members(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "org_id") { + Ok(v) => v, + Err(e) => return e, + }; + let action = match required(&args, "action") { + Ok(v) => v, + Err(e) => return e, + }; + let target = match required(&args, "target") { + Ok(v) => v, + Err(e) => return e, + }; + let mut body = serde_json::Map::new(); + body.insert("action".into(), serde_json::json!(action)); + body.insert("target".into(), serde_json::json!(target)); + if let Err(e) = insert_json_body(&args, &mut body, "permissions") { + return sdk::err_result(&e); + } + insert_bool_body(&args, &mut body, "is_invite"); + let v = call!(bland_patch( + &format!("/orgs/{}/members", path_escape(&org_id)), + &serde_json::Value::Object(body) + )); + json_result(&v) +} + +pub fn update_org_member_permissions(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "org_id") { + Ok(v) => v, + Err(e) => return e, + }; + let action = match required(&args, "action") { + Ok(v) => v, + Err(e) => return e, + }; + let target = match required(&args, "target") { + Ok(v) => v, + Err(e) => return e, + }; + let mut body = serde_json::Map::new(); + body.insert("action".into(), serde_json::json!(action)); + body.insert("target".into(), serde_json::json!(target)); + if let Err(e) = insert_json_body(&args, &mut body, "permissions") { + return sdk::err_result(&e); + } + if !body.contains_key("permissions") { + return sdk::err_result("permissions is required"); + } + let v = call!(bland_patch( + &format!("/orgs/{}/members/permissions", path_escape(&org_id)), + &serde_json::Value::Object(body), + )); + json_result(&v) +} + +pub fn list_my_org_memberships(_args: HashMap) -> sdk::ToolResult { + let v = call!(bland_get("/orgs/self/memberships")); + json_result(&v) +} + +pub fn leave_org(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "org_id") { + Ok(v) => v, + Err(e) => return e, + }; + let body = serde_json::json!({ "org_id": org_id }); + let v = call!(bland_delete("/orgs/self/leave", Some(&body))); + json_result(&v) +} + +pub fn get_org_billing(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "org_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&format!( + "/orgs/{}/billing", + path_escape(&org_id) + ))); + json_result(&v) +} + +pub fn get_org_billing_refill(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "org_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&format!( + "/orgs/{}/billing/refill", + path_escape(&org_id) + ))); + json_result(&v) +} + +fn org_service_path( + args: &HashMap, + suffix: &str, +) -> Result { + let org_id = required(args, "org_id")?; + let service = sdk::arg_str(args, "service"); + let service = if service.is_empty() { + "api_server".to_string() + } else { + service + }; + Ok(format!( + "/orgs/{}/versions/{}{}", + path_escape(&org_id), + path_escape(&service), + suffix + )) +} + +pub fn get_org_current_version(args: HashMap) -> sdk::ToolResult { + let path = match org_service_path(&args, "/current") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&path)); + json_result(&v) +} + +pub fn list_org_versions(args: HashMap) -> sdk::ToolResult { + let path = match org_service_path(&args, "/list") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(bland_get(&path)); + json_result(&v) +} + +pub fn update_org_version(args: HashMap) -> sdk::ToolResult { + let path = match org_service_path(&args, "") { + Ok(v) => v, + Err(e) => return e, + }; + let version = match required(&args, "version") { + Ok(v) => v, + Err(e) => return e, + }; + let body = serde_json::json!({ "version": version }); + let v = call!(bland_patch(&path, &body)); + json_result(&v) +} + +pub fn list_audit_logs(args: HashMap) -> sdk::ToolResult { + let mut params: Vec<(String, String)> = vec![ + ( + "page".into(), + sdk::arg_int(&args, "page").unwrap_or(1).max(1).to_string(), + ), + ( + "page_size".into(), + sdk::arg_int(&args, "page_size") + .unwrap_or(50) + .clamp(1, 100) + .to_string(), + ), + ]; + add_str_param(&args, &mut params, "event_type", "event_type"); + add_str_param(&args, &mut params, "actor_id", "actor_id"); + add_str_param(&args, &mut params, "created_after", "created_after"); + add_str_param(&args, &mut params, "created_before", "created_before"); + let v = call!(bland_get(&append_query("/audit/logs", ¶ms))); + json_result(&v) +} diff --git a/plugins/bland/src/lib.rs b/plugins/bland/src/lib.rs new file mode 100644 index 0000000..bdb239c --- /dev/null +++ b/plugins/bland/src/lib.rs @@ -0,0 +1,563 @@ +mod handlers; +mod tools; + +use std::collections::HashMap; +use std::sync::Mutex; +use switchboard_guest_sdk as sdk; + +#[cfg(test)] +#[no_mangle] +pub extern "C" fn host_http_request(_ptr_size: u64) -> u64 { + 0 +} + +#[cfg(test)] +#[no_mangle] +pub extern "C" fn host_log(_ptr: u32, _size: u32) {} + +const API_BASE: &str = "https://api.bland.ai/v1"; + +struct Config { + api_key: String, + org_id: String, +} + +static CONFIG: Mutex> = Mutex::new(None); + +fn with_config(f: F) -> Result +where + F: FnOnce(&Config) -> R, +{ + let guard = CONFIG.lock().map_err(|e| e.to_string())?; + match guard.as_ref() { + Some(c) => Ok(f(c)), + None => Err("bland: not configured".into()), + } +} + +#[no_mangle] +pub extern "C" fn name() -> u64 { + sdk::leaked_string("bland") +} + +#[no_mangle] +pub extern "C" fn metadata() -> u64 { + sdk::leaked_metadata(&sdk::PluginMetadata { + name: "bland".into(), + version: "0.1.0".into(), + abi_version: 1, + description: "Bland.ai voice AI integration for calls, transcripts, agents, voices, pathways, numbers, and knowledge bases.".into(), + author: "daltoniam".into(), + homepage: "https://github.com/daltoniam/switchboard_plugins".into(), + license: "MIT".into(), + capabilities: vec!["http".into()], + credential_keys: vec!["api_key".into(), "org_id".into()], + plain_text_keys: vec!["org_id".into()], + optional_keys: vec!["org_id".into()], + placeholders: HashMap::from([ + ("api_key".into(), "Bland.ai API key".into()), + ("org_id".into(), "Optional Bland organization ID".into()), + ]), + }) +} + +#[no_mangle] +pub extern "C" fn tools() -> u64 { + let defs = tools::tool_definitions(); + let data = serde_json::to_vec(&defs).unwrap_or_default(); + sdk::leaked_result(&data) +} + +#[no_mangle] +pub extern "C" fn configure(ptr_size: u64) -> u64 { + let input = sdk::read_input(ptr_size); + let creds: HashMap = match serde_json::from_slice(&input) { + Ok(c) => c, + Err(e) => return sdk::leaked_string(&format!("bland: invalid credentials JSON: {e}")), + }; + + let api_key = creds.get("api_key").cloned().unwrap_or_default(); + if api_key.is_empty() { + return sdk::leaked_string("bland: api_key is required"); + } + + *CONFIG.lock().unwrap() = Some(Config { + api_key, + org_id: creds.get("org_id").cloned().unwrap_or_default(), + }); + 0 +} + +#[no_mangle] +pub extern "C" fn execute(ptr_size: u64) -> u64 { + let input = sdk::read_input(ptr_size); + let req: sdk::ExecuteRequest = match serde_json::from_slice(&input) { + Ok(r) => r, + Err(e) => { + let r = sdk::err_result(&format!("invalid request: {e}")); + let data = serde_json::to_vec(&r).unwrap_or_default(); + return sdk::leaked_result(&data); + } + }; + + let result = dispatch(&req.tool_name, req.args); + let data = serde_json::to_vec(&result).unwrap_or_default(); + sdk::leaked_result(&data) +} + +#[no_mangle] +pub extern "C" fn healthy() -> i32 { + match bland_get("/voices") { + Ok(_) => 1, + Err(_) => 0, + } +} + +#[no_mangle] +pub extern "C" fn compact_specs() -> u64 { + sdk::leaked_compact_specs(&compact_spec_map()) +} + +type HandlerFn = fn(HashMap) -> sdk::ToolResult; + +fn dispatch(tool_name: &str, args: HashMap) -> sdk::ToolResult { + let handler: Option = match tool_name { + "bland_list_calls" => Some(handlers::list_calls), + "bland_list_active_calls" => Some(handlers::list_active_calls), + "bland_get_call" => Some(handlers::get_call), + "bland_send_call" => Some(handlers::send_call), + "bland_stop_call" => Some(handlers::stop_call), + "bland_analyze_call" => Some(handlers::analyze_call), + "bland_list_voices" => Some(handlers::list_voices), + "bland_get_voice" => Some(handlers::get_voice), + "bland_list_pathways" => Some(handlers::list_pathways), + "bland_get_pathway" => Some(handlers::get_pathway), + "bland_list_numbers" => Some(handlers::list_numbers), + "bland_get_number" => Some(handlers::get_number), + "bland_list_knowledge_bases" => Some(handlers::list_knowledge_bases), + "bland_get_knowledge_base" => Some(handlers::get_knowledge_base), + "bland_get_me" => Some(handlers::get_me), + "bland_create_org" => Some(handlers::create_org), + "bland_get_org" => Some(handlers::get_org), + "bland_delete_org" => Some(handlers::delete_org), + "bland_update_org_properties" => Some(handlers::update_org_properties), + "bland_list_org_members" => Some(handlers::list_org_members), + "bland_update_org_members" => Some(handlers::update_org_members), + "bland_update_org_member_permissions" => Some(handlers::update_org_member_permissions), + "bland_list_my_org_memberships" => Some(handlers::list_my_org_memberships), + "bland_leave_org" => Some(handlers::leave_org), + "bland_get_org_billing" => Some(handlers::get_org_billing), + "bland_get_org_billing_refill" => Some(handlers::get_org_billing_refill), + "bland_get_org_current_version" => Some(handlers::get_org_current_version), + "bland_list_org_versions" => Some(handlers::list_org_versions), + "bland_update_org_version" => Some(handlers::update_org_version), + "bland_list_audit_logs" => Some(handlers::list_audit_logs), + _ => None, + }; + + match handler { + Some(f) => f(args), + None => sdk::err_result(&format!("unknown tool: {tool_name}")), + } +} + +pub(crate) fn bland_get(path: &str) -> Result { + do_request("GET", path, None) +} + +pub(crate) fn bland_post( + path: &str, + body: &serde_json::Value, +) -> Result { + do_request("POST", path, Some(body)) +} + +pub(crate) fn bland_patch( + path: &str, + body: &serde_json::Value, +) -> Result { + do_request("PATCH", path, Some(body)) +} + +pub(crate) fn bland_delete( + path: &str, + body: Option<&serde_json::Value>, +) -> Result { + do_request("DELETE", path, body) +} + +fn do_request( + method: &str, + path: &str, + body: Option<&serde_json::Value>, +) -> Result { + let (api_key, org_id) = with_config(|c| (c.api_key.clone(), c.org_id.clone()))?; + + let mut headers = HashMap::new(); + headers.insert("Authorization".into(), api_key); + headers.insert("Accept".into(), "application/json".into()); + if !org_id.is_empty() { + headers.insert("x-bland-org-id".into(), org_id); + } + + let body_str = if let Some(b) = body { + headers.insert("Content-Type".into(), "application/json".into()); + serde_json::to_string(b).map_err(|e| format!("bland: encode request: {e}"))? + } else { + String::new() + }; + + let req = sdk::HttpRequest { + method: method.into(), + url: format!("{API_BASE}{path}"), + headers, + body: body_str, + ..Default::default() + }; + + let resp = sdk::host_http_request(&req)?; + if resp.status >= 400 { + return Err(format!("bland API error ({}): {}", resp.status, resp.body)); + } + if resp.status == 204 || resp.body.is_empty() { + return Ok(serde_json::json!({"status": "success"})); + } + serde_json::from_str(&resp.body).map_err(|e| format!("bland: decode response: {e}")) +} + +pub(crate) fn path_escape(s: &str) -> String { + encode_component(s) +} + +pub(crate) fn query_escape(s: &str) -> String { + encode_component(s) +} + +fn encode_component(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.as_bytes() { + let c = *b; + let safe = c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~'); + if safe { + out.push(c as char); + } else { + out.push_str(&format!("%{c:02X}")); + } + } + out +} + +fn compact_spec_map() -> HashMap> { + let mut s: HashMap> = HashMap::new(); + + s.insert( + "bland_list_calls".into(), + vec![ + "count".into(), + "total_count".into(), + "calls[].call_id".into(), + "calls[].created_at".into(), + "calls[].to".into(), + "calls[].from".into(), + "calls[].call_length".into(), + "calls[].completed".into(), + "calls[].queue_status".into(), + "calls[].status".into(), + "calls[].error_message".into(), + "calls[].answered_by".into(), + "calls[].inbound".into(), + "calls[].batch_id".into(), + ], + ); + s.insert( + "bland_list_active_calls".into(), + vec![ + "calls[].call_id".into(), + "calls[].phone_number".into(), + "calls[].to".into(), + "calls[].from".into(), + "calls[].status".into(), + "calls[].created_at".into(), + "calls[].started_at".into(), + ], + ); + s.insert( + "bland_get_call".into(), + vec![ + "call_id".into(), + "created_at".into(), + "to".into(), + "from".into(), + "call_length".into(), + "status".into(), + "completed".into(), + "answered_by".into(), + "summary".into(), + "concatenated_transcript".into(), + "transcripts[].created_at".into(), + "transcripts[].user".into(), + "transcripts[].text".into(), + "analysis".into(), + "variables".into(), + "metadata".into(), + ], + ); + s.insert( + "bland_list_voices".into(), + vec![ + "voices[].id".into(), + "voices[].name".into(), + "voices[].description".into(), + "voices[].public".into(), + "voices[].tags".into(), + "voices[].total_ratings".into(), + "voices[].average_rating".into(), + ], + ); + s.insert( + "bland_get_voice".into(), + vec![ + "id".into(), + "name".into(), + "description".into(), + "public".into(), + "tags".into(), + "total_ratings".into(), + "average_rating".into(), + "language".into(), + ], + ); + s.insert( + "bland_list_pathways".into(), + vec![ + "pathways[].id".into(), + "pathways[].pathway_id".into(), + "pathways[].name".into(), + "pathways[].description".into(), + "pathways[].created_at".into(), + "pathways[].updated_at".into(), + ], + ); + s.insert( + "bland_get_pathway".into(), + vec![ + "id".into(), + "pathway_id".into(), + "name".into(), + "description".into(), + "created_at".into(), + "updated_at".into(), + "nodes".into(), + "edges".into(), + ], + ); + s.insert( + "bland_list_numbers".into(), + vec![ + "numbers[].phone_number".into(), + "numbers[].label".into(), + "numbers[].inbound_agent".into(), + "numbers[].created_at".into(), + ], + ); + s.insert( + "bland_get_number".into(), + vec![ + "phone_number".into(), + "label".into(), + "inbound_agent".into(), + "webhook".into(), + "created_at".into(), + ], + ); + s.insert( + "bland_list_knowledge_bases".into(), + vec![ + "knowledge_bases[].id".into(), + "knowledge_bases[].name".into(), + "knowledge_bases[].description".into(), + "knowledge_bases[].created_at".into(), + "knowledge_bases[].updated_at".into(), + "knowledge_bases[].status".into(), + ], + ); + s.insert( + "bland_get_knowledge_base".into(), + vec![ + "id".into(), + "name".into(), + "description".into(), + "created_at".into(), + "updated_at".into(), + "status".into(), + "documents".into(), + ], + ); + s.insert( + "bland_get_me".into(), + vec![ + "status".into(), + "billing.current_balance".into(), + "billing.refill_to".into(), + "total_calls".into(), + ], + ); + s.insert( + "bland_get_org".into(), + vec![ + "data.id".into(), + "data.org_slug".into(), + "data.org_display_name".into(), + "data.org_plan".into(), + "data.org_creation_date".into(), + "data.kyc_level".into(), + "data.is_deleted".into(), + "data.is_stripe_overdue".into(), + "data.is_suspended".into(), + "data.org_rate_limit".into(), + "data.org_type".into(), + "data.entitlements".into(), + "data.preferences".into(), + ], + ); + s.insert( + "bland_list_org_members".into(), + vec![ + "data[].org_id".into(), + "data[].user_id".into(), + "data[].permissions".into(), + "data[].is_owner".into(), + "data[].is_org_creator".into(), + "data[].joined_at".into(), + "data[].first_name".into(), + "data[].last_name".into(), + "data[].member_email".into(), + "data[].member_phone_number".into(), + ], + ); + s.insert( + "bland_update_org_member_permissions".into(), + vec!["data.newPermissions".into(), "errors".into()], + ); + s.insert( + "bland_list_my_org_memberships".into(), + vec![ + "data[].org_id".into(), + "data[].org_slug".into(), + "data[].org_display_name".into(), + "data[].permissions".into(), + "data[].is_owner".into(), + "data[].is_org_creator".into(), + "data[].joined_at".into(), + ], + ); + s.insert( + "bland_get_org_billing".into(), + vec![ + "data.current_balance".into(), + "data.refill_amount".into(), + "data.refill_at".into(), + "errors".into(), + ], + ); + s.insert( + "bland_get_org_billing_refill".into(), + vec!["data".into(), "errors".into()], + ); + s.insert( + "bland_get_org_current_version".into(), + vec!["data.version".into(), "errors".into()], + ); + s.insert( + "bland_list_org_versions".into(), + vec![ + "data.versions[].id".into(), + "data.versions[].friendly_name".into(), + "data.versions[].created_at".into(), + "data.versions[].git_sha".into(), + "data.versions[].tags".into(), + "data.versions[].currently_supported".into(), + "data.versions[].recommended_upgrade_to".into(), + "data.versions[].service".into(), + "data.versions[].placement_group".into(), + ], + ); + s.insert( + "bland_list_audit_logs".into(), + vec![ + "data.events[].id".into(), + "data.events[].org_id".into(), + "data.events[].actor_id".into(), + "data.events[].event_type".into(), + "data.events[].resource_type".into(), + "data.events[].resource_id".into(), + "data.events[].metadata".into(), + "data.events[].created_at".into(), + "data.total".into(), + "data.total_pages".into(), + "data.current_page".into(), + "data.page_size".into(), + ], + ); + + s +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn tool_definitions_have_required_metadata() { + let defs = tools::tool_definitions(); + assert_eq!(defs.len(), 30); + + let mut seen = HashSet::new(); + for def in defs { + assert!(def.name.starts_with("bland_")); + assert!(!def.description.is_empty()); + assert!(seen.insert(def.name)); + } + } + + #[test] + fn entry_point_tools_have_start_here_guidance() { + for def in tools::tool_definitions() { + if matches!( + def.name.as_str(), + "bland_list_calls" + | "bland_list_voices" + | "bland_list_pathways" + | "bland_list_numbers" + | "bland_list_knowledge_bases" + ) { + assert!(def.description.contains("Start here"), "{}", def.name); + } + } + } + + #[test] + fn compact_specs_reference_known_tools() { + let defs = tools::tool_definitions() + .into_iter() + .map(|def| def.name) + .collect::>(); + for name in compact_spec_map().keys() { + assert!(defs.contains(name), "{name}"); + } + } + + #[test] + fn unknown_tool_returns_error_result() { + let result = dispatch("bland_missing_tool", HashMap::new()); + assert!(result.is_error); + assert!(result.data.contains("unknown tool")); + } + + #[test] + fn escapes_path_and_query_components() { + assert_eq!(path_escape("+1555 123/abc"), "%2B1555%20123%2Fabc"); + assert_eq!(query_escape("created_at desc"), "created_at%20desc"); + } +} diff --git a/plugins/bland/src/tools.rs b/plugins/bland/src/tools.rs new file mode 100644 index 0000000..adde1db --- /dev/null +++ b/plugins/bland/src/tools.rs @@ -0,0 +1,313 @@ +use std::collections::HashMap; +use switchboard_guest_sdk::ToolDefinition; + +macro_rules! tool { + ($name:expr, $desc:expr, $params:expr) => { + ToolDefinition { + name: $name.into(), + description: $desc.into(), + parameters: $params, + required: vec![], + } + }; + ($name:expr, $desc:expr, $params:expr, $req:expr) => { + ToolDefinition { + name: $name.into(), + description: $desc.into(), + parameters: $params, + required: $req.iter().map(|s: &&str| s.to_string()).collect(), + } + }; +} + +macro_rules! params { + () => { HashMap::new() }; + ($($k:expr => $v:expr),+ $(,)?) => {{ + let mut m: HashMap = HashMap::new(); + $(m.insert($k.into(), $v.into());)+ + m + }}; +} + +pub fn tool_definitions() -> Vec { + vec![ + tool!( + "bland_list_calls", + "List Bland.ai voice AI phone calls with status, phone numbers, duration, completion, errors, and transcript availability. Start here for call history, call logs, outbound calls, inbound calls, conversations, and voice agent debugging.", + params!( + "limit" => "Maximum calls to return (default 20, max 100)", + "from" => "Pagination start index (inclusive)", + "to" => "Pagination end index (exclusive)", + "ascending" => "Sort ascending instead of descending (true/false)", + "sort_by" => "Sort field: created_at or updated_at (default created_at)", + "start_date" => "Filter calls created on or after this ISO date/time", + "end_date" => "Filter calls created on or before this ISO date/time", + "batch_id" => "Filter by Bland batch ID", + "answered_by" => "Filter by answered_by result", + "inbound" => "Filter inbound calls (true/false)", + "completed" => "Filter completed calls (true/false)", + "from_number" => "Filter by originating phone number", + "to_number" => "Filter by destination phone number", + "duration_gt" => "Filter calls longer than this duration in seconds", + "duration_lt" => "Filter calls shorter than this duration in seconds" + ) + ), + tool!( + "bland_list_active_calls", + "List currently active live Bland.ai calls. Use for monitoring in-progress phone conversations before stopping or inspecting them.", + params!() + ), + tool!( + "bland_get_call", + "Get details for a specific Bland.ai call, including status, summary, transcript, corrected transcript fields, analysis, variables, metadata, and conversation timing. Use after list_calls when debugging a call or reading a transcript.", + params!( + "call_id" => "Bland call_id returned by list_calls or send_call" + ), + &["call_id"] + ), + tool!( + "bland_send_call", + "Send or schedule an outbound Bland.ai AI phone call. Creates a voice agent call using either a task prompt or pathway_id, with optional voice, first sentence, metadata, webhook, transfer number, and dynamic variables.", + params!( + "phone_number" => "Destination phone number in E.164 format (for example +15551234567)", + "task" => "Plain-language voice agent task/instructions. Required unless pathway_id is provided", + "pathway_id" => "Existing Bland pathway ID. Required unless task is provided", + "voice" => "Voice ID or voice name", + "first_sentence" => "Opening sentence the AI should say", + "model" => "Bland model name", + "language" => "Language code (for example en-US)", + "from" => "Bland phone number to call from", + "webhook" => "Webhook URL for call events/results", + "metadata" => "JSON object string attached to the call", + "dynamic_data" => "JSON object string for pathway/task variables", + "tools" => "JSON array string of Bland tool definitions", + "transfer_phone_number" => "Phone number to transfer to", + "timezone" => "Timezone for scheduling and context", + "max_duration" => "Maximum call duration in minutes", + "wait_for_greeting" => "Wait for recipient greeting before speaking (true/false)", + "record" => "Whether to record the call (true/false)", + "temperature" => "Model temperature", + "interruption_threshold" => "Interruption sensitivity threshold", + "background_track" => "Background audio track name" + ), + &["phone_number"] + ), + tool!( + "bland_stop_call", + "Stop an active Bland.ai phone call. Use after list_active_calls or get_call when a live voice conversation should be ended.", + params!( + "call_id" => "Bland call_id for the active call to stop" + ), + &["call_id"] + ), + tool!( + "bland_analyze_call", + "Analyze a completed Bland.ai call transcript with AI using a goal and structured questions. Use after get_call to extract outcomes, classifications, lead qualification, sentiment, or custom fields.", + params!( + "call_id" => "Bland call_id to analyze", + "goal" => "Analysis goal or rubric", + "questions" => "JSON array of question objects or strings to answer from the call" + ), + &["call_id", "goal", "questions"] + ), + tool!( + "bland_list_voices", + "List Bland.ai voices for text-to-speech and phone calls, including voice IDs, names, descriptions, tags, public/private status, and ratings. Start here for choosing a call voice.", + params!() + ), + tool!( + "bland_get_voice", + "Get details for a specific Bland.ai voice. Use after list_voices to inspect a voice before using it in send_call.", + params!( + "voice_id" => "Bland voice ID" + ), + &["voice_id"] + ), + tool!( + "bland_list_pathways", + "List Bland.ai conversational pathways and voice agent flows. Start here for discovering pathway IDs to use when sending calls.", + params!() + ), + tool!( + "bland_get_pathway", + "Get a Bland.ai pathway's full configuration. Use after list_pathways to inspect a conversational flow before sending calls with pathway_id.", + params!( + "pathway_id" => "Bland pathway ID" + ), + &["pathway_id"] + ), + tool!( + "bland_list_numbers", + "List Bland.ai inbound phone numbers configured on the account. Start here for phone number inventory, inbound agents, and call routing setup.", + params!( + "encrypted_key" => "Optional encrypted key filter used by Bland for number lookup" + ) + ), + tool!( + "bland_get_number", + "Get details for a specific Bland.ai inbound phone number, including inbound agent and routing configuration. Use after list_numbers.", + params!( + "phone_number" => "Inbound phone number to inspect, usually in E.164 format" + ), + &["phone_number"] + ), + tool!( + "bland_list_knowledge_bases", + "List Bland.ai knowledge bases used by voice agents and pathways. Start here for discovering retrieval sources available to calls.", + params!( + "page" => "Page number (default 1)", + "limit" => "Maximum knowledge bases per page (default 20)" + ) + ), + tool!( + "bland_get_knowledge_base", + "Get a Bland.ai knowledge base's details and documents. Use after list_knowledge_bases to inspect retrieval content available to voice agents.", + params!( + "knowledge_base_id" => "Bland knowledge base ID" + ), + &["knowledge_base_id"] + ), + tool!( + "bland_get_me", + "Get the current Bland.ai account details, billing balance, and total call count. Start here for account management and credential verification.", + params!() + ), + tool!( + "bland_create_org", + "Create a new Bland.ai organization/workspace. Use for account and org management setup.", + params!( + "name" => "Organization display name" + ), + &["name"] + ), + tool!( + "bland_get_org", + "Get a Bland.ai organization's details, slug, plan, preferences, entitlements, rate limit, suspension, and deletion status. Use after list_my_org_memberships.", + params!( + "org_id" => "Bland organization ID" + ), + &["org_id"] + ), + tool!( + "bland_delete_org", + "Delete a Bland.ai organization. Requires delete_confirm with the organization slug for safety.", + params!( + "org_id" => "Bland organization ID", + "delete_confirm" => "Organization slug confirmation required by Bland" + ), + &["org_id", "delete_confirm"] + ), + tool!( + "bland_update_org_properties", + "Update Bland.ai organization properties such as display name and preferences including use_bland_url and recording retention lifespan. Use after get_org.", + params!( + "org_id" => "Bland organization ID", + "org_display_name" => "New organization display name (1-30 characters)", + "use_bland_url" => "Whether to use Bland-hosted URLs (true/false)", + "recording_lifespan_days" => "Recording retention in days (1-1825, or -1 to disable)", + "preferences" => "JSON object string for advanced preferences override" + ), + &["org_id"] + ), + tool!( + "bland_list_org_members", + "List Bland.ai organization members with emails, phone numbers, permissions, owner/admin/operator/viewer roles, join dates, and org metadata. Start here for org user management.", + params!( + "org_id" => "Bland organization ID" + ), + &["org_id"] + ), + tool!( + "bland_update_org_members", + "Add or remove Bland.ai organization members and invites. Use for org member management after list_org_members.", + params!( + "org_id" => "Bland organization ID", + "action" => "Member action: add or remove", + "target" => "Target user ID", + "permissions" => "JSON array of permissions for add: owner, admin, operator, viewer", + "is_invite" => "Whether removal applies to an invite (true/false)" + ), + &["org_id", "action", "target"] + ), + tool!( + "bland_update_org_member_permissions", + "Update Bland.ai organization member permissions and roles. Supports add, remove, reset, or set permissions: owner, admin, operator, viewer. Use after list_org_members.", + params!( + "org_id" => "Bland organization ID", + "action" => "Permission action: add, remove, reset, or set", + "target" => "Target user ID", + "permissions" => "JSON array of permissions: owner, admin, operator, viewer" + ), + &["org_id", "action", "target", "permissions"] + ), + tool!( + "bland_list_my_org_memberships", + "List Bland.ai organizations the current user belongs to, including org IDs, slugs, display names, permissions, owner status, and join dates. Start here to discover org_id values for management tools.", + params!() + ), + tool!( + "bland_leave_org", + "Leave a Bland.ai organization as the current user. Use after list_my_org_memberships.", + params!( + "org_id" => "Bland organization ID to leave" + ), + &["org_id"] + ), + tool!( + "bland_get_org_billing", + "Get Bland.ai organization billing information including current balance, refill amount, and refill threshold. Start here for org billing management.", + params!( + "org_id" => "Bland organization ID" + ), + &["org_id"] + ), + tool!( + "bland_get_org_billing_refill", + "Get Bland.ai organization billing refill threshold information. Use after get_org_billing.", + params!( + "org_id" => "Bland organization ID" + ), + &["org_id"] + ), + tool!( + "bland_get_org_current_version", + "Get the current Bland.ai service version for an organization. Supports api_server and ws_server services.", + params!( + "org_id" => "Bland organization ID", + "service" => "Service name: api_server (default) or ws_server" + ), + &["org_id"] + ), + tool!( + "bland_list_org_versions", + "List available Bland.ai service versions for an organization, including support status and recommended upgrades. Supports api_server and ws_server services.", + params!( + "org_id" => "Bland organization ID", + "service" => "Service name: api_server (default) or ws_server" + ), + &["org_id"] + ), + tool!( + "bland_update_org_version", + "Update a Bland.ai organization's service version. Supports api_server and ws_server services. Use after list_org_versions.", + params!( + "org_id" => "Bland organization ID", + "service" => "Service name: api_server (default) or ws_server", + "version" => "Version identifier to switch to" + ), + &["org_id", "version"] + ), + tool!( + "bland_list_audit_logs", + "List Bland.ai audit logs for enterprise org compliance, security, admin activity, pathway changes, knowledge base updates, and SSO events. Start here for compliance and audit investigations.", + params!( + "event_type" => "Optional exact event type filter", + "actor_id" => "Optional user ID filter", + "created_after" => "Optional ISO 8601 lower bound", + "created_before" => "Optional ISO 8601 upper bound", + "page" => "Page number (default 1)", + "page_size" => "Page size (default 50, max 100)" + ) + ), + ] +}