Skip to main content

self_agent_sdk/
agent_card.rs

1// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
2// SPDX-License-Identifier: BUSL-1.1
3// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8// ─── Helpers ────────────────────────────────────────────────────────────────
9
10/// Slugify a string into a URL/ID-safe lowercase-hyphenated form.
11fn slugify(s: &str) -> String {
12    let lowered = s.to_lowercase();
13    let replaced: String = lowered
14        .chars()
15        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
16        .collect();
17    replaced
18        .trim_matches('-')
19        .split('-')
20        .filter(|s| !s.is_empty())
21        .collect::<Vec<_>>()
22        .join("-")
23}
24
25// ─── A2A Agent Card sub-types ───────────────────────────────────────────────
26
27/// A capability or skill advertised by an A2A agent (v0.3.0).
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct AgentSkill {
31    /// Unique identifier for this skill (required per A2A v0.3.0).
32    pub id: String,
33    pub name: String,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub description: Option<String>,
36    /// Freeform tags for categorization.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub tags: Option<Vec<String>>,
39    /// Example prompts or inputs that exercise this skill.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub examples: Option<Vec<String>>,
42    /// MIME types this skill accepts as input.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub input_modes: Option<Vec<String>>,
45    /// MIME types this skill can produce.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub output_modes: Option<Vec<String>>,
48}
49
50/// A2A v0.3.0 agent interface declaration describing a protocol endpoint.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct AgentInterface {
54    /// The URL of this interface endpoint.
55    pub url: String,
56    /// The protocol binding used by this interface ("JSONRPC", "GRPC", "HTTP+JSON").
57    pub protocol_binding: String,
58    /// The A2A protocol version, e.g. "0.3.0".
59    pub protocol_version: String,
60}
61
62/// Feature flags describing what the A2A agent endpoint supports.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct A2ACapabilities {
66    pub streaming: bool,
67    pub push_notifications: bool,
68    /// Whether the agent exposes full task state transition history.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub state_transition_history: Option<bool>,
71    /// Whether the agent supports an extended agent card endpoint.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub extended_agent_card: Option<bool>,
74}
75
76/// Organization or individual that operates the A2A agent.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct A2AProvider {
80    pub name: String,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub url: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub email: Option<String>,
85}
86
87// ─── Security Scheme types (A2A v0.3.0 / OpenAPI-style) ─────────────────────
88
89/// Discriminated union of all supported A2A security scheme types.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(tag = "type", rename_all = "camelCase")]
92pub enum SecurityScheme {
93    /// API Key authentication scheme.
94    #[serde(rename = "apiKey")]
95    ApiKey {
96        name: String,
97        #[serde(rename = "in")]
98        location: String, // "header" | "query" | "cookie"
99        #[serde(skip_serializing_if = "Option::is_none")]
100        description: Option<String>,
101    },
102    /// HTTP authentication scheme (e.g. Bearer).
103    #[serde(rename = "http")]
104    Http {
105        scheme: String,
106        #[serde(skip_serializing_if = "Option::is_none")]
107        bearer_format: Option<String>,
108        #[serde(skip_serializing_if = "Option::is_none")]
109        description: Option<String>,
110    },
111    /// OAuth2 authentication scheme.
112    #[serde(rename = "oauth2")]
113    OAuth2 {
114        flows: serde_json::Value,
115        #[serde(skip_serializing_if = "Option::is_none")]
116        description: Option<String>,
117    },
118    /// OpenID Connect authentication scheme.
119    #[serde(rename = "openIdConnect")]
120    OpenIdConnect {
121        open_id_connect_url: String,
122        #[serde(skip_serializing_if = "Option::is_none")]
123        description: Option<String>,
124    },
125}
126
127/// Named map of security schemes (OpenAPI-style).
128pub type SecuritySchemes = HashMap<String, SecurityScheme>;
129
130/// A security requirement entry: maps scheme name to list of scopes.
131pub type SecurityRequirement = HashMap<String, Vec<String>>;
132
133// ─── Signatures & Extensions ─────────────────────────────────────────────────
134
135/// RFC 7515 JWS signature attached to the agent card.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct JwsSignature {
139    /// The protected header (Base64url-encoded).
140    #[serde(rename = "protected")]
141    pub protected_header: String,
142    /// The JWS signature value (Base64url-encoded).
143    pub signature: String,
144    /// Optional unprotected header parameters.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub header: Option<serde_json::Value>,
147}
148
149/// An agent card extension declaration.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct AgentExtension {
153    /// URI identifying the extension specification.
154    pub uri: String,
155    /// Extension-specific data (flattened into the JSON object).
156    #[serde(flatten)]
157    pub data: serde_json::Map<String, serde_json::Value>,
158}
159
160// ─── Trust & Credentials ─────────────────────────────────────────────────────
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct TrustModel {
165    pub proof_type: String,
166    pub sybil_resistant: bool,
167    pub ofac_screened: bool,
168    pub minimum_age_verified: u64,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct CardCredentials {
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub nationality: Option<String>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub issuing_state: Option<String>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub older_than: Option<u64>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub ofac_clean: Option<bool>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub has_name: Option<bool>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub has_date_of_birth: Option<bool>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub has_gender: Option<bool>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub document_expiry: Option<String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct SelfProtocolExtension {
195    pub agent_id: u64,
196    pub registry: String,
197    pub chain_id: u64,
198    pub proof_provider: String,
199    pub provider_name: String,
200    pub verification_strength: u8,
201    pub trust_model: TrustModel,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub credentials: Option<CardCredentials>,
204}
205
206// ─── ERC-8004 service entry ──────────────────────────────────────────────────
207
208/// A service endpoint entry in the ERC-8004 agent document.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(rename_all = "camelCase")]
211pub struct Erc8004Service {
212    pub name: String, // "web" | "A2A" | "MCP" | "OASF" | "ENS" | "DID" | "email"
213    pub endpoint: String,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub version: Option<String>,
216}
217
218// ─── Cross-chain registration reference (CAIP-10) ───────────────────────────
219
220/// Cross-chain registration reference using CAIP-10 addressing.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct Erc8004Registration {
224    pub agent_id: u64,
225    pub agent_registry: String, // CAIP-10: eip155:<chainId>:<address>
226}
227
228// ─── The combined ERC-8004 + A2A document ────────────────────────────────────
229
230/// Combined ERC-8004 registration document with optional A2A Agent Card fields
231/// and Self Protocol on-chain proof metadata.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct Erc8004AgentDocument {
235    // ── ERC-8004 required ──
236    #[serde(rename = "type")]
237    pub doc_type: String,
238    pub name: String,
239    pub description: String,
240    pub image: String,
241    pub services: Vec<Erc8004Service>,
242
243    // ── ERC-8004 optional ──
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub active: Option<bool>,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub registrations: Option<Vec<Erc8004Registration>>,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub supported_trust: Option<Vec<String>>,
250
251    // ── A2A optional ──
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub version: Option<String>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub url: Option<String>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub provider: Option<A2AProvider>,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub capabilities: Option<A2ACapabilities>,
260
261    /// Named map of security schemes (A2A v0.3.0 / OpenAPI-style).
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub security_schemes: Option<SecuritySchemes>,
264
265    /// Security requirements referencing scheme names.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub security: Option<Vec<SecurityRequirement>>,
268
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub default_input_modes: Option<Vec<String>>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub default_output_modes: Option<Vec<String>>,
273
274    /// A2A v0.3.0 structured interface declarations.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub supported_interfaces: Option<Vec<AgentInterface>>,
277
278    /// URL to agent icon/avatar. Maps to/from ERC-8004 `image`.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub icon_url: Option<String>,
281
282    /// URL to agent documentation.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub documentation_url: Option<String>,
285
286    /// RFC 7515 JWS signatures attached to this card.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub signatures: Option<Vec<JwsSignature>>,
289
290    /// Agent card extension declarations.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub extensions: Option<Vec<AgentExtension>>,
293
294    // ── Self Protocol extension ──
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub self_protocol: Option<SelfProtocolExtension>,
297
298    // ── A2A skills ──
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub skills: Option<Vec<AgentSkill>>,
301}
302
303
304// ─── Provider Scoring ────────────────────────────────────────────────────────
305
306pub fn get_provider_label(strength: u8) -> &'static str {
307    match strength {
308        100..=u8::MAX => "passport",
309        80..=99 => "kyc",
310        60..=79 => "govt_id",
311        40..=59 => "liveness",
312        _ => "unknown",
313    }
314}
315
316pub fn get_strength_color(strength: u8) -> &'static str {
317    match strength {
318        80..=u8::MAX => "green",
319        60..=79 => "blue",
320        40..=59 => "amber",
321        _ => "gray",
322    }
323}
324
325// ─── Registration JSON Builder ──────────────────────────────────────────────
326
327/// A2A-specific options for generating an ERC-8004 + A2A hybrid document.
328#[derive(Debug, Clone)]
329pub struct A2AOptions {
330    pub version: String,
331    pub url: String,
332    pub provider: Option<A2AProvider>,
333    pub capabilities: Option<A2ACapabilities>,
334    pub security_schemes: Option<SecuritySchemes>,
335    pub security: Option<Vec<SecurityRequirement>>,
336    pub default_input_modes: Option<Vec<String>>,
337    pub default_output_modes: Option<Vec<String>>,
338    pub skills: Option<Vec<AgentSkill>>,
339    pub supported_interfaces: Option<Vec<AgentInterface>>,
340    pub icon_url: Option<String>,
341    pub documentation_url: Option<String>,
342    pub signatures: Option<Vec<JwsSignature>>,
343    pub extensions: Option<Vec<AgentExtension>>,
344}
345
346/// Options for building an ERC-8004 registration JSON document.
347#[derive(Debug, Clone)]
348pub struct GenerateRegistrationJsonOptions {
349    pub name: String,
350    pub description: String,
351    pub image: String,
352    pub services: Vec<Erc8004Service>,
353    pub active: Option<bool>,
354    pub registrations: Option<Vec<Erc8004Registration>>,
355    pub supported_trust: Option<Vec<String>>,
356    pub a2a: Option<A2AOptions>,
357}
358
359/// Build an ERC-8004 registration document synchronously from plain options.
360///
361/// When `options.a2a` is provided, the returned document is also a valid A2A
362/// Agent Card. Skills without an explicit `id` will have one auto-generated
363/// from the skill name.
364pub fn generate_registration_json(
365    options: GenerateRegistrationJsonOptions,
366) -> Erc8004AgentDocument {
367    let a2a = &options.a2a;
368
369    // Auto-generate skill IDs if missing
370    let skills = a2a.as_ref().and_then(|a| {
371        a.skills.as_ref().map(|skills| {
372            skills
373                .iter()
374                .map(|s| AgentSkill {
375                    id: if s.id.is_empty() {
376                        slugify(&s.name)
377                    } else {
378                        s.id.clone()
379                    },
380                    ..s.clone()
381                })
382                .collect()
383        })
384    });
385
386    // Auto-generate supportedInterfaces from url if not explicitly provided
387    let supported_interfaces = a2a.as_ref().map(|a| {
388        a.supported_interfaces.clone().unwrap_or_else(|| {
389            vec![AgentInterface {
390                url: a.url.clone(),
391                protocol_binding: "JSONRPC".to_string(),
392                protocol_version: "0.3.0".to_string(),
393            }]
394        })
395    });
396
397    // Ensure services array contains an A2A entry when a2a is provided
398    let mut services = options.services;
399    if let Some(a) = a2a.as_ref() {
400        if !services.iter().any(|s| s.name == "A2A") {
401            services.push(Erc8004Service {
402                name: "A2A".to_string(),
403                endpoint: a.url.clone(),
404                version: Some(a.version.clone()),
405            });
406        }
407    }
408
409    let (version, url, provider, capabilities, security_schemes, security,
410         default_input_modes, default_output_modes, icon_url, documentation_url,
411         signatures, extensions) = match a2a.as_ref() {
412        Some(a) => (
413            Some(a.version.clone()),
414            Some(a.url.clone()),
415            a.provider.clone(),
416            Some(a.capabilities.clone().unwrap_or(A2ACapabilities {
417                streaming: false,
418                push_notifications: false,
419                state_transition_history: Some(false),
420                extended_agent_card: Some(false),
421            })),
422            a.security_schemes.clone(),
423            a.security.clone(),
424            a.default_input_modes.clone(),
425            a.default_output_modes.clone(),
426            Some(a.icon_url.clone().unwrap_or_else(|| options.image.clone())),
427            a.documentation_url.clone(),
428            a.signatures.clone(),
429            a.extensions.clone(),
430        ),
431        None => (None, None, None, None, None, None, None, None, None, None, None, None),
432    };
433
434    Erc8004AgentDocument {
435        doc_type: "https://eips.ethereum.org/EIPS/eip-8004#registration-v1".to_string(),
436        name: options.name,
437        description: options.description,
438        image: options.image,
439        services,
440        active: options.active,
441        registrations: options.registrations,
442        supported_trust: options.supported_trust,
443        version,
444        url,
445        provider,
446        capabilities,
447        security_schemes,
448        security,
449        default_input_modes,
450        default_output_modes,
451        supported_interfaces,
452        icon_url,
453        documentation_url,
454        signatures,
455        extensions,
456        self_protocol: None,
457        skills,
458    }
459}