cots
v0.2.0Cots.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// 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
Installation
cargo add cots[dependencies]
cots = "0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }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.
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:
let agent = AgentClient::new(AgentConfig::from_env()?);| Env var | Config field | Default |
|---|---|---|
| COTS_TENANT_ID | tenant_id (required) | — |
| COTS_AGENT_ID | agent_id (required) | — |
| COTS_DATA_PLANE_URL | data_plane_url | http://localhost:9090 |
| COTS_CONTROL_PLANE_URL | control_plane_url | http://localhost:8080/api |
| COTS_APPROVAL_TIMEOUT_MS | approval_timeout_ms | 30000 |
| COTS_APPROVAL_POLL_MS | approval_poll_ms | 2000 |
| COTS_API_KEY | api_key | unset |
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.
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 ranDescribing 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.
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")
};| Field | Type | Purpose |
|---|---|---|
| target_system | String (required) | System being acted on, e.g. "Slack", "Payments" |
| action_type | String (required) | Action verb, e.g. "send_slack_message" |
| action_name | Option<String> | Human label for the specific action |
| principal_id | Option<String> | Who the agent is acting on behalf of |
| session_id | Option<String> | Session/conversation correlation id |
| data_classification | Option<String> | e.g. "pii", "secret" — feeds policy rules |
| risk_score | Option<i32> | Caller-supplied risk signal |
| amount | Option<f64> | Monetary amount for money-movement rules |
| recipient | Option<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() does | outcome | detail |
|---|---|---|---|
| "allowed" | Runs execute immediately | Executed | Some(T) |
| "blocked" | Never runs execute | Blocked | None |
| "require_approval" → approved | Waits, then runs execute | Executed | Some(T) |
| "require_approval" → denied | Waits, never runs execute | Blocked | None |
| "require_approval" → timed out | Gives up after approval_timeout_ms | Timeout | None |
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"),
}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.
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 */ }
}
}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.
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(®.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 interceptorApprovals 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.
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?;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/.
// 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;
}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/notifyAPI Reference
// 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 }Ready to try cots?
cargo add cots// more