diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2650c9b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,133 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "looker-wasm" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "switchboard-guest-sdk", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "switchboard-guest-sdk" +version = "0.1.0" +source = "git+https://github.com/daltoniam/switchboard.git#62423750d8f845cca1e14e840a81d0775e094c54" +dependencies = [ + "base64", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/README.md b/README.md index dda39b4..c9a7d63 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ A home for integrations that make more sense as standalone WASM modules than as | Plugin | Tools | Description | |--------|-------|-------------| -| _none yet_ | | | +| [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/dist/looker.wasm b/dist/looker.wasm new file mode 100755 index 0000000..4514c39 Binary files /dev/null and b/dist/looker.wasm differ diff --git a/manifest.json b/manifest.json index db60b1b..6bca8bd 100644 --- a/manifest.json +++ b/manifest.json @@ -2,5 +2,44 @@ "schema_version": 1, "name": "daltoniam-plugins", "description": "Third-party Switchboard WASM plugins by @daltoniam", - "plugins": [] + "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.", + "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/looker.wasm", + "sha256": "32ca911794ce5df434193ec34328ff4185e55b2129c40e68ea4f196e7af6b40f", + "size": 221638, + "released_at": "2026-05-15T19:27:11Z", + "changelog": "Initial release. 23 tools across search, folders, Looks, dashboards, queries, SQL Runner, models, connections, users, and scheduled plans." + } + ] + } + ] } 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)" + ) + ), + ] +} diff --git a/plugins/looker/Cargo.toml b/plugins/looker/Cargo.toml new file mode 100644 index 0000000..080cca8 --- /dev/null +++ b/plugins/looker/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "looker-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/looker/README.md b/plugins/looker/README.md new file mode 100644 index 0000000..e83d473 --- /dev/null +++ b/plugins/looker/README.md @@ -0,0 +1,53 @@ +# looker + +[Looker](https://cloud.google.com/looker) BI integration for Switchboard — query dashboards, Looks, LookML models, ad-hoc analytics, and the SQL Runner from any MCP-compatible client. + +## Install + +In the Switchboard web UI, **Plugin Marketplace → Add manifest URL**: + +``` +https://raw.githubusercontent.com/daltoniam/switchboard_plugins/main/manifest.json +``` + +Then enable the `looker` plugin and configure credentials. + +## Credentials + +| Key | Required | Plaintext | Example | +|-----|----------|-----------|---------| +| `base_url` | ✅ | yes | `https://your-instance.cloud.looker.com:19999` (with or without `/api/4.0`) | +| `client_id` | ✅ | yes | Looker API3 client_id | +| `client_secret` | ✅ | no | Looker API3 client_secret | + +Create an API3 key from **Admin → Users → Edit user → API3 Keys** in your Looker instance. + +## Tools (23) + +| Domain | Tools | +|--------|-------| +| Search | `looker_search_content` (entry point) | +| Folders | `looker_list_folders`, `looker_get_folder` | +| Looks | `looker_list_looks`, `looker_get_look`, `looker_run_look` | +| Dashboards | `looker_list_dashboards`, `looker_get_dashboard` | +| Queries | `looker_run_inline_query` (BI entry point), `looker_run_query`, `looker_get_query`, `looker_create_query` | +| SQL Runner | `looker_run_sql_query` | +| Models | `looker_list_models`, `looker_get_model`, `looker_get_model_explore` | +| Connections | `looker_list_connections`, `looker_get_connection` | +| Users | `looker_get_me`, `looker_list_users`, `looker_get_user` | +| Schedules | `looker_list_scheduled_plans`, `looker_get_scheduled_plan` | + +All `_run_*` and `_create_*` tools clamp `limit` to a max of **5,000 rows** (default 100) to prevent runaway responses. + +## Auth + +Uses Looker's [API3 client_id/client_secret](https://cloud.google.com/looker/docs/api-auth) login flow. Tokens are cached in WASM module state with a 30s safety window and transparently refreshed on `401`. + +## Build + +From the workspace root: + +```bash +cargo build --release --target wasm32-wasip1 -p looker-wasm +cp target/wasm32-wasip1/release/looker_wasm.wasm dist/looker.wasm +``` diff --git a/plugins/looker/src/handlers.rs b/plugins/looker/src/handlers.rs new file mode 100644 index 0000000..be57e40 --- /dev/null +++ b/plugins/looker/src/handlers.rs @@ -0,0 +1,432 @@ +use std::collections::HashMap; +use switchboard_guest_sdk as sdk; + +use crate::{clamp_limit, looker_get, looker_post, path_escape, query_escape, DEFAULT_ROW_LIMIT}; + +// ── Small helpers ────────────────────────────────────────────────────────── + +fn split_csv(s: &str) -> Vec { + s.split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect() +} + +/// Return the inner Value on success or an `err_result` ToolResult. +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}")), + } +} + +// ── Search ───────────────────────────────────────────────────────────────── + +/// Run unified search across dashboards, Looks, and folders. Looker has +/// separate `/dashboards/search`, `/looks/search`, `/folders/search` +/// endpoints (no unified `/search`). Fan out to whichever types are requested +/// and merge results. +pub fn search_content(args: HashMap) -> sdk::ToolResult { + let terms = sdk::arg_str(&args, "terms"); + if terms.is_empty() { + return sdk::err_result("terms is required"); + } + let types = sdk::arg_str(&args, "types"); + let limit = sdk::arg_int(&args, "limit").unwrap_or(25); + let offset = sdk::arg_int(&args, "offset").unwrap_or(0); + + let mut wanted = [true; 3]; // [dashboard, look, folder] + if !types.is_empty() { + wanted = [false; 3]; + for t in types.split(',') { + match t.trim().to_lowercase().as_str() { + "dashboard" => wanted[0] = true, + "look" => wanted[1] = true, + "folder" => wanted[2] = true, + _ => {} + } + } + } + + let mut out = serde_json::Map::new(); + + if wanted[0] { + let path = format!( + "/dashboards/search?title={}&limit={}&offset={}", + query_escape(&terms), + limit, + offset + ); + let v = call!(looker_get(&path)); + out.insert("dashboards".into(), v); + } + if wanted[1] { + let path = format!( + "/looks/search?title={}&limit={}&offset={}", + query_escape(&terms), + limit, + offset + ); + let v = call!(looker_get(&path)); + out.insert("looks".into(), v); + } + if wanted[2] { + // /folders/search uses `name` rather than `title`. + let path = format!( + "/folders/search?name={}&limit={}&offset={}", + query_escape(&terms), + limit, + offset + ); + let v = call!(looker_get(&path)); + out.insert("folders".into(), v); + } + + json_result(&serde_json::Value::Object(out)) +} + +// ── Folders ──────────────────────────────────────────────────────────────── + +pub fn list_folders(args: HashMap) -> sdk::ToolResult { + let fields = sdk::arg_str(&args, "fields"); + let path = if fields.is_empty() { + "/folders".to_string() + } else { + format!("/folders?fields={}", query_escape(&fields)) + }; + let v = call!(looker_get(&path)); + json_result(&v) +} + +pub fn get_folder(args: HashMap) -> sdk::ToolResult { + let id = sdk::arg_str(&args, "folder_id"); + if id.is_empty() { + return sdk::err_result("folder_id is required"); + } + let v = call!(looker_get(&format!("/folders/{}", path_escape(&id)))); + json_result(&v) +} + +// ── Looks ────────────────────────────────────────────────────────────────── + +pub fn list_looks(args: HashMap) -> sdk::ToolResult { + let limit = sdk::arg_int(&args, "limit").unwrap_or(25); + let offset = sdk::arg_int(&args, "offset").unwrap_or(0); + let fields = sdk::arg_str(&args, "fields"); + let mut path = format!("/looks?limit={limit}&offset={offset}"); + if !fields.is_empty() { + path.push_str(&format!("&fields={}", query_escape(&fields))); + } + let v = call!(looker_get(&path)); + json_result(&v) +} + +pub fn get_look(args: HashMap) -> sdk::ToolResult { + let id = sdk::arg_str(&args, "look_id"); + if id.is_empty() { + return sdk::err_result("look_id is required"); + } + let v = call!(looker_get(&format!("/looks/{}", path_escape(&id)))); + json_result(&v) +} + +pub fn run_look(args: HashMap) -> sdk::ToolResult { + let id = sdk::arg_str(&args, "look_id"); + if id.is_empty() { + return sdk::err_result("look_id is required"); + } + let raw_limit = sdk::arg_int(&args, "limit").unwrap_or(0); + let apply_fmt = sdk::arg_bool(&args, "apply_formatting").unwrap_or(false); + let apply_vis = sdk::arg_bool(&args, "apply_vis").unwrap_or(false); + let cache_arg = args.get("cache"); + + let mut q = format!("limit={}", clamp_limit(raw_limit)); + if apply_fmt { + q.push_str("&apply_formatting=true"); + } + if apply_vis { + q.push_str("&apply_vis=true"); + } + if let Some(v) = cache_arg { + let on = sdk::arg_bool(&args, "cache").unwrap_or(true); + if !on && !v.is_null() { + q.push_str("&cache=false"); + } + } + let v = call!(looker_get(&format!( + "/looks/{}/run/json?{q}", + path_escape(&id) + ))); + json_result(&v) +} + +// ── Dashboards ───────────────────────────────────────────────────────────── + +pub fn list_dashboards(args: HashMap) -> sdk::ToolResult { + let limit = sdk::arg_int(&args, "limit").unwrap_or(25); + let offset = sdk::arg_int(&args, "offset").unwrap_or(0); + let fields = sdk::arg_str(&args, "fields"); + let mut path = format!("/dashboards?limit={limit}&offset={offset}"); + if !fields.is_empty() { + path.push_str(&format!("&fields={}", query_escape(&fields))); + } + let v = call!(looker_get(&path)); + json_result(&v) +} + +pub fn get_dashboard(args: HashMap) -> sdk::ToolResult { + let id = sdk::arg_str(&args, "dashboard_id"); + if id.is_empty() { + return sdk::err_result("dashboard_id is required"); + } + let v = call!(looker_get(&format!("/dashboards/{}", path_escape(&id)))); + json_result(&v) +} + +// ── Queries ──────────────────────────────────────────────────────────────── + +/// Build the JSON body shared by `POST /queries` and `POST /queries/run/{format}`. +fn build_query_body( + args: &HashMap, + limit_override: i64, +) -> Result { + let model = sdk::arg_str(args, "model"); + let view = sdk::arg_str(args, "view"); + let fields = sdk::arg_str(args, "fields"); + if model.is_empty() || view.is_empty() || fields.is_empty() { + return Err("model, view, and fields are required".into()); + } + let mut body = serde_json::json!({ + "model": model, + "view": view, + "fields": split_csv(&fields), + }); + let obj = body.as_object_mut().unwrap(); + let sorts = sdk::arg_str(args, "sorts"); + if !sorts.is_empty() { + obj.insert("sorts".into(), serde_json::json!(split_csv(&sorts))); + } + let pivots = sdk::arg_str(args, "pivots"); + if !pivots.is_empty() { + obj.insert("pivots".into(), serde_json::json!(split_csv(&pivots))); + } + let filters = sdk::arg_str(args, "filters"); + if !filters.is_empty() { + let parsed: serde_json::Value = serde_json::from_str(&filters) + .map_err(|e| format!("filters must be a JSON object: {e}"))?; + if !parsed.is_object() { + return Err("filters must be a JSON object".into()); + } + obj.insert("filters".into(), parsed); + } + if limit_override > 0 { + obj.insert( + "limit".into(), + serde_json::json!(limit_override.to_string()), + ); + } + Ok(body) +} + +pub fn run_inline_query(args: HashMap) -> sdk::ToolResult { + let raw_limit = sdk::arg_int(&args, "limit").unwrap_or(0); + let limit = clamp_limit(raw_limit); + let body = match build_query_body(&args, limit) { + Ok(b) => b, + Err(e) => return sdk::err_result(&e), + }; + let v = call!(looker_post("/queries/run/json", &body)); + json_result(&v) +} + +pub fn create_query(args: HashMap) -> sdk::ToolResult { + let raw_limit = sdk::arg_int(&args, "limit").unwrap_or(500); + let body = match build_query_body(&args, clamp_limit(raw_limit)) { + Ok(b) => b, + Err(e) => return sdk::err_result(&e), + }; + let v = call!(looker_post("/queries", &body)); + json_result(&v) +} + +pub fn get_query(args: HashMap) -> sdk::ToolResult { + let id = sdk::arg_str(&args, "query_id"); + if id.is_empty() { + return sdk::err_result("query_id is required"); + } + let v = call!(looker_get(&format!("/queries/{}", path_escape(&id)))); + json_result(&v) +} + +pub fn run_query(args: HashMap) -> sdk::ToolResult { + let id = sdk::arg_str(&args, "query_id"); + if id.is_empty() { + return sdk::err_result("query_id is required"); + } + let raw_limit = sdk::arg_int(&args, "limit").unwrap_or(0); + let apply_fmt = sdk::arg_bool(&args, "apply_formatting").unwrap_or(false); + + let mut q = format!("limit={}", clamp_limit(raw_limit)); + if apply_fmt { + q.push_str("&apply_formatting=true"); + } + if let Some(v) = args.get("cache") { + let on = sdk::arg_bool(&args, "cache").unwrap_or(true); + if !on && !v.is_null() { + q.push_str("&cache=false"); + } + } + let v = call!(looker_get(&format!( + "/queries/{}/run/json?{q}", + path_escape(&id) + ))); + json_result(&v) +} + +// ── SQL Runner ───────────────────────────────────────────────────────────── + +/// Looker requires the two-step SQL Runner pattern: POST /sql_queries to +/// register a query, then GET /sql_queries/{slug}/run/{format} to run it. +pub fn run_sql_query(args: HashMap) -> sdk::ToolResult { + let conn = sdk::arg_str(&args, "connection_name"); + let sql = sdk::arg_str(&args, "sql"); + if conn.is_empty() || sql.is_empty() { + return sdk::err_result("connection_name and sql are required"); + } + let body = serde_json::json!({ + "connection_name": conn, + "sql": sql, + }); + let created = call!(looker_post("/sql_queries", &body)); + let slug = created + .get("slug") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if slug.is_empty() { + return sdk::err_result("sql_query: missing slug in response"); + } + let v = call!(looker_get(&format!( + "/sql_queries/{}/run/json", + path_escape(&slug) + ))); + json_result(&v) +} + +// ── LookML Models ────────────────────────────────────────────────────────── + +pub fn list_models(_args: HashMap) -> sdk::ToolResult { + let v = call!(looker_get("/lookml_models")); + json_result(&v) +} + +pub fn get_model(args: HashMap) -> sdk::ToolResult { + let name = sdk::arg_str(&args, "model_name"); + if name.is_empty() { + return sdk::err_result("model_name is required"); + } + let v = call!(looker_get(&format!( + "/lookml_models/{}", + path_escape(&name) + ))); + json_result(&v) +} + +pub fn get_model_explore(args: HashMap) -> sdk::ToolResult { + let model = sdk::arg_str(&args, "model_name"); + let explore = sdk::arg_str(&args, "explore_name"); + if model.is_empty() || explore.is_empty() { + return sdk::err_result("model_name and explore_name are required"); + } + let v = call!(looker_get(&format!( + "/lookml_models/{}/explores/{}", + path_escape(&model), + path_escape(&explore) + ))); + json_result(&v) +} + +// ── Connections ──────────────────────────────────────────────────────────── + +pub fn list_connections(_args: HashMap) -> sdk::ToolResult { + let v = call!(looker_get("/connections")); + json_result(&v) +} + +pub fn get_connection(args: HashMap) -> sdk::ToolResult { + let name = sdk::arg_str(&args, "connection_name"); + if name.is_empty() { + return sdk::err_result("connection_name is required"); + } + let v = call!(looker_get(&format!("/connections/{}", path_escape(&name)))); + json_result(&v) +} + +// ── Users ────────────────────────────────────────────────────────────────── + +pub fn get_me(_args: HashMap) -> sdk::ToolResult { + let v = call!(looker_get("/user")); + json_result(&v) +} + +pub fn list_users(args: HashMap) -> sdk::ToolResult { + let limit = sdk::arg_int(&args, "limit").unwrap_or(25); + let offset = sdk::arg_int(&args, "offset").unwrap_or(0); + let v = call!(looker_get(&format!("/users?limit={limit}&offset={offset}"))); + json_result(&v) +} + +pub fn get_user(args: HashMap) -> sdk::ToolResult { + let id = sdk::arg_str(&args, "user_id"); + if id.is_empty() { + return sdk::err_result("user_id is required"); + } + let v = call!(looker_get(&format!("/users/{}", path_escape(&id)))); + json_result(&v) +} + +// ── Scheduled plans ──────────────────────────────────────────────────────── + +pub fn list_scheduled_plans(args: HashMap) -> sdk::ToolResult { + let user_id = sdk::arg_str(&args, "user_id"); + let all = sdk::arg_bool(&args, "all").unwrap_or(false); + let mut parts: Vec = Vec::new(); + if !user_id.is_empty() { + parts.push(format!("user_id={}", query_escape(&user_id))); + } + if all { + parts.push("all_users=true".into()); + } + let path = if parts.is_empty() { + "/scheduled_plans".to_string() + } else { + format!("/scheduled_plans?{}", parts.join("&")) + }; + let v = call!(looker_get(&path)); + json_result(&v) +} + +pub fn get_scheduled_plan(args: HashMap) -> sdk::ToolResult { + let id = sdk::arg_str(&args, "scheduled_plan_id"); + if id.is_empty() { + return sdk::err_result("scheduled_plan_id is required"); + } + let v = call!(looker_get(&format!( + "/scheduled_plans/{}", + path_escape(&id) + ))); + json_result(&v) +} + +// Suppress unused-import warning when DEFAULT_ROW_LIMIT is only conceptually used. +#[allow(dead_code)] +const _: i64 = DEFAULT_ROW_LIMIT; diff --git a/plugins/looker/src/lib.rs b/plugins/looker/src/lib.rs new file mode 100644 index 0000000..e39830d --- /dev/null +++ b/plugins/looker/src/lib.rs @@ -0,0 +1,706 @@ +mod handlers; +mod tools; + +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; +use switchboard_guest_sdk as sdk; + +// ── Constants ────────────────────────────────────────────────────────────── + +/// Hard cap on rows returned from any inline/saved query. Prevents +/// accidentally pulling millions of rows into context. +pub(crate) const MAX_ROW_LIMIT: i64 = 5000; + +/// Default row cap when a caller omits `limit`. +pub(crate) const DEFAULT_ROW_LIMIT: i64 = 100; + +/// Buffer subtracted from the Looker `access_token` expiry before treating it +/// as expired. Avoids racing on the wire-side TTL. +const TOKEN_SAFETY_WINDOW_SECS: u64 = 30; + +/// Fallback expiry if the Looker login response omits `expires_in`. +const DEFAULT_TOKEN_TTL_SECS: u64 = 15 * 60; + +// ── Config + token cache ─────────────────────────────────────────────────── + +struct Config { + base_url: String, // includes /api/4.0 + client_id: String, + client_secret: String, +} + +struct TokenCache { + token: String, + /// Unix seconds when the token expires. + expires_at: u64, +} + +static CONFIG: Mutex> = Mutex::new(None); +static TOKEN: 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("looker: not configured".into()), + } +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +// ── Required ABI exports ─────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn name() -> u64 { + sdk::leaked_string("looker") +} + +#[no_mangle] +pub extern "C" fn metadata() -> u64 { + sdk::leaked_metadata(&sdk::PluginMetadata { + name: "looker".into(), + version: "0.1.0".into(), + abi_version: 1, + description: "Looker BI integration — dashboards, Looks, LookML models, inline analytics queries, SQL Runner.".into(), + author: "daltoniam".into(), + homepage: "https://github.com/daltoniam/switchboard_plugins".into(), + license: "MIT".into(), + capabilities: vec!["http".into()], + credential_keys: vec!["base_url".into(), "client_id".into(), "client_secret".into()], + plain_text_keys: vec!["base_url".into(), "client_id".into()], + optional_keys: vec![], + placeholders: HashMap::from([ + ("base_url".into(), "https://your-instance.cloud.looker.com:19999".into()), + ("client_id".into(), "Looker API3 client_id".into()), + ("client_secret".into(), "Looker API3 client_secret".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!("looker: invalid credentials JSON: {e}")), + }; + + let client_id = creds.get("client_id").cloned().unwrap_or_default(); + let client_secret = creds.get("client_secret").cloned().unwrap_or_default(); + let raw = creds + .get("base_url") + .map(|s| s.trim_end_matches('/').to_string()) + .unwrap_or_default(); + + if client_id.is_empty() { + return sdk::leaked_string("looker: client_id is required"); + } + if client_secret.is_empty() { + return sdk::leaked_string("looker: client_secret is required"); + } + if raw.is_empty() { + return sdk::leaked_string( + "looker: base_url is required (e.g., https://your-instance.cloud.looker.com:19999)", + ); + } + + // Normalize: ensure /api/4.0 suffix. If the user pasted only the host, + // append it; if they pasted the API root, leave it alone. + let base_url = if raw.contains("/api/") { + raw + } else { + format!("{raw}/api/4.0") + }; + + *CONFIG.lock().unwrap() = Some(Config { + base_url, + client_id, + client_secret, + }); + // Reset any cached token from a previous configuration. + *TOKEN.lock().unwrap() = None; + 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 looker_get("/user") { + Ok(_) => 1, + Err(_) => 0, + } +} + +#[no_mangle] +pub extern "C" fn compact_specs() -> u64 { + sdk::leaked_compact_specs(&compact_spec_map()) +} + +// ── Dispatch ─────────────────────────────────────────────────────────────── + +type HandlerFn = fn(HashMap) -> sdk::ToolResult; + +fn dispatch(tool_name: &str, args: HashMap) -> sdk::ToolResult { + let handler: Option = match tool_name { + // Search + "looker_search_content" => Some(handlers::search_content), + // Folders + "looker_list_folders" => Some(handlers::list_folders), + "looker_get_folder" => Some(handlers::get_folder), + // Looks + "looker_list_looks" => Some(handlers::list_looks), + "looker_get_look" => Some(handlers::get_look), + "looker_run_look" => Some(handlers::run_look), + // Dashboards + "looker_list_dashboards" => Some(handlers::list_dashboards), + "looker_get_dashboard" => Some(handlers::get_dashboard), + // Queries + "looker_run_inline_query" => Some(handlers::run_inline_query), + "looker_run_query" => Some(handlers::run_query), + "looker_get_query" => Some(handlers::get_query), + "looker_create_query" => Some(handlers::create_query), + // SQL Runner + "looker_run_sql_query" => Some(handlers::run_sql_query), + // Models + "looker_list_models" => Some(handlers::list_models), + "looker_get_model" => Some(handlers::get_model), + "looker_get_model_explore" => Some(handlers::get_model_explore), + // Connections + "looker_list_connections" => Some(handlers::list_connections), + "looker_get_connection" => Some(handlers::get_connection), + // Users + "looker_get_me" => Some(handlers::get_me), + "looker_list_users" => Some(handlers::list_users), + "looker_get_user" => Some(handlers::get_user), + // Schedules + "looker_list_scheduled_plans" => Some(handlers::list_scheduled_plans), + "looker_get_scheduled_plan" => Some(handlers::get_scheduled_plan), + _ => None, + }; + + match handler { + Some(f) => f(args), + None => sdk::err_result(&format!("unknown tool: {tool_name}")), + } +} + +// ── Auth ─────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct LoginResponse { + access_token: String, + #[serde(default)] + expires_in: u64, +} + +/// Return a cached, unexpired access token, fetching/refreshing as needed. +/// Process-scoped (module-instance-scoped) cache. +fn ensure_token() -> Result { + { + let guard = TOKEN.lock().map_err(|e| e.to_string())?; + if let Some(tc) = guard.as_ref() { + if !tc.token.is_empty() + && now_secs().saturating_add(TOKEN_SAFETY_WINDOW_SECS) < tc.expires_at + { + return Ok(tc.token.clone()); + } + } + } + + let (base_url, client_id, client_secret) = with_config(|c| { + ( + c.base_url.clone(), + c.client_id.clone(), + c.client_secret.clone(), + ) + })?; + + let body = format!( + "client_id={}&client_secret={}", + form_encode(&client_id), + form_encode(&client_secret), + ); + + let mut headers = HashMap::new(); + headers.insert( + "Content-Type".into(), + "application/x-www-form-urlencoded".into(), + ); + + let req = sdk::HttpRequest { + method: "POST".into(), + url: format!("{base_url}/login"), + headers, + body, + ..Default::default() + }; + + let resp = sdk::host_http_request(&req)?; + if resp.status >= 400 { + return Err(format!( + "looker login failed ({}): {}", + resp.status, resp.body + )); + } + + let lr: LoginResponse = serde_json::from_str(&resp.body) + .map_err(|e| format!("looker login: decode response: {e}"))?; + if lr.access_token.is_empty() { + return Err("looker login: empty access_token".into()); + } + + let ttl = if lr.expires_in == 0 { + DEFAULT_TOKEN_TTL_SECS + } else { + lr.expires_in + }; + let expires_at = now_secs().saturating_add(ttl); + + *TOKEN.lock().map_err(|e| e.to_string())? = Some(TokenCache { + token: lr.access_token.clone(), + expires_at, + }); + Ok(lr.access_token) +} + +/// Form-encode a single value. Looker `client_id`/`client_secret` are typically +/// hex tokens, but we still escape to be safe. +fn form_encode(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 +} + +// ── HTTP helpers ─────────────────────────────────────────────────────────── + +/// Send a request to the Looker API and return the raw response body as a +/// `serde_json::Value`. On 401 transparently refresh the token and retry once. +fn do_request( + method: &str, + path: &str, + body: Option<&serde_json::Value>, +) -> Result { + let (raw, status) = send(method, path, body)?; + let (raw, status) = if status == 401 { + *TOKEN.lock().map_err(|e| e.to_string())? = None; + send(method, path, body)? + } else { + (raw, status) + }; + if status >= 400 { + return Err(format!("looker API error ({status}): {raw}")); + } + if status == 204 || raw.is_empty() { + return Ok(serde_json::json!({"status": "success"})); + } + serde_json::from_str(&raw).map_err(|e| format!("looker: decode response: {e}")) +} + +fn send( + method: &str, + path: &str, + body: Option<&serde_json::Value>, +) -> Result<(String, i32), String> { + let token = ensure_token()?; + let base_url = with_config(|c| c.base_url.clone())?; + + let mut headers = HashMap::new(); + headers.insert("Authorization".into(), format!("token {token}")); + headers.insert("Accept".into(), "application/json".into()); + let body_str = if let Some(b) = body { + headers.insert("Content-Type".into(), "application/json".into()); + serde_json::to_string(b).map_err(|e| e.to_string())? + } else { + String::new() + }; + + let req = sdk::HttpRequest { + method: method.into(), + url: format!("{base_url}{path}"), + headers, + body: body_str, + ..Default::default() + }; + + let resp = sdk::host_http_request(&req)?; + Ok((resp.body, resp.status)) +} + +pub(crate) fn looker_get(path: &str) -> Result { + do_request("GET", path, None) +} + +pub(crate) fn looker_post( + path: &str, + body: &serde_json::Value, +) -> Result { + do_request("POST", path, Some(body)) +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/// Apply the default and hard-cap row limits to a user-supplied value. +pub(crate) fn clamp_limit(n: i64) -> i64 { + if n <= 0 { + DEFAULT_ROW_LIMIT + } else if n > MAX_ROW_LIMIT { + MAX_ROW_LIMIT + } else { + n + } +} + +/// Percent-encode a path segment. Identical safe-char set to Go's +/// `url.PathEscape`: encodes everything except RFC 3986 unreserved chars. +pub(crate) fn path_escape(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 +} + +/// Encode a query-string value (spaces -> %20, no `+` substitution). +pub(crate) fn query_escape(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 +} + +// ── Compact specs ────────────────────────────────────────────────────────── + +fn compact_spec_map() -> HashMap> { + // Compaction specs cover read endpoints only. Tools that return user + // analytics data (run_inline_query, run_query, run_look, run_sql_query) + // intentionally have NO specs — their payload IS the answer and must pass + // through untouched. Mutations (create_query) also pass through unmodified. + let mut s: HashMap> = HashMap::new(); + + // Search — wrapper {dashboards:[],looks:[],folders:[]}. + s.insert( + "looker_search_content".into(), + vec![ + "dashboards[].id".into(), + "dashboards[].title".into(), + "dashboards[].description".into(), + "dashboards[].folder.name".into(), + "dashboards[].user_id".into(), + "dashboards[].view_count".into(), + "dashboards[].updated_at".into(), + "looks[].id".into(), + "looks[].title".into(), + "looks[].description".into(), + "looks[].folder.name".into(), + "looks[].user_id".into(), + "looks[].query_id".into(), + "looks[].view_count".into(), + "looks[].updated_at".into(), + "folders[].id".into(), + "folders[].name".into(), + "folders[].parent_id".into(), + ], + ); + + // Folders + s.insert( + "looker_list_folders".into(), + vec![ + "id".into(), + "name".into(), + "parent_id".into(), + "child_count".into(), + ], + ); + s.insert( + "looker_get_folder".into(), + vec![ + "id".into(), + "name".into(), + "parent_id".into(), + "child_count".into(), + "dashboards".into(), + "looks".into(), + ], + ); + + // Looks + s.insert( + "looker_list_looks".into(), + vec![ + "id".into(), + "title".into(), + "description".into(), + "folder.name".into(), + "user_id".into(), + "query_id".into(), + "view_count".into(), + "favorite_count".into(), + "public".into(), + "updated_at".into(), + ], + ); + s.insert( + "looker_get_look".into(), + vec![ + "id".into(), + "title".into(), + "description".into(), + "folder.name".into(), + "folder.id".into(), + "user_id".into(), + "query_id".into(), + "view_count".into(), + "favorite_count".into(), + "public".into(), + "updated_at".into(), + "query.model".into(), + "query.view".into(), + "query.fields".into(), + ], + ); + + // Dashboards + s.insert( + "looker_list_dashboards".into(), + vec![ + "id".into(), + "title".into(), + "description".into(), + "folder.name".into(), + "user_id".into(), + "view_count".into(), + "favorite_count".into(), + "updated_at".into(), + ], + ); + s.insert( + "looker_get_dashboard".into(), + vec![ + "id".into(), + "title".into(), + "description".into(), + "folder.name".into(), + "folder.id".into(), + "user_id".into(), + "view_count".into(), + "favorite_count".into(), + "updated_at".into(), + "dashboard_elements[].id".into(), + "dashboard_elements[].title".into(), + "dashboard_elements[].type".into(), + "dashboard_elements[].query_id".into(), + "dashboard_filters".into(), + ], + ); + + // Queries + s.insert( + "looker_get_query".into(), + vec![ + "id".into(), + "model".into(), + "view".into(), + "fields".into(), + "filters".into(), + "sorts".into(), + "limit".into(), + "pivots".into(), + "share_url".into(), + ], + ); + + // Models / explores + s.insert( + "looker_list_models".into(), + vec![ + "name".into(), + "label".into(), + "project_name".into(), + "explores[].name".into(), + "explores[].label".into(), + "explores[].hidden".into(), + ], + ); + s.insert( + "looker_get_model".into(), + vec![ + "name".into(), + "label".into(), + "project_name".into(), + "allowed_db_connection_names".into(), + "explores[].name".into(), + "explores[].label".into(), + "explores[].description".into(), + "explores[].hidden".into(), + ], + ); + s.insert( + "looker_get_model_explore".into(), + vec![ + "name".into(), + "label".into(), + "description".into(), + "model_name".into(), + "view_name".into(), + "connection_name".into(), + "fields".into(), + "joins[].name".into(), + "joins[].type".into(), + "joins[].relationship".into(), + ], + ); + + // Connections + s.insert( + "looker_list_connections".into(), + vec![ + "name".into(), + "dialect_name".into(), + "host".into(), + "port".into(), + "database".into(), + "schema".into(), + ], + ); + s.insert( + "looker_get_connection".into(), + vec![ + "name".into(), + "dialect_name".into(), + "host".into(), + "port".into(), + "database".into(), + "schema".into(), + "username".into(), + "max_connections".into(), + "ssl".into(), + ], + ); + + // Users + s.insert( + "looker_get_me".into(), + vec![ + "id".into(), + "email".into(), + "display_name".into(), + "first_name".into(), + "last_name".into(), + "role_ids".into(), + "is_disabled".into(), + ], + ); + s.insert( + "looker_list_users".into(), + vec![ + "id".into(), + "email".into(), + "display_name".into(), + "role_ids".into(), + "is_disabled".into(), + ], + ); + s.insert( + "looker_get_user".into(), + vec![ + "id".into(), + "email".into(), + "display_name".into(), + "first_name".into(), + "last_name".into(), + "role_ids".into(), + "group_ids".into(), + "is_disabled".into(), + "verified_looker_employee".into(), + ], + ); + + // Scheduled plans + s.insert( + "looker_list_scheduled_plans".into(), + vec![ + "id".into(), + "name".into(), + "user_id".into(), + "look_id".into(), + "dashboard_id".into(), + "enabled".into(), + "crontab".into(), + "timezone".into(), + "next_run_at".into(), + ], + ); + s.insert( + "looker_get_scheduled_plan".into(), + vec![ + "id".into(), + "name".into(), + "user_id".into(), + "look_id".into(), + "dashboard_id".into(), + "enabled".into(), + "crontab".into(), + "timezone".into(), + "next_run_at".into(), + "scheduled_plan_destination".into(), + "filters_string".into(), + ], + ); + + s +} diff --git a/plugins/looker/src/tools.rs b/plugins/looker/src/tools.rs new file mode 100644 index 0000000..e264263 --- /dev/null +++ b/plugins/looker/src/tools.rs @@ -0,0 +1,262 @@ +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![ + // ── Search / discovery ────────────────────────────────────────── + tool!( + "looker_search_content", + "Search Looker dashboards, Looks (saved reports), and folders by title or description. \ + Start here for BI, business intelligence, analytics, reports, visualizations, and finding existing data assets in Looker.", + params!( + "terms" => "Search terms (matches title/description)", + "types" => "Comma-separated content types to include: dashboard, look, folder (default: all)", + "limit" => "Maximum results to return (default: 25)", + "offset" => "Pagination offset (default: 0)" + ), + &["terms"] + ), + + // ── Folders ───────────────────────────────────────────────────── + tool!( + "looker_list_folders", + "List Looker folders (spaces) used to organize dashboards and Looks. Use for navigating BI content organization.", + params!( + "fields" => "Optional comma-separated field selection" + ) + ), + tool!( + "looker_get_folder", + "Get a Looker folder's metadata and child content (dashboards, Looks). Use after list_folders or search_content.", + params!( + "folder_id" => "Folder ID" + ), + &["folder_id"] + ), + + // ── Looks ─────────────────────────────────────────────────────── + tool!( + "looker_list_looks", + "List Looks — saved Looker reports and queries used for BI and analytics dashboards. \ + Use to discover existing saved analytics queries before creating new ones.", + params!( + "limit" => "Maximum Looks to return (default: 25)", + "offset" => "Pagination offset (default: 0)", + "fields" => "Optional comma-separated field selection" + ) + ), + tool!( + "looker_get_look", + "Get a Look's metadata, owner, folder, and underlying query reference. Use after list_looks to inspect a saved BI report.", + params!( + "look_id" => "Look ID" + ), + &["look_id"] + ), + tool!( + "looker_run_look", + "Run a saved Look and return its data rows as JSON. Use for fetching saved BI report results and analytics data. \ + Row count is capped (default 100, max 5000) — pass `limit` to adjust.", + params!( + "look_id" => "Look ID", + "limit" => "Row limit (default: 100, max: 5000)", + "apply_formatting" => "Apply Looker value formatting (default: false)", + "apply_vis" => "Apply visualization options (default: false)", + "cache" => "Use cache (default: true)" + ), + &["look_id"] + ), + + // ── Dashboards ────────────────────────────────────────────────── + tool!( + "looker_list_dashboards", + "List Looker dashboards — interactive BI reports and analytics visualizations. \ + Use for discovering existing dashboards before exploring data.", + params!( + "limit" => "Maximum dashboards to return (default: 25)", + "offset" => "Pagination offset (default: 0)", + "fields" => "Optional comma-separated field selection" + ) + ), + tool!( + "looker_get_dashboard", + "Get a dashboard's metadata, tiles, and filter definitions. Use after list_dashboards or search_content to inspect dashboard contents.", + params!( + "dashboard_id" => "Dashboard ID (numeric for user dashboards, slug-like for LookML)" + ), + &["dashboard_id"] + ), + + // ── Queries (the analytics workhorse) ─────────────────────────── + tool!( + "looker_run_inline_query", + "Run an ad-hoc Looker analytics query against a LookML model/explore and return rows as JSON. \ + This is the main BI tool — compose model + view + fields + filters + sorts to explore data. \ + Use list_models and get_model_explore first to discover available fields. \ + Row count is capped (default 100, max 5000).", + params!( + "model" => "LookML model name (from list_models)", + "view" => "LookML explore/view name (from get_model_explore)", + "fields" => "Comma-separated field names (e.g., 'users.id,users.email,orders.count')", + "filters" => "JSON object of filter expressions, e.g., {\"orders.created_date\":\"7 days\"}", + "sorts" => "Comma-separated sort expressions (e.g., 'orders.count desc')", + "limit" => "Row limit (default: 100, max: 5000)", + "pivots" => "Optional comma-separated pivot field names" + ), + &["model", "view", "fields"] + ), + tool!( + "looker_run_query", + "Run a previously-saved Looker query by ID and return rows as JSON. Use when chaining off get_look (look.query_id) or after create_query.", + params!( + "query_id" => "Query ID", + "limit" => "Row limit override (default: 100, max: 5000)", + "apply_formatting" => "Apply Looker value formatting (default: false)", + "cache" => "Use cache (default: true)" + ), + &["query_id"] + ), + tool!( + "looker_get_query", + "Get the definition of a saved query (model, view, fields, filters, sorts). Use to inspect a Look's query before re-running it.", + params!( + "query_id" => "Query ID" + ), + &["query_id"] + ), + tool!( + "looker_create_query", + "Create a saved Looker query definition (does not run it). Returns the query_id. Use with run_query when you want to reuse the same query multiple times.", + params!( + "model" => "LookML model name", + "view" => "LookML explore/view name", + "fields" => "Comma-separated field names", + "filters" => "JSON object of filter expressions", + "sorts" => "Comma-separated sort expressions", + "limit" => "Saved row limit (default: 500)", + "pivots" => "Optional comma-separated pivot field names" + ), + &["model", "view", "fields"] + ), + + // ── SQL Runner ────────────────────────────────────────────────── + tool!( + "looker_run_sql_query", + "Run a raw SQL query through Looker's SQL Runner against a configured connection. \ + Use for ad-hoc SQL analytics, BI exploration, and bypassing the LookML semantic layer when needed.", + params!( + "connection_name" => "Looker connection name (from list_connections)", + "sql" => "SQL query string" + ), + &["connection_name", "sql"] + ), + + // ── LookML Models ─────────────────────────────────────────────── + tool!( + "looker_list_models", + "List all LookML models available in Looker. LookML is Looker's semantic data modeling layer. Use before run_inline_query to discover models and their explores.", + params!() + ), + tool!( + "looker_get_model", + "Get a LookML model's metadata and list of explores. Use after list_models to find explore (view) names for run_inline_query.", + params!( + "model_name" => "LookML model name" + ), + &["model_name"] + ), + tool!( + "looker_get_model_explore", + "Get a LookML explore's full field metadata (dimensions, measures, filters). Use to discover field names for run_inline_query.", + params!( + "model_name" => "LookML model name", + "explore_name" => "Explore (view) name" + ), + &["model_name", "explore_name"] + ), + + // ── Connections ───────────────────────────────────────────────── + tool!( + "looker_list_connections", + "List database connections configured in Looker. Use to find connection_name for run_sql_query.", + params!() + ), + tool!( + "looker_get_connection", + "Get details of a Looker database connection (host, dialect, schema).", + params!( + "connection_name" => "Connection name" + ), + &["connection_name"] + ), + + // ── Users ─────────────────────────────────────────────────────── + tool!( + "looker_get_me", + "Get the current authenticated Looker user. Use to verify credentials and discover your user_id.", + params!() + ), + tool!( + "looker_list_users", + "List Looker users. Use for admin tasks like finding owners of Looks and dashboards.", + params!( + "limit" => "Maximum users (default: 25)", + "offset" => "Pagination offset (default: 0)" + ) + ), + tool!( + "looker_get_user", + "Get a Looker user's profile, roles, and email.", + params!( + "user_id" => "User ID" + ), + &["user_id"] + ), + + // ── Schedules ─────────────────────────────────────────────────── + tool!( + "looker_list_scheduled_plans", + "List Looker scheduled plans (recurring email/Slack deliveries of dashboards and Looks).", + params!( + "user_id" => "Optional: filter to plans owned by this user", + "all" => "Set 'true' to list across all users (requires admin)" + ) + ), + tool!( + "looker_get_scheduled_plan", + "Get a scheduled plan's details (recipients, frequency, content).", + params!( + "scheduled_plan_id" => "Scheduled plan ID" + ), + &["scheduled_plan_id"] + ), + ] +}