Teams (deploy ladder rung 1): schema + provisioning + API

A team = a baseline topology staffed with real claws. Migration 0010 (teams +
team_members node→claw bindings) + cm-db repo/teams.rs. runtime_provision.rs
turns a claw into a live runtime agent claw_<id> via the synced gateway config
API (#7468): create agent + bind model_provider (mapped from chosen model) +
risk_profile=toolfree + clawmates_door bundle — atomic, immediately drivable.

routes/teams.rs: POST /api/teams (create claws + provision + build(kind,roles)
+ bind node.attrs["agent"]=claw_<id> + persist), GET /api/teams[/{id}],
POST /api/teams/{id}/run (enqueue a durable run of the team graph — reuses the
topology worker + SSE). v1 persona = topology role via the prompt builder; the
claw's system_prompt stays its chat identity.

Spike confirmed: runtime agent provisioning works; IDENTITY.md persona works
for API models (Gemini/Groq), masked by CLI models (Claude/Kimi Code). 16
cm-api tests + provision unit tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-18 13:13:14 -07:00
co-authored by Claude Opus 4.8
parent 3402a3b56d
commit 8123a27bcf
12 changed files with 745 additions and 0 deletions
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, kind, status, created_at FROM teams\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "39e601efa7be91d2e5dcc3ef87864c7175e268d4c1f1f6064b824984b832d018"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO team_members (team_id, node_id, claw_id, role)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "528ae9fe25e494fa2031ed558911dea2ed53f2f901c039d7a34a4bbe08a57371"
}
@@ -0,0 +1,53 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, kind, graph, status, created_at FROM teams\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "graph",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "98a380135b389b2788b7e3b647f4a9a84f143e57b0d3ecd927d1775f9f845c6d"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT node_id, claw_id, role FROM team_members WHERE team_id = $1 ORDER BY node_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "node_id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "claw_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "role",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "b3c480db2b497a995cf47477ad051fd4a7d5d5400aa198159d9390b15826fb81"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO teams (id, workspace_id, name, kind, graph)\n VALUES ($1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "c121bdbab5de2f82f58c6762bb90ebf08a45cb6e2766e4c69dddd765e168579a"
}
+7
View File
@@ -4,6 +4,7 @@ mod error;
mod extract; mod extract;
mod mcp_door; mod mcp_door;
mod routes; mod routes;
mod runtime_provision;
mod topology_exec; mod topology_exec;
pub mod topology_worker; pub mod topology_worker;
@@ -144,6 +145,12 @@ pub fn router(state: AppState) -> Router {
.route("/api/topologies/build", post(routes::topology::build_graph)) .route("/api/topologies/build", post(routes::topology::build_graph))
.route("/api/topologies/compare", post(routes::topology::compare_topologies)) .route("/api/topologies/compare", post(routes::topology::compare_topologies))
.route("/api/topologies/run", post(routes::topology::run_topology)) .route("/api/topologies/run", post(routes::topology::run_topology))
.route(
"/api/teams",
get(routes::teams::list_teams).post(routes::teams::create_team),
)
.route("/api/teams/{id}", get(routes::teams::get_team))
.route("/api/teams/{id}/run", post(routes::teams::run_team))
.route("/api/topology-runs", get(routes::topology::list_runs)) .route("/api/topology-runs", get(routes::topology::list_runs))
.route("/api/topology-runs/{id}", get(routes::topology::get_run)) .route("/api/topology-runs/{id}", get(routes::topology::get_run))
.route( .route(
+1
View File
@@ -15,4 +15,5 @@ pub mod sessions;
pub mod skills; pub mod skills;
pub mod slack; pub mod slack;
pub mod team; pub mod team;
pub mod teams;
pub mod topology; pub mod topology;
+234
View File
@@ -0,0 +1,234 @@
//! Team endpoints — deploy a baseline topology staffed with real claws, then run
//! it on the durable topology runner. The first rung of the deploy ladder.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
use cm_topology::{build, TopologyKind};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::runtime_provision::{claw_alias, RuntimeProvisioner};
use crate::{ApiError, AppState, Authed};
/// One staffed role in the team (becomes a claw + a topology node).
#[derive(Deserialize)]
pub struct TeamMemberInput {
pub role: String,
pub name: String,
/// Model selector: claude | glm | glm-5.2 | kimi | gemini | groq.
#[serde(default)]
pub model: String,
#[serde(default)]
pub system_prompt: String,
#[serde(default)]
pub accent: String,
}
#[derive(Deserialize)]
pub struct CreateTeamRequest {
pub name: String,
/// TopologyKind (snake_case), e.g. "hierarchical", "pipeline".
pub kind: String,
pub members: Vec<TeamMemberInput>,
}
#[derive(Serialize)]
pub struct TeamCreated {
pub team_id: String,
}
fn parse_kind(s: &str) -> Result<TopologyKind, ApiError> {
serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest)
}
/// `POST /api/teams` — create a team: for each member create a claw + provision a
/// runtime agent, build the baseline topology, bind node→claw, persist.
pub async fn create_team(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateTeamRequest>,
) -> Result<(StatusCode, Json<TeamCreated>), ApiError> {
if body.members.is_empty() {
return Err(ApiError::BadRequest);
}
let kind = parse_kind(&body.kind)?;
let provisioner = RuntimeProvisioner::from_env().ok_or(ApiError::Internal)?;
// 1. Create each claw (DB row) + provision it as a live runtime agent.
let mut claw_ids: Vec<Uuid> = Vec::with_capacity(body.members.len());
for m in &body.members {
let agent = Agent {
id: AgentId::new(),
workspace_id: user.workspace_id,
name: m.name.clone(),
job_title: m.role.clone(),
system_prompt: m.system_prompt.clone(),
avatar: String::new(),
accent: m.accent.clone(),
wallpaper: String::new(),
managed_by: user.user_id,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?;
let claw_id = agent.id.as_uuid();
// Provisioning failure rolls the team back at the runtime layer is best-
// effort; the claw row stays (visible in the roster) so nothing is lost.
provisioner
.provision_claw(claw_id, &m.model)
.await
.map_err(|e| {
eprintln!("teams: provision claw {claw_id} failed: {e}");
ApiError::Internal
})?;
claw_ids.push(claw_id);
}
// 2. Build the baseline topology and bind each node to its claw's runtime alias.
let roles: Vec<&str> = body.members.iter().map(|m| m.role.as_str()).collect();
let mut graph = build(kind, &roles).map_err(|_| ApiError::BadRequest)?;
for (i, node) in graph.nodes.iter_mut().enumerate() {
if let Some(cid) = claw_ids.get(i) {
node.attrs.insert("agent".into(), claw_alias(*cid));
node.attrs.insert("claw_id".into(), cid.to_string());
}
}
// 3. Persist team + node→claw bindings.
let team_id = Uuid::now_v7();
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::teams::insert_team(
&state.pool,
team_id,
user.workspace_id,
&body.name,
kind.as_str(),
&graph_json,
)
.await?;
for (i, node) in graph.nodes.iter().enumerate() {
if let Some(cid) = claw_ids.get(i) {
cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role).await?;
}
}
Ok((
StatusCode::CREATED,
Json(TeamCreated {
team_id: team_id.to_string(),
}),
))
}
#[derive(Serialize)]
pub struct TeamSummaryOut {
pub id: String,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: String,
}
/// `GET /api/teams` — recent teams for the workspace.
pub async fn list_teams(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<TeamSummaryOut>>, ApiError> {
let rows = cm_db::repo::teams::list_for_workspace(&state.pool, user.workspace_id, 50).await?;
Ok(Json(
rows.into_iter()
.map(|t| TeamSummaryOut {
id: t.id.to_string(),
name: t.name,
kind: t.kind,
status: t.status,
created_at: t.created_at.format(&Rfc3339).unwrap_or_default(),
})
.collect(),
))
}
#[derive(Serialize)]
pub struct TeamMemberOut {
pub node_id: String,
pub claw_id: String,
pub role: String,
}
#[derive(Serialize)]
pub struct TeamDetail {
pub id: String,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: String,
pub graph: Value,
pub members: Vec<TeamMemberOut>,
}
/// `GET /api/teams/{id}` — a team's graph + node→claw bindings.
pub async fn get_team(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<TeamDetail>, ApiError> {
let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
let members = cm_db::repo::teams::members_for_team(&state.pool, id).await?;
Ok(Json(TeamDetail {
id: team.id.to_string(),
name: team.name,
kind: team.kind,
status: team.status,
created_at: team.created_at.format(&Rfc3339).unwrap_or_default(),
graph: team.graph,
members: members
.into_iter()
.map(|m| TeamMemberOut {
node_id: m.node_id,
claw_id: m.claw_id.to_string(),
role: m.role,
})
.collect(),
}))
}
#[derive(Deserialize)]
pub struct RunTeamRequest {
pub task: String,
}
#[derive(Serialize)]
pub struct RunAccepted {
pub run_id: String,
pub status: String,
}
/// `POST /api/teams/{id}/run` — enqueue a durable run of the team's topology
/// (drives the bound claws). Poll/stream via `/api/topology-runs/{run_id}`.
pub async fn run_team(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<RunTeamRequest>,
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
let run_id = Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run(
&state.pool,
run_id,
user.workspace_id,
&body.task,
&team.graph,
)
.await?;
Ok((
StatusCode::ACCEPTED,
Json(RunAccepted {
run_id: run_id.to_string(),
status: "queued".into(),
}),
))
}
+159
View File
@@ -0,0 +1,159 @@
//! Provision a workspace claw as a live agent in the ZeroClaw runtime.
//!
//! A team deploy turns each persisted claw into a real runtime agent
//! (`claw_<id>`) via the gateway config API (added upstream in #7468): create
//! the agent, then bind its model provider, risk profile, and the §15 door
//! bundle. The agent is atomic + immediately drivable via `/ws/chat?agent=...`,
//! so the durable topology runner can execute the team on these claws (each node
//! carries `attrs["agent"] = claw_<id>`).
//!
//! Persona note (v1): behavior is driven by the topology **role** in the turn
//! prompt; the claw's rich `system_prompt` remains its chat-path identity.
//! Injecting per-claw persona into runtime turns is a fast-follow.
use uuid::Uuid;
/// The runtime agent alias for a claw id.
pub fn claw_alias(claw_id: Uuid) -> String {
format!("claw_{}", claw_id.simple())
}
/// Map a claw's chosen model to an EXISTING runtime provider alias (no new
/// provider provisioning for v1). Unknown → Claude default.
pub fn provider_alias_for(model: &str) -> &'static str {
match model.trim().to_ascii_lowercase().as_str() {
"glm" | "glm-4.7" | "glm4.7" => "claude_cli.glm",
"glm-5.2" | "glm5.2" | "glm5" => "claude_cli.glm5",
"kimi" | "kimi-for-coding" => "kimi_cli.default",
"gemini" | "gemini-2.5-flash" => "gemini.default",
"groq" | "llama" => "groq.default",
_ => "claude_cli.default", // "claude" / unknown
}
}
/// Talks to a live ZeroClaw runtime's config API to provision/deprovision agents.
pub struct RuntimeProvisioner {
http: reqwest::Client,
gateway_url: String,
token: String,
}
impl RuntimeProvisioner {
/// Build from the same env the topology executor uses (`ZEROCLAW_GATEWAY_URL`
/// + a durable `ZEROCLAW_TOKEN`). Returns `None` if not configured.
pub fn from_env() -> Option<RuntimeProvisioner> {
let gateway_url = std::env::var("ZEROCLAW_GATEWAY_URL")
.ok()
.filter(|u| !u.is_empty())?;
let token = std::env::var("ZEROCLAW_TOKEN")
.ok()
.filter(|t| !t.is_empty())?;
Some(RuntimeProvisioner {
http: reqwest::Client::new(),
gateway_url,
token,
})
}
async fn set_prop(&self, path: &str, value: serde_json::Value) -> Result<(), String> {
let res = self
.http
.put(format!("{}/api/config/prop", self.gateway_url))
.bearer_auth(&self.token)
.json(&serde_json::json!({ "path": path, "value": value }))
.send()
.await
.map_err(|e| format!("prop {path} request failed: {e}"))?;
if !res.status().is_success() {
let code = res.status();
let body = res.text().await.unwrap_or_default();
return Err(format!("prop {path} failed ({code}): {body}"));
}
Ok(())
}
/// Create `claw_<id>` as a live runtime agent bound to `model_alias`, the
/// `toolfree` risk profile, and the `clawmates_door` MCP bundle. Idempotent
/// on the create step.
pub async fn provision_claw(&self, claw_id: Uuid, model: &str) -> Result<String, String> {
let alias = claw_alias(claw_id);
let model_alias = provider_alias_for(model);
// 1. Create the agent map-key (idempotent — returns created:false if exists).
let res = self
.http
.post(format!(
"{}/api/config/map-key?path=agents&key={}",
self.gateway_url, alias
))
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| format!("create agent request failed: {e}"))?;
if !res.status().is_success() {
let code = res.status();
let body = res.text().await.unwrap_or_default();
return Err(format!("create agent {alias} failed ({code}): {body}"));
}
// 2. Bind provider, risk profile, and the §15 door bundle.
self.set_prop(
&format!("agents.{alias}.model_provider"),
serde_json::json!(model_alias),
)
.await?;
self.set_prop(
&format!("agents.{alias}.risk_profile"),
serde_json::json!("toolfree"),
)
.await?;
self.set_prop(
&format!("agents.{alias}.mcp_bundles"),
serde_json::json!(["clawmates_door"]),
)
.await?;
Ok(alias)
}
/// Remove a provisioned claw agent (rollback / team teardown — wired when
/// team delete lands).
#[allow(dead_code)]
pub async fn deprovision_claw(&self, claw_id: Uuid) -> Result<(), String> {
let alias = claw_alias(claw_id);
let res = self
.http
.delete(format!(
"{}/api/config/map-key?path=agents&key={}",
self.gateway_url, alias
))
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| format!("delete agent request failed: {e}"))?;
if !res.status().is_success() {
return Err(format!("delete agent {alias} failed ({})", res.status()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_alias_mapping() {
assert_eq!(provider_alias_for("gemini"), "gemini.default");
assert_eq!(provider_alias_for("GLM-4.7"), "claude_cli.glm");
assert_eq!(provider_alias_for("kimi"), "kimi_cli.default");
assert_eq!(provider_alias_for("groq"), "groq.default");
assert_eq!(provider_alias_for("anything-else"), "claude_cli.default");
}
#[test]
fn alias_is_stable_and_safe() {
let id = Uuid::nil();
assert_eq!(claw_alias(id), "claw_00000000000000000000000000000000");
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod runs;
pub mod sessions; pub mod sessions;
pub mod skills; pub mod skills;
pub mod steps; pub mod steps;
pub mod teams;
pub mod threads; pub mod threads;
pub mod topology_runs; pub mod topology_runs;
pub mod users; pub mod users;
+150
View File
@@ -0,0 +1,150 @@
//! Persistence for deployed teams — a baseline topology staffed with real
//! workspace claws. `teams` holds the topology graph; `team_members` is the
//! durable node→claw binding.
use cm_domain::WorkspaceId;
use serde_json::Value;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
/// A team row summary (list view).
pub struct TeamSummary {
pub id: Uuid,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: OffsetDateTime,
}
/// A full team (graph + metadata).
pub struct Team {
pub id: Uuid,
pub name: String,
pub kind: String,
pub graph: Value,
pub status: String,
pub created_at: OffsetDateTime,
}
/// A node→claw binding within a team.
pub struct TeamMember {
pub node_id: String,
pub claw_id: Uuid,
pub role: String,
}
/// Insert a team (the topology graph). Members are added separately.
pub async fn insert_team(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
name: &str,
kind: &str,
graph: &Value,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO teams (id, workspace_id, name, kind, graph)
VALUES ($1, $2, $3, $4, $5)",
id,
workspace_id.as_uuid(),
name,
kind,
graph,
)
.execute(pool)
.await?;
Ok(())
}
/// Bind a claw to a topology node within a team.
pub async fn add_member(
pool: &PgPool,
team_id: Uuid,
node_id: &str,
claw_id: Uuid,
role: &str,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO team_members (team_id, node_id, claw_id, role)
VALUES ($1, $2, $3, $4)",
team_id,
node_id,
claw_id,
role,
)
.execute(pool)
.await?;
Ok(())
}
/// The most recent teams for a workspace, newest first.
pub async fn list_for_workspace(
pool: &PgPool,
workspace_id: WorkspaceId,
limit: i64,
) -> Result<Vec<TeamSummary>, DbError> {
let rows = sqlx::query!(
"SELECT id, name, kind, status, created_at FROM teams
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
workspace_id.as_uuid(),
limit,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| TeamSummary {
id: r.id,
name: r.name,
kind: r.kind,
status: r.status,
created_at: r.created_at,
})
.collect())
}
/// A single team, workspace-scoped.
pub async fn get_team(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
) -> Result<Team, DbError> {
let row = sqlx::query!(
"SELECT id, name, kind, graph, status, created_at FROM teams
WHERE id = $1 AND workspace_id = $2",
id,
workspace_id.as_uuid(),
)
.fetch_optional(pool)
.await?
.ok_or(DbError::NotFound)?;
Ok(Team {
id: row.id,
name: row.name,
kind: row.kind,
graph: row.graph,
status: row.status,
created_at: row.created_at,
})
}
/// The node→claw bindings for a team.
pub async fn members_for_team(pool: &PgPool, team_id: Uuid) -> Result<Vec<TeamMember>, DbError> {
let rows = sqlx::query!(
"SELECT node_id, claw_id, role FROM team_members WHERE team_id = $1 ORDER BY node_id",
team_id,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| TeamMember {
node_id: r.node_id,
claw_id: r.claw_id,
role: r.role,
})
.collect())
}
+24
View File
@@ -0,0 +1,24 @@
-- Teams: a deployed multi-agent unit = a baseline TOPOLOGY staffed with real
-- workspace claws. The first rung of the deploy ladder (single → team → company
-- → org). `graph` is the TopologyGraph whose nodes carry attrs["agent"] = the
-- claw's runtime alias (claw_<id>); team_members is the durable node→claw
-- binding (each claw is also a normal agents-table row, individually chattable).
CREATE TABLE teams (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL, -- TopologyKind (snake_case)
graph JSONB NOT NULL, -- TopologyGraph (nodes/edges)
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX teams_workspace_idx ON teams (workspace_id, created_at DESC);
CREATE TABLE team_members (
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
node_id TEXT NOT NULL, -- topology node id
claw_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
role TEXT NOT NULL,
PRIMARY KEY (team_id, node_id)
);