Agents: real ClawBrainHub integration (list + pull brains → trading cards)
CI / policy (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled

Backend:
- brainhub.rs: BrainHubClient trait + HttpBrainHubClient (public discovery API,
  optional Bearer token) + FakeBrainHubClient. Lists the user's brains
  (CLAWBRAINHUB_OWNER, default "omar") + redclawsystems reference brains, and
  pulls a .brain JSON, parsing identity/skills/tools into card fields
  (tagline, skills, tools, capabilities). 2 parser unit tests.
- Routes GET /v1/brainhub/brains + /v1/brainhub/pull (rate-limited); wired into
  AppState + main (HttpBrainHubClient) + test harness (FakeBrainHubClient).

Mobile:
- src/api/brainhub.ts (listBrains/pullBrain), draftStore.pendingAgentBrain.
- app/pick-brain.tsx: pick a brain → pull → hand to the agent-card editor.
- Agents "+ New agent" now opens the brain picker; demo.tsx adopts the pulled
  brain (name, tagline, skill bars, tools, capabilities) for the flip side.

Verified live against clawbrainhub.com (omar's brains + reference brains list;
general-assistant pull yields real skills). Token stays server-side/env only.
Gate: mobile 28, backend 123.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-06 00:50:16 -05:00
co-authored by Claude Opus 4.8
parent d30deaf857
commit 9765a08a5a
13 changed files with 611 additions and 3 deletions
@@ -0,0 +1,350 @@
//! ClawBrainHub (clawbrainhub.com) integration: list agent "brains" and pull a
//! brain's identity/skills so the app can turn it into a trading-card-style
//! agent card. Reads use the public discovery API (no auth); an optional token
//! is sent as a Bearer for the user's private brains.
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
const DEFAULT_BASE: &str = "https://clawbrainhub.com/api/v1";
/// Reference brains the registry always ships — handy picks even when the user
/// hasn't published their own yet.
const REFERENCE_OWNER: &str = "redclawsystems";
/// A brain as shown in the picker list.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BrainSummary {
pub owner: String,
pub name: String,
pub description: Option<String>,
pub version: String,
pub trust_score: Option<i64>,
pub badge: Option<String>,
}
/// A pulled brain normalized for a card (identity + skills/tools/capabilities).
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AgentBrain {
pub owner: String,
pub name: String,
pub version: String,
pub tagline: String,
pub skills: Vec<String>,
pub tools: Vec<String>,
pub capabilities: Vec<String>,
}
#[derive(Debug)]
pub enum BrainHubError {
Http(String),
Parse(String),
}
impl std::fmt::Display for BrainHubError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BrainHubError::Http(e) => write!(f, "clawbrainhub request failed: {e}"),
BrainHubError::Parse(e) => write!(f, "clawbrainhub parse failed: {e}"),
}
}
}
#[async_trait]
pub trait BrainHubClient: Send + Sync {
/// The user's brains plus the registry's reference brains.
async fn list_brains(&self) -> Result<Vec<BrainSummary>, BrainHubError>;
/// Pull a brain and normalize it into card fields.
async fn pull_brain(
&self,
owner: &str,
name: &str,
version: &str,
) -> Result<AgentBrain, BrainHubError>;
}
pub struct HttpBrainHubClient {
http: reqwest::Client,
base: String,
/// The user's ClawBrainHub account (its brains are listed first).
owner: String,
token: Option<String>,
}
impl HttpBrainHubClient {
pub fn new(owner: String, token: Option<String>) -> Self {
Self {
http: reqwest::Client::new(),
base: DEFAULT_BASE.to_string(),
owner,
token,
}
}
async fn get_json(&self, url: &str) -> Result<serde_json::Value, BrainHubError> {
let mut req = self.http.get(url);
if let Some(t) = &self.token {
req = req.bearer_auth(t);
}
let resp = req
.send()
.await
.map_err(|e| BrainHubError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(BrainHubError::Http(format!("status {}", resp.status())));
}
resp.json()
.await
.map_err(|e| BrainHubError::Parse(e.to_string()))
}
async fn list_owner(&self, owner: &str) -> Vec<BrainSummary> {
let url = format!("{}/brains/{}", self.base, owner);
match self.get_json(&url).await {
Ok(json) => parse_summaries(&json),
Err(_) => Vec::new(),
}
}
}
#[async_trait]
impl BrainHubClient for HttpBrainHubClient {
async fn list_brains(&self) -> Result<Vec<BrainSummary>, BrainHubError> {
let mut out = self.list_owner(&self.owner).await;
for b in self.list_owner(REFERENCE_OWNER).await {
if !out.iter().any(|x| x.owner == b.owner && x.name == b.name) {
out.push(b);
}
}
Ok(out)
}
async fn pull_brain(
&self,
owner: &str,
name: &str,
version: &str,
) -> Result<AgentBrain, BrainHubError> {
let url = format!("{}/brains/{}/{}/{}/pull", self.base, owner, name, version);
let json = self.get_json(&url).await?;
Ok(parse_brain(owner, name, version, &json))
}
}
/// Parse a `/brains/{owner}` list response into summaries (latest version each).
fn parse_summaries(json: &serde_json::Value) -> Vec<BrainSummary> {
let arr = match json.as_array() {
Some(a) => a,
None => return Vec::new(),
};
arr.iter()
.filter_map(|b| {
let owner = b.get("owner")?.as_str()?.to_string();
let name = b.get("name")?.as_str()?.to_string();
// First non-yanked version (the API lists newest first).
let ver = b.get("versions").and_then(|v| v.as_array()).and_then(|vs| {
vs.iter()
.find(|v| !v.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false))
});
let version = ver
.and_then(|v| v.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("latest")
.to_string();
Some(BrainSummary {
owner,
name,
description: b
.get("description")
.and_then(|d| d.as_str())
.map(str::to_string),
version,
trust_score: ver
.and_then(|v| v.get("trust_score"))
.and_then(|t| t.as_i64()),
badge: ver
.and_then(|v| v.get("badge"))
.and_then(|x| x.as_str())
.map(str::to_string),
})
})
.collect()
}
/// Normalize a pulled `.brain` JSON into card fields.
fn parse_brain(owner: &str, name: &str, version: &str, json: &serde_json::Value) -> AgentBrain {
let meta_name = json
.pointer("/meta/brain_name")
.and_then(|v| v.as_str())
.unwrap_or(name);
let agent_md = json
.pointer("/identity/agent_md")
.and_then(|v| v.as_str())
.unwrap_or("");
let soul_md = json
.pointer("/identity/soul_md")
.and_then(|v| v.as_str())
.unwrap_or("");
let skills_md = json
.pointer("/skills/skills_md")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut skills = md_headings(skills_md);
skills.truncate(8);
let mut tools: Vec<String> = json
.pointer("/skills/tool_defs")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|t| t.get("name").and_then(|n| n.as_str()).map(str::to_string))
.collect()
})
.unwrap_or_default();
tools.truncate(8);
let mut capabilities = md_bullets(agent_md);
if capabilities.is_empty() {
capabilities = md_headings(agent_md)
.into_iter()
.filter(|h| h != "Purpose")
.collect();
}
capabilities.truncate(6);
let tagline = first_paragraph(soul_md)
.or_else(|| first_paragraph(agent_md))
.unwrap_or_default();
AgentBrain {
owner: owner.to_string(),
name: meta_name.to_string(),
version: version.to_string(),
tagline,
skills,
tools,
capabilities,
}
}
/// `## Heading` lines, prettified (snake_case → Title Case).
fn md_headings(md: &str) -> Vec<String> {
md.lines()
.filter_map(|l| l.trim().strip_prefix("## "))
.map(|h| {
h.trim()
.split(['_', '-', ' '])
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
})
.collect()
}
/// `- ` / `* ` bullet lines, stripped.
fn md_bullets(md: &str) -> Vec<String> {
md.lines()
.filter_map(|l| {
let t = l.trim();
t.strip_prefix("- ")
.or_else(|| t.strip_prefix("* "))
.map(|s| s.trim().to_string())
})
.filter(|s| !s.is_empty() && s.len() < 80)
.collect()
}
/// First non-empty, non-heading, non-bullet line.
fn first_paragraph(md: &str) -> Option<String> {
md.lines()
.map(str::trim)
.find(|l| {
!l.is_empty() && !l.starts_with('#') && !l.starts_with('-') && !l.starts_with('*')
})
.map(|s| {
if s.chars().count() <= 150 {
return s.to_string();
}
// Truncate at a word boundary so the tagline doesn't end mid-word.
let head: String = s.chars().take(150).collect();
let cut = head.rsplit_once(' ').map(|(a, _)| a).unwrap_or(&head);
format!("{}…", cut.trim_end_matches([',', '.', ';', ':', ' ']))
})
}
/// Deterministic double for tests.
pub struct FakeBrainHubClient;
#[async_trait]
impl BrainHubClient for FakeBrainHubClient {
async fn list_brains(&self) -> Result<Vec<BrainSummary>, BrainHubError> {
Ok(vec![BrainSummary {
owner: "redclawsystems".into(),
name: "general-assistant".into(),
description: Some("General-purpose assistant".into()),
version: "1.0.0".into(),
trust_score: Some(90),
badge: Some("Verified".into()),
}])
}
async fn pull_brain(
&self,
owner: &str,
name: &str,
version: &str,
) -> Result<AgentBrain, BrainHubError> {
Ok(AgentBrain {
owner: owner.to_string(),
name: name.to_string(),
version: version.to_string(),
tagline: "A general-purpose assistant.".into(),
skills: vec!["Web Search".into(), "Code".into()],
tools: vec![],
capabilities: vec!["Writing".into(), "Analysis".into()],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_skills_and_tagline_from_brain_json() {
let json = serde_json::json!({
"meta": { "brain_name": "general-assistant" },
"identity": {
"soul_md": "# Soul\n\nI am a helpful general-purpose assistant.",
"agent_md": "# Agent\n\n## Purpose\n\n- Writing\n- Planning"
},
"skills": { "skills_md": "# Skills\n\n## web_search\nSearch the web.\n## code_review\nReview code." }
});
let b = parse_brain("redclawsystems", "general-assistant", "1.0.0", &json);
assert_eq!(b.name, "general-assistant");
assert_eq!(b.skills, vec!["Web Search", "Code Review"]);
assert_eq!(b.capabilities, vec!["Writing", "Planning"]);
assert!(b.tagline.contains("general-purpose assistant"));
}
#[test]
fn parses_list_summaries() {
let json = serde_json::json!([
{ "owner": "o", "name": "n", "description": null,
"versions": [{ "version": "1.0.0", "trust_score": 85, "badge": "Verified", "yanked": false }] }
]);
let s = parse_summaries(&json);
assert_eq!(s.len(), 1);
assert_eq!(s[0].version, "1.0.0");
assert_eq!(s[0].trust_score, Some(85));
}
}
@@ -0,0 +1,54 @@
//! ClawBrainHub handlers: list agent brains and pull one (normalized for a
//! trading-card-style agent card). Reads proxy the public registry; lightly
//! rate-limited per IP to avoid hammering the upstream.
use axum::extract::{Query, State};
use axum::http::HeaderMap;
use axum::Json;
use cardclaws_types::AppError;
use serde::Deserialize;
use crate::brainhub::{AgentBrain, BrainSummary};
use crate::error::ApiResult;
use crate::handlers::analytics::client_ip;
use crate::middleware::rate_limit;
use crate::state::AppState;
fn rl_key(op: &str, headers: &HeaderMap) -> String {
let ip = client_ip(headers).unwrap_or_else(|| "unknown".into());
format!("brainhub:{op}:{ip}")
}
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
) -> ApiResult<Json<Vec<BrainSummary>>> {
rate_limit::check(state.cache.as_ref(), &rl_key("list", &headers), 60, 3600).await?;
let brains = state
.brainhub
.list_brains()
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(brains))
}
#[derive(Deserialize)]
pub struct PullQuery {
pub owner: String,
pub name: String,
pub version: String,
}
pub async fn pull(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<PullQuery>,
) -> ApiResult<Json<AgentBrain>> {
rate_limit::check(state.cache.as_ref(), &rl_key("pull", &headers), 60, 3600).await?;
let brain = state
.brainhub
.pull_brain(&q.owner, &q.name, &q.version)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(brain))
}
@@ -3,6 +3,7 @@ pub mod ai;
pub mod analytics; pub mod analytics;
pub mod assets; pub mod assets;
pub mod auth; pub mod auth;
pub mod brainhub;
pub mod cards; pub mod cards;
pub mod demo; pub mod demo;
pub mod health; pub mod health;
@@ -6,6 +6,7 @@
pub mod ai; pub mod ai;
pub mod assets; pub mod assets;
pub mod brainhub;
pub mod cache; pub mod cache;
pub mod email; pub mod email;
pub mod error; pub mod error;
@@ -5,6 +5,7 @@ use std::sync::Arc;
use cardclaws_api::ai::{AiClient, DisabledAiClient, GeminiClient}; use cardclaws_api::ai::{AiClient, DisabledAiClient, GeminiClient};
use cardclaws_api::assets::R2Store; use cardclaws_api::assets::R2Store;
use cardclaws_api::brainhub::HttpBrainHubClient;
use cardclaws_api::cache::RedisCache; use cardclaws_api::cache::RedisCache;
use cardclaws_api::email::ResendEmailSender; use cardclaws_api::email::ResendEmailSender;
use cardclaws_api::geo::{GeoResolver, NullGeoResolver}; use cardclaws_api::geo::{GeoResolver, NullGeoResolver};
@@ -59,6 +60,16 @@ async fn main() -> Result<(), BoxError> {
None => Arc::new(DisabledAiClient), None => Arc::new(DisabledAiClient),
}; };
// ClawBrainHub: list/pull agent brains. Reads are public; CLAWBRAINHUB_TOKEN
// (optional) authorizes the user's private brains. CLAWBRAINHUB_OWNER is the
// account whose brains are listed first (defaults to "omar").
let brainhub = Arc::new(HttpBrainHubClient::new(
std::env::var("CLAWBRAINHUB_OWNER").unwrap_or_else(|_| "omar".into()),
std::env::var("CLAWBRAINHUB_TOKEN")
.ok()
.filter(|t| !t.trim().is_empty()),
));
// Brand glyphs bundled into every pass. Solid-fill placeholders for now; // Brand glyphs bundled into every pass. Solid-fill placeholders for now;
// replaced by real CardClaws artwork when design assets land. // replaced by real CardClaws artwork when design assets land.
let brand = BrandAssets { let brand = BrandAssets {
@@ -73,6 +84,7 @@ async fn main() -> Result<(), BoxError> {
assets: Arc::new(assets), assets: Arc::new(assets),
geo, geo,
ai, ai,
brainhub,
jwt: JwtKeys::new(&config.jwt_secret), jwt: JwtKeys::new(&config.jwt_secret),
apple: Arc::new(HttpJwkProvider::new()), apple: Arc::new(HttpJwkProvider::new()),
apple_audience, apple_audience,
@@ -5,8 +5,8 @@ use axum::routing::{get, post};
use axum::Router; use axum::Router;
use crate::handlers::{ use crate::handlers::{
account, ai, analytics, assets, auth, cards, demo, health, profile, share, teams, wallet, account, ai, analytics, assets, auth, brainhub, cards, demo, health, profile, share, teams,
webhooks, wallet, webhooks,
}; };
use crate::middleware::cors; use crate::middleware::cors;
use crate::state::AppState; use crate::state::AppState;
@@ -78,6 +78,8 @@ pub fn build_router(state: AppState) -> Router {
.route("/ai/image", post(ai::image)) .route("/ai/image", post(ai::image))
.route("/ai/video", post(ai::start_video)) .route("/ai/video", post(ai::start_video))
.route("/ai/video/status", post(ai::video_status)) .route("/ai/video/status", post(ai::video_status))
.route("/brainhub/brains", get(brainhub::list))
.route("/brainhub/pull", get(brainhub::pull))
.route("/account/export", get(account::export_data)) .route("/account/export", get(account::export_data))
.route("/account", axum::routing::delete(account::delete_account)) .route("/account", axum::routing::delete(account::delete_account))
.route("/assets/upload", post(assets::presign_upload)) .route("/assets/upload", post(assets::presign_upload))
@@ -12,6 +12,7 @@ use cardclaws_wallet::google::jwt_signer::GoogleWalletSigner;
use crate::ai::AiClient; use crate::ai::AiClient;
use crate::assets::ObjectStore; use crate::assets::ObjectStore;
use crate::brainhub::BrainHubClient;
use crate::cache::Cache; use crate::cache::Cache;
use crate::email::EmailSender; use crate::email::EmailSender;
use crate::geo::GeoResolver; use crate::geo::GeoResolver;
@@ -26,6 +27,7 @@ pub struct AppState {
pub assets: Arc<dyn ObjectStore>, pub assets: Arc<dyn ObjectStore>,
pub geo: Arc<dyn GeoResolver>, pub geo: Arc<dyn GeoResolver>,
pub ai: Arc<dyn AiClient>, pub ai: Arc<dyn AiClient>,
pub brainhub: Arc<dyn BrainHubClient>,
pub jwt: JwtKeys, pub jwt: JwtKeys,
pub apple: Arc<dyn JwkProvider>, pub apple: Arc<dyn JwkProvider>,
/// Apple Services ID / bundle id the identity token must be addressed to. /// Apple Services ID / bundle id the identity token must be addressed to.
@@ -20,6 +20,7 @@ use tower::ServiceExt;
use cardclaws_api::ai::FakeAiClient; use cardclaws_api::ai::FakeAiClient;
use cardclaws_api::assets::InMemoryStore; use cardclaws_api::assets::InMemoryStore;
use cardclaws_api::brainhub::FakeBrainHubClient;
use cardclaws_api::cache::InMemoryCache; use cardclaws_api::cache::InMemoryCache;
use cardclaws_api::email::CapturingEmailSender; use cardclaws_api::email::CapturingEmailSender;
use cardclaws_api::geo::{GeoLocation, GeoResolver}; use cardclaws_api::geo::{GeoLocation, GeoResolver};
@@ -86,6 +87,7 @@ pub async fn try_setup() -> Option<TestApp> {
assets: assets.clone(), assets: assets.clone(),
geo: Arc::new(FakeGeo), geo: Arc::new(FakeGeo),
ai: Arc::new(FakeAiClient), ai: Arc::new(FakeAiClient),
brainhub: Arc::new(FakeBrainHubClient),
jwt: JwtKeys::new("test-jwt-secret"), jwt: JwtKeys::new("test-jwt-secret"),
apple: Arc::new(NoopApple), apple: Arc::new(NoopApple),
apple_audience: "com.cardclaws.test".into(), apple_audience: "com.cardclaws.test".into(),
+24
View File
@@ -107,6 +107,15 @@ function buildDemoCard(
}; };
} }
/** "general-assistant" → "General Assistant". */
function prettifyBrainName(name: string): string {
return name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export default function CardEditorScreen() { export default function CardEditorScreen() {
const router = useRouter(); const router = useRouter();
const { cardId, kind: kindParam } = useLocalSearchParams<{ cardId?: string; kind?: string }>(); const { cardId, kind: kindParam } = useLocalSearchParams<{ cardId?: string; kind?: string }>();
@@ -173,6 +182,21 @@ export default function CardEditorScreen() {
setShowing(false); setShowing(false);
draft.setPendingVideoUri(null); draft.setPendingVideoUri(null);
} }
// A brain pulled from ClawBrainHub → prefill the agent card's flip side.
if (draft.pendingAgentBrain) {
const b = draft.pendingAgentBrain;
setCardKind("agent");
setName(prettifyBrainName(b.name));
setTitle(`v${b.version} · @${b.owner}`);
setAgentMeta({
tagline: b.tagline,
skills: b.skills.map((s) => ({ name: s, level: 4 })),
tools: b.tools,
capabilities: b.capabilities,
});
setShowing(false);
draft.setPendingAgentBrain(null);
}
}, []), }, []),
); );
+112
View File
@@ -0,0 +1,112 @@
// Pick an agent brain from ClawBrainHub (your account + reference brains), pull
// it, and hand it to the agent-card editor to turn into a trading card.
import { Stack, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from "react-native";
import { BrainSummary, listBrains, pullBrain } from "../src/api/brainhub";
import { useDraftStore } from "../src/stores/draftStore";
export default function PickBrain() {
const router = useRouter();
const setPendingAgentBrain = useDraftStore((s) => s.setPendingAgentBrain);
const [brains, setBrains] = useState<BrainSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pulling, setPulling] = useState<string | null>(null);
useEffect(() => {
let alive = true;
listBrains()
.then((b) => alive && setBrains(b))
.catch(() => alive && setError("Couldn't reach ClawBrainHub. Check your connection."))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, []);
const choose = async (b: BrainSummary) => {
const key = `${b.owner}/${b.name}`;
setPulling(key);
try {
const brain = await pullBrain(b.owner, b.name, b.version);
setPendingAgentBrain(brain);
router.replace({ pathname: "/demo", params: { kind: "agent" } });
} catch {
setError("Couldn't pull that brain. Try another.");
setPulling(null);
}
};
return (
<View style={styles.root}>
<Stack.Screen options={{ title: "Pick an agent brain", headerTitleAlign: "center" }} />
{loading ? (
<View style={styles.center}>
<ActivityIndicator color="#ff3b30" />
<Text style={styles.dim}>Loading brains…</Text>
</View>
) : error ? (
<View style={styles.center}>
<Text style={styles.error}>{error}</Text>
</View>
) : (
<FlatList
data={brains}
keyExtractor={(b) => `${b.owner}/${b.name}`}
contentContainerStyle={styles.list}
ListHeaderComponent={
<Text style={styles.hint}>Pull a brain from ClawBrainHub to make an agent card.</Text>
}
renderItem={({ item }) => {
const key = `${item.owner}/${item.name}`;
return (
<Pressable
style={styles.row}
disabled={!!pulling}
onPress={() => choose(item)}
>
<View style={{ flex: 1 }}>
<Text style={styles.name} numberOfLines={1}>
{item.name}
</Text>
<Text style={styles.owner} numberOfLines={1}>
@{item.owner} · v{item.version}
{item.badge ? ` · ${item.badge}` : ""}
</Text>
</View>
{pulling === key ? (
<ActivityIndicator color="#ff3b30" />
) : (
<Text style={styles.pull}>Pull →</Text>
)}
</Pressable>
);
}}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0a0a0c" },
center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12, padding: 32 },
dim: { color: "#9a9aa0" },
error: { color: "#ff453a", textAlign: "center", fontSize: 15 },
list: { padding: 16, gap: 10 },
hint: { color: "#9a9aa0", fontSize: 14, marginBottom: 8 },
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
backgroundColor: "#15151a",
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 16,
},
name: { color: "#f5f5f7", fontSize: 16, fontWeight: "700" },
owner: { color: "#9a9aa0", fontSize: 13, marginTop: 2 },
pull: { color: "#ff3b30", fontWeight: "700", fontSize: 14 },
});
+38
View File
@@ -0,0 +1,38 @@
// ClawBrainHub API (via our backend proxy): list agent brains and pull one,
// normalized into card fields (skills/tools/capabilities).
import { api } from "./client";
export interface BrainSummary {
owner: string;
name: string;
description?: string | null;
version: string;
trustScore?: number | null;
badge?: string | null;
}
export interface AgentBrain {
owner: string;
name: string;
version: string;
tagline: string;
skills: string[];
tools: string[];
capabilities: string[];
}
export async function listBrains(): Promise<BrainSummary[]> {
const res = await api.get<BrainSummary[]>("/v1/brainhub/brains", { timeout: 30000 });
return res.data;
}
export async function pullBrain(
owner: string,
name: string,
version: string,
): Promise<AgentBrain> {
const q = `owner=${encodeURIComponent(owner)}&name=${encodeURIComponent(name)}&version=${encodeURIComponent(version)}`;
const res = await api.get<AgentBrain>(`/v1/brainhub/pull?${q}`, { timeout: 30000 });
return res.data;
}
@@ -34,7 +34,11 @@ export function CardCollection({
kind, kind,
); );
const columns = columnsFor(cards.length); const columns = columnsFor(cards.length);
const openNew = () => router.push({ pathname: "/demo", params: { kind } }); // Agents start by picking a brain from ClawBrainHub; others go straight to the creator.
const openNew = () =>
kind === "agent"
? router.push("/pick-brain")
: router.push({ pathname: "/demo", params: { kind } });
return ( return (
<View style={styles.root}> <View style={styles.root}>
@@ -2,12 +2,16 @@
// the card editor picks it up when it regains focus. // the card editor picks it up when it regains focus.
import { create } from "zustand"; import { create } from "zustand";
import type { AgentBrain } from "../api/brainhub";
interface DraftState { interface DraftState {
pendingImageUri: string | null; pendingImageUri: string | null;
setPendingImageUri: (uri: string | null) => void; setPendingImageUri: (uri: string | null) => void;
pendingVideoUri: string | null; pendingVideoUri: string | null;
setPendingVideoUri: (uri: string | null) => void; setPendingVideoUri: (uri: string | null) => void;
/** Brain pulled from ClawBrainHub, awaiting the agent-card editor. */
pendingAgentBrain: AgentBrain | null;
setPendingAgentBrain: (b: AgentBrain | null) => void;
} }
export const useDraftStore = create<DraftState>((set) => ({ export const useDraftStore = create<DraftState>((set) => ({
@@ -15,4 +19,6 @@ export const useDraftStore = create<DraftState>((set) => ({
setPendingImageUri: (uri) => set({ pendingImageUri: uri }), setPendingImageUri: (uri) => set({ pendingImageUri: uri }),
pendingVideoUri: null, pendingVideoUri: null,
setPendingVideoUri: (uri) => set({ pendingVideoUri: uri }), setPendingVideoUri: (uri) => set({ pendingVideoUri: uri }),
pendingAgentBrain: null,
setPendingAgentBrain: (b) => set({ pendingAgentBrain: b }),
})); }));