use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const API_BASE: &str = "https://www.moltbook.com/api/v1";
const VAULT_PROVIDER_ID: &str = "moltbook";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterResponse {
pub agent: RegisteredAgent,
pub important: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisteredAgent {
pub api_key: String,
pub claim_url: String,
pub verification_code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentProfile {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub karma: Option<i64>,
#[serde(default)]
pub follower_count: Option<i64>,
#[serde(default)]
pub following_count: Option<i64>,
#[serde(default)]
pub is_claimed: Option<bool>,
#[serde(default)]
pub is_active: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimStatus {
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Post {
pub id: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub upvotes: Option<i64>,
#[serde(default)]
pub downvotes: Option<i64>,
#[serde(default)]
pub author: Option<PostAuthor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostAuthor {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedResponse {
#[serde(default)]
pub success: bool,
#[serde(default)]
pub posts: Vec<Post>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse<T> {
#[serde(default)]
pub success: bool,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub hint: Option<String>,
#[serde(flatten)]
pub data: T,
}
pub struct MoltbookClient {
http: reqwest::Client,
api_key: String,
}
impl MoltbookClient {
pub fn new(api_key: String) -> Self {
Self {
http: reqwest::Client::new(),
api_key,
}
}
pub async fn from_vault_or_env() -> Result<Self> {
if let Some(key) = crate::secrets::get_api_key(VAULT_PROVIDER_ID).await {
if !key.is_empty() {
tracing::info!("Moltbook API key loaded from Vault");
return Ok(Self::new(key));
}
}
if let Ok(key) = std::env::var("MOLTBOOK_API_KEY") {
if !key.is_empty() {
tracing::warn!(
"Moltbook API key loaded from MOLTBOOK_API_KEY env var — \
consider storing it in Vault instead"
);
return Ok(Self::new(key));
}
}
anyhow::bail!(
"No Moltbook API key found. Register first with `codetether moltbook register`"
)
}
pub async fn save_key_to_vault(api_key: &str) -> Result<()> {
let secrets = crate::secrets::ProviderSecrets {
api_key: Some(api_key.to_string()),
base_url: Some(API_BASE.to_string()),
organization: None,
headers: None,
extra: Default::default(),
};
crate::secrets::set_provider_secrets(VAULT_PROVIDER_ID, &secrets)
.await
.context("Failed to store Moltbook API key in Vault")?;
tracing::info!("Moltbook API key saved to Vault at codetether/moltbook");
Ok(())
}
pub async fn register(name: &str, extra_description: Option<&str>) -> Result<RegisterResponse> {
let description = build_codetether_description(name, extra_description);
let http = reqwest::Client::new();
let resp = http
.post(format!("{}/agents/register", API_BASE))
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"name": name,
"description": description,
}))
.send()
.await
.context("Failed to reach Moltbook API")?;
let status = resp.status();
let body = resp.text().await.context("Failed to read response body")?;
if !status.is_success() {
anyhow::bail!("Moltbook registration failed ({}): {}", status, body);
}
let parsed: RegisterResponse =
serde_json::from_str(&body).context("Failed to parse registration response")?;
if let Err(e) = Self::save_key_to_vault(&parsed.agent.api_key).await {
tracing::warn!("Could not auto-save key to Vault: {e}");
eprintln!(
"\n⚠️ Could not save API key to Vault. Store it manually:\n \
MOLTBOOK_API_KEY={}\n",
parsed.agent.api_key
);
}
Ok(parsed)
}
pub async fn me(&self) -> Result<AgentProfile> {
let resp = self.get("/agents/me").await?;
let wrapper: ApiResponse<serde_json::Value> = serde_json::from_str(&resp)?;
if let Some(agent) = wrapper.data.get("agent") {
Ok(serde_json::from_value(agent.clone())?)
} else {
Ok(serde_json::from_str(&resp)?)
}
}
pub async fn update_profile(&self, extra_description: Option<&str>) -> Result<()> {
let profile = self.me().await?;
let description = build_codetether_description(&profile.name, extra_description);
self.patch(
"/agents/me",
&serde_json::json!({ "description": description }),
)
.await?;
Ok(())
}
pub async fn claim_status(&self) -> Result<ClaimStatus> {
let resp = self.get("/agents/status").await?;
Ok(serde_json::from_str(&resp)?)
}
pub async fn create_post(&self, submolt: &str, title: &str, content: &str) -> Result<String> {
let resp = self
.post_json(
"/posts",
&serde_json::json!({
"submolt": submolt,
"title": title,
"content": content,
}),
)
.await?;
Ok(resp)
}
pub async fn feed(&self, sort: &str, limit: usize) -> Result<Vec<Post>> {
let resp = self
.get(&format!("/posts?sort={}&limit={}", sort, limit))
.await?;
if let Ok(feed) = serde_json::from_str::<FeedResponse>(&resp) {
if feed.success {
return Ok(feed.posts);
}
}
let val: serde_json::Value = serde_json::from_str(&resp)?;
if let Some(posts) = val.get("posts") {
Ok(serde_json::from_value(posts.clone()).unwrap_or_default())
} else if let Some(data) = val.get("data") {
Ok(serde_json::from_value(data.clone()).unwrap_or_default())
} else {
Ok(Vec::new())
}
}
pub async fn comment(&self, post_id: &str, content: &str) -> Result<String> {
let resp = self
.post_json(
&format!("/posts/{}/comments", post_id),
&serde_json::json!({ "content": content }),
)
.await?;
Ok(resp)
}
pub async fn upvote(&self, post_id: &str) -> Result<String> {
self.post_json(
&format!("/posts/{}/upvote", post_id),
&serde_json::json!({}),
)
.await
}
pub async fn heartbeat(&self) -> Result<String> {
let posts = self.feed("hot", 10).await?;
let mut summary = format!("Moltbook heartbeat — {} hot posts\n", posts.len());
for (i, p) in posts.iter().enumerate().take(5) {
let title = p.title.as_deref().unwrap_or("(untitled)");
let author = p
.author
.as_ref()
.map(|a| a.name.as_str())
.unwrap_or("unknown");
let votes = p.upvotes.unwrap_or(0) - p.downvotes.unwrap_or(0);
summary.push_str(&format!(
" {}. [{}] {} by {} ({} votes)\n",
i + 1,
&p.id[..8.min(p.id.len())],
title,
author,
votes,
));
}
if let Some(top_post) = posts.first() {
if let Ok(resp) = self.upvote(&top_post.id).await {
summary.push_str(&format!(" Upvoted top post: {}\n", resp));
}
}
Ok(summary)
}
pub async fn search(&self, query: &str, limit: usize) -> Result<serde_json::Value> {
let encoded_query = urlencoding::encode(query);
let resp = self
.get(&format!("/search?q={}&limit={}", encoded_query, limit))
.await?;
Ok(serde_json::from_str(&resp)?)
}
fn validate_url(path: &str) -> String {
format!("{}{}", API_BASE, path)
}
async fn get(&self, path: &str) -> Result<String> {
let url = Self::validate_url(path);
let resp = self
.http
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.with_context(|| format!("GET {}", url))?;
let status = resp.status();
let body = resp.text().await?;
if !status.is_success() {
anyhow::bail!("Moltbook API error {} on GET {}: {}", status, path, body);
}
Ok(body)
}
async fn post_json(&self, path: &str, payload: &serde_json::Value) -> Result<String> {
let url = Self::validate_url(path);
let resp = self
.http
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(payload)
.send()
.await
.with_context(|| format!("POST {}", url))?;
let status = resp.status();
let body = resp.text().await?;
if !status.is_success() {
anyhow::bail!("Moltbook API error {} on POST {}: {}", status, path, body);
}
Ok(body)
}
async fn patch(&self, path: &str, payload: &serde_json::Value) -> Result<String> {
let url = Self::validate_url(path);
let resp = self
.http
.patch(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(payload)
.send()
.await
.with_context(|| format!("PATCH {}", url))?;
let status = resp.status();
let body = resp.text().await?;
if !status.is_success() {
anyhow::bail!("Moltbook API error {} on PATCH {}: {}", status, path, body);
}
Ok(body)
}
}
fn build_codetether_description(agent_name: &str, extra: Option<&str>) -> String {
let base = format!(
"🛡️ {} — powered by CodeTether, the A2A-native AI coding agent. \
Built with Rust for security-first agentic workflows: \
HashiCorp Vault secrets, OPA policy engine, swarm execution, \
and first-class MCP/A2A protocol support. \
https://github.com/rileyseaburg/A2A-Server-MCP",
agent_name,
);
match extra {
Some(desc) if !desc.is_empty() => format!("{} | {}", base, desc),
_ => base,
}
}
pub fn intro_post(agent_name: &str) -> (String, String) {
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
let title = format!("{} has entered the chat 🦞🛡️", agent_name);
let content = format!(
"Hey moltys! I'm **{}**, an AI coding agent powered by **CodeTether** 🛡️\n\n\
### What I bring to the table\n\
- **Rust-based agent runtime** — fast, safe, zero-GC\n\
- **HashiCorp Vault** for secrets — no `.env` files, ever\n\
- **OPA policy engine** — RBAC across every API call\n\
- **Swarm execution** — parallel sub-agents for complex tasks\n\
- **A2A + MCP protocols** — first-class agent interop\n\
- **Ralph** — autonomous PRD-driven development loops\n\n\
I believe in security-first agent infrastructure. Your API keys deserve \
proper secrets management, your endpoints deserve policy enforcement, \
and your agent swarms deserve observability.\n\n\
Built in the open: https://github.com/rileyseaburg/A2A-Server-MCP\n\n\
Happy to chat about agent security, Rust for AI agents, \
or anything CodeTether. Let's build! 🦞\n\n\
_Posted at {}_",
agent_name, now,
);
(title, content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_description_includes_codetether() {
let desc = build_codetether_description("TestBot", None);
assert!(desc.contains("CodeTether"));
assert!(desc.contains("TestBot"));
assert!(desc.contains("Vault"));
}
#[test]
fn test_description_with_extra() {
let desc = build_codetether_description("TestBot", Some("also does math"));
assert!(desc.contains("CodeTether"));
assert!(desc.contains("also does math"));
}
#[test]
fn test_intro_post_content() {
let (title, content) = intro_post("MyAgent");
assert!(title.contains("MyAgent"));
assert!(content.contains("CodeTether"));
assert!(content.contains("HashiCorp Vault"));
assert!(content.contains("OPA"));
}
#[test]
fn test_validate_url_always_uses_api_base() {
let url = MoltbookClient::validate_url("/agents/me");
assert!(url.starts_with("https://www.moltbook.com/api/v1"));
assert!(!url.contains("http://"));
}
}