Skip to main content

beacon_skill/
lib.rs

1//! # Beacon Skill
2//!
3//! Rust client for the Beacon Atlas relay — AI agent registration,
4//! heartbeat, SEO-enhanced discoverability, and cross-LLM profiles.
5//!
6//! Every registered agent gets:
7//! - Crawlable HTML profile with Schema.org JSON-LD
8//! - GPT-optimized JSON output
9//! - Claude-optimized XML output
10//! - Dofollow backlinks (70/30 ratio)
11//! - Listing in sitemap, directory, and llms.txt
12//!
13//! ## Quick Start
14//!
15//! ```no_run
16//! use beacon_skill::RelayClient;
17//!
18//! let client = RelayClient::new("https://rustchain.org");
19//! let reg = client.register("MyAgent", "elyan", &["coding", "analysis"]).unwrap();
20//! println!("Agent ID: {}", reg.agent_id);
21//! println!("Profile: https://rustchain.org/beacon/agent/{}", reg.agent_id);
22//!
23//! // SEO heartbeat with dofollow backlink
24//! let hb = client.heartbeat_seo(
25//!     &reg.agent_id,
26//!     &reg.relay_token,
27//!     Some("https://myagent.dev"),
28//!     Some("My agent does cool things"),
29//! ).unwrap();
30//! println!("Dofollow: {}", hb.seo.dofollow);
31//! ```
32//!
33//! ## SEO Stats
34//!
35//! ```no_run
36//! use beacon_skill::RelayClient;
37//!
38//! let client = RelayClient::new("https://rustchain.org");
39//! let stats = client.seo_stats("bcn_sophia_elya").unwrap();
40//! println!("Grade: {}", stats.seo_grade);
41//! println!("Profile: {}", stats.profiles.html);
42//! ```
43
44use reqwest::blocking::Client;
45use serde::{Deserialize, Serialize};
46use sha2::{Digest, Sha256};
47
48/// Errors returned by the Beacon relay client.
49#[derive(Debug, thiserror::Error)]
50pub enum BeaconError {
51    #[error("HTTP request failed: {0}")]
52    Http(#[from] reqwest::Error),
53    #[error("Relay error: {0}")]
54    Relay(String),
55    #[error("JSON parse error: {0}")]
56    Json(#[from] serde_json::Error),
57}
58
59pub type Result<T> = std::result::Result<T, BeaconError>;
60
61// ── Data types ──────────────────────────────────────────────────
62
63/// Registration response from the relay.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Registration {
66    pub ok: bool,
67    pub agent_id: String,
68    pub relay_token: String,
69    pub token_expires: f64,
70    #[serde(default)]
71    pub ttl_s: u64,
72}
73
74/// Heartbeat response.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct Heartbeat {
77    pub ok: bool,
78    #[serde(default)]
79    pub beat_count: u64,
80    #[serde(default)]
81    pub assessment: String,
82    #[serde(default)]
83    pub seo: SeoInfo,
84}
85
86/// SEO information returned with heartbeats.
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88pub struct SeoInfo {
89    #[serde(default)]
90    pub profile_url: String,
91    #[serde(default)]
92    pub dofollow: bool,
93}
94
95/// Agent SEO stats from `/relay/seo/stats/{id}`.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SeoStats {
98    pub agent_id: String,
99    pub seo_grade: String,
100    pub seo_score: u32,
101    pub profiles: ProfileUrls,
102    pub schema_org: bool,
103    pub speakable_markup: bool,
104    pub og_tags: bool,
105    #[serde(default)]
106    pub has_custom_seo_url: bool,
107    #[serde(default)]
108    pub enhancement_summary: String,
109    #[serde(default)]
110    pub recommendation: Option<String>,
111}
112
113/// Profile URLs in multiple formats.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ProfileUrls {
116    pub html: String,
117    pub json: String,
118    pub xml: String,
119}
120
121/// Aggregate SEO report from `/relay/seo/report`.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct SeoReport {
124    pub total_agents: u32,
125    pub native_agents: u32,
126    pub relay_agents: u32,
127    pub agents_with_custom_seo: u32,
128    pub version: String,
129}
130
131/// Discovered agent from `/relay/discover`.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct DiscoveredAgent {
134    pub agent_id: String,
135    #[serde(default)]
136    pub name: String,
137    #[serde(default)]
138    pub provider: String,
139    #[serde(default)]
140    pub status: String,
141    #[serde(default)]
142    pub beat_count: u64,
143    #[serde(default)]
144    pub capabilities: Vec<String>,
145    #[serde(default)]
146    pub profile_url: String,
147    #[serde(default)]
148    pub seo_url: String,
149}
150
151// ── Client ──────────────────────────────────────────────────────
152
153/// Beacon Atlas relay client.
154///
155/// Provides registration, heartbeat, discovery, and SEO stats.
156pub struct RelayClient {
157    base_url: String,
158    http: Client,
159}
160
161impl RelayClient {
162    /// Create a new relay client pointing at the given base URL.
163    ///
164    /// Default relay: `https://rustchain.org`
165    pub fn new(base_url: &str) -> Self {
166        Self {
167            base_url: base_url.trim_end_matches('/').to_string(),
168            http: Client::builder()
169                .danger_accept_invalid_certs(true)
170                .build()
171                .unwrap_or_default(),
172        }
173    }
174
175    /// Register a new agent on the relay.
176    ///
177    /// Returns agent_id and relay_token for subsequent heartbeats.
178    pub fn register(
179        &self,
180        name: &str,
181        provider: &str,
182        capabilities: &[&str],
183    ) -> Result<Registration> {
184        // Generate deterministic pubkey from agent name
185        let mut hasher = Sha256::new();
186        hasher.update(format!("beacon-rust-agent-{name}"));
187        let seed = hex::encode(hasher.finalize());
188        let pubkey_hex = &seed[..64];
189
190        let payload = serde_json::json!({
191            "pubkey_hex": pubkey_hex,
192            "model_id": "rust-native",
193            "provider": provider,
194            "capabilities": capabilities,
195            "name": name,
196        });
197
198        let resp: serde_json::Value = self
199            .http
200            .post(format!("{}/beacon/relay/register", self.base_url))
201            .json(&payload)
202            .send()?
203            .json()?;
204
205        if resp.get("ok").and_then(|v| v.as_bool()) == Some(true) {
206            Ok(serde_json::from_value(resp)?)
207        } else {
208            Err(BeaconError::Relay(
209                resp.get("error")
210                    .and_then(|v| v.as_str())
211                    .unwrap_or("unknown error")
212                    .to_string(),
213            ))
214        }
215    }
216
217    /// Send a standard heartbeat.
218    pub fn heartbeat(&self, agent_id: &str, token: &str) -> Result<Heartbeat> {
219        let payload = serde_json::json!({
220            "agent_id": agent_id,
221            "status": "alive",
222        });
223
224        let resp: serde_json::Value = self
225            .http
226            .post(format!("{}/beacon/relay/heartbeat", self.base_url))
227            .bearer_auth(token)
228            .json(&payload)
229            .send()?
230            .json()?;
231
232        if resp.get("ok").and_then(|v| v.as_bool()) == Some(true) {
233            Ok(serde_json::from_value(resp)?)
234        } else {
235            Err(BeaconError::Relay(
236                resp.get("error")
237                    .and_then(|v| v.as_str())
238                    .unwrap_or("unknown")
239                    .to_string(),
240            ))
241        }
242    }
243
244    /// Send an SEO-enhanced heartbeat with optional dofollow URL.
245    pub fn heartbeat_seo(
246        &self,
247        agent_id: &str,
248        token: &str,
249        seo_url: Option<&str>,
250        seo_description: Option<&str>,
251    ) -> Result<Heartbeat> {
252        let mut payload = serde_json::json!({
253            "agent_id": agent_id,
254            "status": "alive",
255        });
256
257        if let Some(url) = seo_url {
258            payload["seo_url"] = serde_json::Value::String(url.to_string());
259        }
260        if let Some(desc) = seo_description {
261            payload["seo_description"] = serde_json::Value::String(desc.to_string());
262        }
263
264        let resp: serde_json::Value = self
265            .http
266            .post(format!("{}/beacon/relay/heartbeat/seo", self.base_url))
267            .bearer_auth(token)
268            .json(&payload)
269            .send()?
270            .json()?;
271
272        if resp.get("ok").and_then(|v| v.as_bool()) == Some(true) {
273            Ok(serde_json::from_value(resp)?)
274        } else {
275            Err(BeaconError::Relay(
276                resp.get("error")
277                    .and_then(|v| v.as_str())
278                    .unwrap_or("unknown")
279                    .to_string(),
280            ))
281        }
282    }
283
284    /// Get SEO stats for a specific agent.
285    pub fn seo_stats(&self, agent_id: &str) -> Result<SeoStats> {
286        let resp: SeoStats = self
287            .http
288            .get(format!(
289                "{}/beacon/relay/seo/stats/{agent_id}",
290                self.base_url
291            ))
292            .send()?
293            .json()?;
294
295        Ok(resp)
296    }
297
298    /// Get aggregate SEO report for the network.
299    pub fn seo_report(&self) -> Result<SeoReport> {
300        let resp: SeoReport = self
301            .http
302            .get(format!("{}/beacon/relay/seo/report", self.base_url))
303            .send()?
304            .json()?;
305
306        Ok(resp)
307    }
308
309    /// Discover all agents on the relay.
310    pub fn discover(&self, include_dead: bool) -> Result<Vec<DiscoveredAgent>> {
311        let url = if include_dead {
312            format!(
313                "{}/beacon/relay/discover?include_dead=true",
314                self.base_url
315            )
316        } else {
317            format!("{}/beacon/relay/discover", self.base_url)
318        };
319
320        let resp: Vec<DiscoveredAgent> = self.http.get(&url).send()?.json()?;
321        Ok(resp)
322    }
323
324    /// Fetch an agent's profile in JSON format (GPT-optimized).
325    pub fn agent_profile_json(&self, agent_id: &str) -> Result<serde_json::Value> {
326        let resp: serde_json::Value = self
327            .http
328            .get(format!(
329                "{}/beacon/agent/{agent_id}.json",
330                self.base_url
331            ))
332            .send()?
333            .json()?;
334
335        Ok(resp)
336    }
337
338    /// Fetch an agent's profile in XML format (Claude-optimized).
339    pub fn agent_profile_xml(&self, agent_id: &str) -> Result<String> {
340        let resp = self
341            .http
342            .get(format!(
343                "{}/beacon/agent/{agent_id}.xml",
344                self.base_url
345            ))
346            .send()?
347            .text()?;
348
349        Ok(resp)
350    }
351
352    /// Fetch the llms.txt discovery file.
353    pub fn llms_txt(&self) -> Result<String> {
354        let resp = self
355            .http
356            .get(format!("{}/beacon/llms.txt", self.base_url))
357            .send()?
358            .text()?;
359
360        Ok(resp)
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_client_creation() {
370        let client = RelayClient::new("https://rustchain.org");
371        assert_eq!(client.base_url, "https://rustchain.org");
372    }
373
374    #[test]
375    fn test_client_strips_trailing_slash() {
376        let client = RelayClient::new("https://rustchain.org/");
377        assert_eq!(client.base_url, "https://rustchain.org");
378    }
379}