open·source logoopen·source
All crates
Open source · Rust · MIT

cots

v0.2.0

Cots.ai agent-governance SDK for Rust

The Cots.ai SDK for Rust. Configure an AgentClient once with your tenant_id / agent_id, then guard() every governed action through the interceptor (PEP/PDP) before it runs: allowed executes now, blocked never runs, and require_approval holds until a human decides. Framework-agnostic (reqwest + tokio only) — drop it into actix-web, axum, or a bare worker. Mirrors the @cots/sample Node SDK field for field.

$cargo add cots
cotsagentpolicyauditfirewall
~/cots
cots
v0.2.0 · MIT · Rust Rust
$ cargo add cots
One method — guard() — governs any agent action through the PEP/PDP
Decisions obeyed for you: allowed runs now, blocked never runs, require_approval waits for a human
Framework-agnostic: reqwest + tokio only — actix-web, axum, or no server at all
Built-in control-plane onboarding: register tenant/agent, action surfaces, activate
Mirrors the @cots/sample Node SDK field for field — same config, guard, and wire format

// documentation

cots docs

Overview

cots is the Cots.ai SDK for Rust. Its cots::agents module is the interceptor client for agents written in Rust: you configure an AgentClient once with your tenant_id / agent_id, then wrap every real-world action your agent takes in guard(). guard() consults the interceptor (the PEP/PDP) first, and only runs your code when policy allows — so governance is enforced at the point of action, not bolted on after the fact.

  • guard(action, execute) — the one method most agents need
  • Three decisions, obeyed for you: allowed (run now), blocked (never run), require_approval (hold for a human)
  • Lower-level intercept() and wait_for_approval() when you want to handle the decision yourself
  • A control-plane onboarding surface: register a tenant, an agent, action surfaces, then activate
  • An approvals surface: list pending approvals, approve, or deny
  • Framework-agnostic — reqwest + tokio only; no dependency on any web or agent framework
Note — cots::agents mirrors @cots/sample (the Node SDK) field for field — the same config shape, the same guard() pattern, and the same wire format the Rust PEP/PDP expects. Future modules (cots::policy, cots::audit, …) would live alongside agents in this crate as it grows.

Installation

bash
cargo add cots
toml
[dependencies]
cots  = "0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Note — The SDK is built on reqwest + tokio — you need a Tokio runtime, but no web framework. actix-web and axum appear only in the crate’s dev-dependencies, purely to prove the runnable examples.

Configure the Agent

Build an AgentConfig (tenant_id and agent_id come from onboarding) and hand it to AgentClient::new. The URLs default to this SDK’s local demo ports; override them with the builder methods. The client is Clone and Debug, so it drops straight into actix web::Data or an axum State.

rust
use cots::agents::{AgentClient, AgentConfig};

let agent = AgentClient::new(
    AgentConfig::new("ten_xxxxxxxxxx", "agt_xxxxxxxxxx")
        .with_data_plane_url("http://localhost:9090")
        .with_control_plane_url("http://localhost:8080/api")
        .with_api_key("agk_xxxxxxxxxx"),   // required once the data plane has auth on
);

Or load everything from the environment (same convention as the Node SDK) — COTS_TENANT_ID and COTS_AGENT_ID are required, the rest are optional:

rust
let agent = AgentClient::new(AgentConfig::from_env()?);
Env varConfig fieldDefault
COTS_TENANT_IDtenant_id (required)
COTS_AGENT_IDagent_id (required)
COTS_DATA_PLANE_URLdata_plane_urlhttp://localhost:9090
COTS_CONTROL_PLANE_URLcontrol_plane_urlhttp://localhost:8080/api
COTS_APPROVAL_TIMEOUT_MSapproval_timeout_ms30000
COTS_APPROVAL_POLL_MSapproval_poll_ms2000
COTS_API_KEYapi_keyunset
Note — api_key is the agent’s own credential (returned once when you register the agent). It is sent as the x-cots-api-key header on intercept and on the agent’s own approval-polling calls. Leave it unset only against a data plane still running with auth disabled — otherwise every poll silently 401s and guard() just times out with no obvious cause.

guard() — the one method most agents need

Wrap the real action in guard(). It calls the interceptor first, then obeys the decision: if allowed, your closure runs immediately; if blocked, it never runs; if it requires approval, guard() holds and polls the control plane until a human decides (or approval_timeout_ms elapses), then runs your closure only if approved.

rust
use cots::agents::NormalizedAction;

let result = agent
    .guard(NormalizedAction::new("Slack", "send_slack_message"), || async {
        // send the real Slack message here
        "sent"
    })
    .await?;

// result.outcome  is Executed | Blocked | Timeout
// result.decision is "allowed" | "blocked" | "require_approval"
// result.detail   is Some(<your closure’s return>) only when it actually ran
Note — The execute closure is FnOnce returning a future, so it runs at most once and only when policy permits. Its return type T is captured in GovernedResult<T>.detail — None whenever the closure did not run (blocked or timed out).

Describing the Action

NormalizedAction is the context the PDP evaluates. new(target_system, action_type) sets the two required fields; the rest are public Option fields you fill in as your policies need (risk score, amount, recipient, data classification, and so on). Only the fields you set are sent on the wire.

rust
let action = NormalizedAction {
    principal_id: Some("user_42".into()),
    session_id: Some("sess_abc".into()),
    data_classification: Some("pii".into()),
    risk_score: Some(70),
    amount: Some(2500.0),
    recipient: Some("finance@acme.com".into()),
    ..NormalizedAction::new("Payments", "transfer_funds")
};
FieldTypePurpose
target_systemString (required)System being acted on, e.g. "Slack", "Payments"
action_typeString (required)Action verb, e.g. "send_slack_message"
action_nameOption<String>Human label for the specific action
principal_idOption<String>Who the agent is acting on behalf of
session_idOption<String>Session/conversation correlation id
data_classificationOption<String>e.g. "pii", "secret" — feeds policy rules
risk_scoreOption<i32>Caller-supplied risk signal
amountOption<f64>Monetary amount for money-movement rules
recipientOption<String>Email / phone — for recipient-domain rules

Decisions & Outcomes

guard() returns a GovernedResult<T>. decision is the raw PDP verdict; outcome is what actually happened once the SDK obeyed it; detail carries your closure’s result when it ran.

decision (from PDP)What guard() doesoutcomedetail
"allowed"Runs execute immediatelyExecutedSome(T)
"blocked"Never runs executeBlockedNone
"require_approval" → approvedWaits, then runs executeExecutedSome(T)
"require_approval" → deniedWaits, never runs executeBlockedNone
"require_approval" → timed outGives up after approval_timeout_msTimeoutNone
rust
use cots::agents::GovernedOutcome;

match result.outcome {
    GovernedOutcome::Executed => println!("ran: {:?}", result.detail),
    GovernedOutcome::Blocked  => println!("blocked by {:?}", result.matched_rules),
    GovernedOutcome::Timeout  => println!("no approval in time"),
}
Note — GovernedResult also carries action_event_id (the interceptor’s event id — use it to correlate with the control plane / audit log) and matched_rules (the policy rules the PDP matched).

Lower-Level: intercept & wait_for_approval

guard() is a composition of two public calls you can use directly when you need to handle the decision yourself. intercept() POSTs the action to the data plane and returns the raw verdict; wait_for_approval() polls a held event until it resolves or times out.

rust
use cots::agents::ApprovalOutcome;

// One request to POST {data_plane_url}/v1/intercept — no waiting, no execution.
let verdict = agent.intercept(NormalizedAction::new("Slack", "send_slack_message")).await?;
// verdict: InterceptResult { action_event_id, tenant_id, decision, status, matched_rules, latency_ms }

if verdict.decision == "require_approval" {
    match agent.wait_for_approval(&verdict.action_event_id).await? {
        ApprovalOutcome::Approved => { /* do the work */ }
        ApprovalOutcome::Denied   => { /* abort */ }
        ApprovalOutcome::Timeout  => { /* gave up after approval_timeout_ms */ }
    }
}
Note — wait_for_approval polls control_plane.get_action every approval_poll_ms until the event’s status becomes executed (→ Approved) or denied / blocked (→ Denied), or the timeout elapses (→ Timeout). There is no retry beyond this poll loop — intercept() is a single request and returns Err on failure.

Onboarding (one-time)

agent.control_plane exposes the same onboarding surface the admin panel’s wizard uses. Run this once per organization/agent. Every route except register_tenant and login requires an authenticated session, so log in as the org admin right after registering the tenant — the client keeps a cookie store, so the session travels on every later call automatically.

rust
let cp = &agent.control_plane;

// 1. Register the org (returns the tenant + seeded admin/approver users).
let reg = cp.register_tenant("Srotas Space Pvt Ltd", Some("admin-password")).await?;

// 2. Authenticate as that admin before anything else.
cp.login(&reg.admin_user.email, "admin-password").await?;

// 3. Register the agent — its api_key is returned ONCE here; store it.
let created = cp.register_agent("Ops Agent").await?;
let api_key = created.api_key.expect("shown once at registration");

// 4. Declare what this agent may do, then activate it.
cp.create_action_surface(&created.agent_id, "Slack", &["send_slack_message"]).await?;
let pep = cp.activate_agent(&created.agent_id).await?;   // PepConfig for the interceptor
Note — register_agent returns the api_key exactly once and never again — persist it and pass it back as AgentConfig::with_api_key (or COTS_API_KEY), or the agent’s own polling calls have no credential. activate_agent returns a PepConfig whose pep_endpoint is a real-deployment placeholder; locally, keep using the data_plane_url you configured.

Approvals Surface

The control plane also drives the human side of require_approval — the calls an Approval Center (or your own tooling) uses to resolve held actions.

rust
let cp = &agent.control_plane;

// Everything waiting on a human, for this tenant:
let pending = cp.list_pending_approvals(&tenant_id).await?;

for req in pending {
    // req: ApprovalRequest { approval_request_id, action_event_id, status }
    cp.approve(&req.approval_request_id, "looks fine").await?;
    // or: cp.deny(&req.approval_request_id, "over the limit").await?;
}

// Look up the approval attached to a specific action event:
let req = cp.get_approval_for_action(&action_event_id).await?;
Note — Approving flips the action event’s status to executed, which the agent’s wait_for_approval / guard() poll observes and then runs the guarded closure. Denying resolves it as blocked.

Framework-Agnostic Usage

cots::agents has no dependency on any web framework — it works inside whatever your agent already runs, or no server at all. The AgentClient is Clone, so store it in framework state and call guard() from a handler. Two runnable proofs ship in examples/.

rust
// actix-web — examples/with_actix.rs (POST /notify on :8090)
#[actix_web::post("/notify")]
async fn notify(agent: actix_web::web::Data<AgentClient>) -> impl actix_web::Responder {
    let r = agent
        .guard(NormalizedAction::new("Slack", "send_slack_message"), || async { "sent (actix)" })
        .await;
    // ... map r.decision / r.outcome into a JSON response ...
    actix_web::HttpResponse::Ok().finish()
}

// axum — examples/with_axum.rs (POST /notify on :8091)
async fn notify(axum::extract::State(agent): axum::extract::State<std::sync::Arc<AgentClient>>) {
    let _r = agent
        .guard(NormalizedAction::new("Slack", "send_slack_message"), || async { "sent (axum)" })
        .await;
}
bash
cargo run --example with_actix   # actix-web on :8090
cargo run --example with_axum    # axum on :8091
curl -X POST localhost:8090/notify
curl -X POST localhost:8091/notify

API Reference

rust
// AgentClient — cots::agents
AgentClient::new(config: AgentConfig) -> AgentClient
guard<F, Fut, T>(action, execute: F) -> Result<GovernedResult<T>, SdkError>   // FnOnce -> Future<T>
intercept(action: NormalizedAction) -> Result<InterceptResult, SdkError>
wait_for_approval(action_event_id: &str) -> Result<ApprovalOutcome, SdkError>
control_plane: ControlPlaneClient                              // public field

// AgentConfig — builder + env
AgentConfig::new(tenant_id, agent_id) -> AgentConfig
    .with_data_plane_url(url) .with_control_plane_url(url)
    .with_api_key(key) .with_approval_timeout_ms(ms) .with_approval_poll_ms(ms)
AgentConfig::from_env() -> Result<AgentConfig, SdkError>

// ControlPlaneClient — agent.control_plane
login(email, password) -> LoginResult
register_tenant(name, admin_password: Option<&str>) -> TenantRegistration
register_agent(name) -> Agent                                 // .api_key shown once
create_action_surface(agent_id, target_system, allowed_action_types: &[&str]) -> Value
activate_agent(agent_id) -> PepConfig
get_action(action_event_id) -> ActionEventStatus
get_approval_for_action(action_event_id) -> ApprovalRequest
list_pending_approvals(tenant_id) -> Vec<ApprovalRequest>
approve(approval_request_id, reason) -> Value
deny(approval_request_id, reason) -> Value

// Errors — cots::SdkError
SdkError::Config(String) | SdkError::Http(reqwest::Error) | SdkError::Api { status: u16, body: String }
Note — What the crate does NOT do: no real Slack/SMS/email/payment integrations (your execute closure does that), no retry beyond the approval poll loop, and no dependency on any particular agent framework — call guard() wherever your agent decides to act.

Ready to try cots?

cargo add cots
View on crates.io

// more

cots — help & policies