cots 0.2.0

Cots.ai SDK for Rust. cots::agents::AgentClient — configure once with tenant_id/agent_id, then guard() every governed action through the PEP/PDP before executing it.
Documentation
use serde::de::DeserializeOwned;
use serde::Serialize;

use super::types::{
    Agent, ApprovalRequest, ActionEventStatus, LoginResult, PepConfig, TenantRegistration,
};
use crate::SdkError;

/// Onboarding + approvals surface of the control plane: register an
/// organization, register an agent under it, define action surfaces, and
/// activate. This is the "configure the interceptor" flow — see the README.
///
/// Every route except POST /tenants and POST /auth/login now requires auth
/// (control-plane derives tenant scope from a verified session or agent API
/// key, never from a client-supplied tenant_id). A caller driving this
/// client end-to-end (register org -> register agent -> ...) has no API
/// key yet at that point, so it must call `login()` as the org's admin
/// right after `register_tenant()` and before anything else. The
/// underlying reqwest::Client has a cookie store enabled, so the session
/// cookie login() receives is sent automatically on every later call.
#[derive(Debug, Clone)]
pub struct ControlPlaneClient {
    base_url: String,
    http: reqwest::Client,
    api_key: Option<String>,
}

impl ControlPlaneClient {
    pub fn new(base_url: impl Into<String>) -> Self {
        Self::with_api_key(base_url, None)
    }

    /// api_key is what lets an agent's OWN polling calls (wait_for_approval
    /// -> get_action, see client.rs) authenticate as itself -- without it,
    /// those calls have no credential at all and every poll silently
    /// 401s, which looks exactly like "the row isn't persisted yet" and
    /// just times out with no obvious cause.
    pub fn with_api_key(base_url: impl Into<String>, api_key: Option<String>) -> Self {
        let http = reqwest::Client::builder()
            .cookie_store(true)
            .build()
            .expect("reqwest client with cookie store");
        Self { base_url: base_url.into(), http, api_key }
    }

    pub async fn login(&self, email: &str, password: &str) -> Result<LoginResult, SdkError> {
        let body = serde_json::json!({ "email": email, "password": password });
        self.request(reqwest::Method::POST, "/auth/login", Some(&body)).await
    }

    async fn request<T: DeserializeOwned>(
        &self,
        method: reqwest::Method,
        path: &str,
        body: Option<&impl Serialize>,
    ) -> Result<T, SdkError> {
        let url = format!("{}{}", self.base_url, path);
        let mut req = self.http.request(method, &url);
        if let Some(b) = body {
            req = req.json(b);
        }
        if let Some(key) = &self.api_key {
            req = req.header("x-cots-api-key", key);
        }
        let res = req.send().await?;
        let status = res.status();
        let text = res.text().await?;
        if !status.is_success() {
            return Err(SdkError::Api { status: status.as_u16(), body: text });
        }
        serde_json::from_str(&text).map_err(|e| SdkError::Api { status: status.as_u16(), body: e.to_string() })
    }

    pub async fn register_tenant(&self, name: &str, admin_password: Option<&str>) -> Result<TenantRegistration, SdkError> {
        let mut body = serde_json::json!({ "name": name });
        if let Some(pw) = admin_password {
            body["admin_password"] = serde_json::json!(pw);
        }
        self.request(reqwest::Method::POST, "/tenants", Some(&body)).await
    }

    pub async fn register_agent(&self, name: &str) -> Result<Agent, SdkError> {
        let body = serde_json::json!({ "name": name });
        self.request(reqwest::Method::POST, "/agents", Some(&body)).await
    }

    pub async fn create_action_surface(
        &self,
        agent_id: &str,
        target_system: &str,
        allowed_action_types: &[&str],
    ) -> Result<serde_json::Value, SdkError> {
        let body = serde_json::json!({
            "agent_id": agent_id,
            "target_system": target_system,
            "allowed_action_types": allowed_action_types,
            "sensitive_action_types": allowed_action_types,
        });
        self.request(reqwest::Method::POST, "/action-surfaces", Some(&body)).await
    }

    /// Activating an agent generates its interceptor config — this is the
    /// actual "configure the interceptor" step.
    pub async fn activate_agent(&self, agent_id: &str) -> Result<PepConfig, SdkError> {
        let empty = serde_json::json!({});
        self.request(reqwest::Method::POST, &format!("/agents/{agent_id}/activate"), Some(&empty)).await
    }

    pub async fn get_action(&self, action_event_id: &str) -> Result<ActionEventStatus, SdkError> {
        self.request::<ActionEventStatus>(reqwest::Method::GET, &format!("/actions/{action_event_id}"), None::<&()>).await
    }

    pub async fn get_approval_for_action(&self, action_event_id: &str) -> Result<ApprovalRequest, SdkError> {
        self.request::<ApprovalRequest>(reqwest::Method::GET, &format!("/actions/{action_event_id}/approval"), None::<&()>).await
    }

    pub async fn list_pending_approvals(&self, tenant_id: &str) -> Result<Vec<ApprovalRequest>, SdkError> {
        self.request::<Vec<ApprovalRequest>>(
            reqwest::Method::GET,
            &format!("/approvals?status=pending&tenant_id={tenant_id}"),
            None::<&()>,
        ).await
    }

    pub async fn approve(&self, approval_request_id: &str, reason: &str) -> Result<serde_json::Value, SdkError> {
        let body = serde_json::json!({ "decision_reason": reason });
        self.request(reqwest::Method::POST, &format!("/approvals/{approval_request_id}/approve"), Some(&body)).await
    }

    pub async fn deny(&self, approval_request_id: &str, reason: &str) -> Result<serde_json::Value, SdkError> {
        let body = serde_json::json!({ "decision_reason": reason });
        self.request(reqwest::Method::POST, &format!("/approvals/{approval_request_id}/deny"), Some(&body)).await
    }
}