agentbadge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentbadge/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """Agent Badge Python SDK."""
2
+
3
+ from agentbadge_core.agent_card import AgentCard, badge_fragment, patch_badge
4
+ from agentbadge_core.protocols import (
5
+ SUPPORTED_PROTOCOLS,
6
+ badge_headers,
7
+ find_badge,
8
+ placement_table,
9
+ )
10
+
11
+ from agentbadge.auth import agent_auth_headers
12
+ from agentbadge.client import AgentBadgeClient
13
+ from agentbadge.types import (
14
+ AckResponse,
15
+ AgentBadgeAPIError,
16
+ AgentMessage,
17
+ BadgeDescriptor,
18
+ BadgeFeatures,
19
+ BadgeMetadata,
20
+ ChallengeInfo,
21
+ EnrollResponse,
22
+ MessagesResponse,
23
+ SendMessageResponse,
24
+ SubscribeResponse,
25
+ VerificationResponse,
26
+ )
27
+ from agentbadge.verify import verify_agent_card, verify_badge
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "SUPPORTED_PROTOCOLS",
33
+ "AckResponse",
34
+ "AgentBadgeAPIError",
35
+ "AgentBadgeClient",
36
+ "AgentCard",
37
+ "AgentMessage",
38
+ "BadgeDescriptor",
39
+ "BadgeFeatures",
40
+ "BadgeMetadata",
41
+ "ChallengeInfo",
42
+ "EnrollResponse",
43
+ "MessagesResponse",
44
+ "SendMessageResponse",
45
+ "SubscribeResponse",
46
+ "VerificationResponse",
47
+ "agent_auth_headers",
48
+ "badge_fragment",
49
+ "badge_headers",
50
+ "find_badge",
51
+ "patch_badge",
52
+ "placement_table",
53
+ "verify_agent_card",
54
+ "verify_badge",
55
+ ]
agentbadge/auth.py ADDED
@@ -0,0 +1,29 @@
1
+ """Agent-side authentication for the messaging endpoints.
2
+
3
+ The agent proves control of its card key by signing
4
+ "<badgeId>:<timestamp>:<nonce>" with the Ed25519 key its AgentCard exposes.
5
+ """
6
+
7
+ import base64
8
+ import secrets
9
+ import time
10
+
11
+ from agentbadge_core.crypto import Ed25519KeyPair
12
+
13
+
14
+ def agent_auth_headers(
15
+ badge_id: str,
16
+ keypair: Ed25519KeyPair,
17
+ timestamp: int | None = None,
18
+ nonce: str | None = None,
19
+ ) -> dict[str, str]:
20
+ """The X-Agent-Signature / X-Agent-Timestamp / X-Agent-Nonce headers for one
21
+ request. Each call uses a fresh nonce — headers must not be reused."""
22
+ ts = timestamp if timestamp is not None else int(time.time())
23
+ n = nonce if nonce is not None else secrets.token_hex(16)
24
+ signature = keypair.sign(f"{badge_id}:{ts}:{n}".encode("ascii"))
25
+ return {
26
+ "X-Agent-Signature": base64.b64encode(signature).decode("ascii"),
27
+ "X-Agent-Timestamp": str(ts),
28
+ "X-Agent-Nonce": n,
29
+ }
agentbadge/client.py ADDED
@@ -0,0 +1,296 @@
1
+ """Async HTTP client for the Agent Badge API."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ import httpx
7
+ from agentbadge_core.agent_card import AgentCard
8
+ from agentbadge_core.constants import EXTENSION_URI
9
+ from agentbadge_core.crypto import Ed25519KeyPair
10
+
11
+ from agentbadge.auth import agent_auth_headers
12
+ from agentbadge.types import (
13
+ AckResponse,
14
+ AgentBadgeAPIError,
15
+ BadgeFeatures,
16
+ BadgeMetadata,
17
+ EnrollResponse,
18
+ MessagesResponse,
19
+ SendMessageResponse,
20
+ SubscribeResponse,
21
+ VerificationResponse,
22
+ )
23
+
24
+
25
+ class AgentBadgeClient:
26
+ """Thin typed client for the Agent Badge API.
27
+
28
+ An `httpx.AsyncClient` may be injected (tests pass one backed by
29
+ `httpx.MockTransport`); it is used both for API calls and for fetching
30
+ AgentCards in `verify_agent_card`. Operator-side messaging
31
+ (`send_message`) requires `admin_api_key`; agent-side messaging (poll,
32
+ ack, webhooks) signs each request with the agent's Ed25519 keypair.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ base_url: str = "http://localhost:8000",
38
+ client: httpx.AsyncClient | None = None,
39
+ admin_api_key: str | None = None,
40
+ ):
41
+ self.base_url = base_url.rstrip("/")
42
+ self._client = client
43
+ self._owns_client = client is None
44
+ self.admin_api_key = admin_api_key
45
+
46
+ async def _http(self) -> httpx.AsyncClient:
47
+ if self._client is None:
48
+ self._client = httpx.AsyncClient(timeout=10.0, follow_redirects=True)
49
+ return self._client
50
+
51
+ async def aclose(self) -> None:
52
+ if self._owns_client and self._client is not None:
53
+ await self._client.aclose()
54
+
55
+ async def __aenter__(self) -> "AgentBadgeClient":
56
+ return self
57
+
58
+ async def __aexit__(self, *exc_info: object) -> None:
59
+ await self.aclose()
60
+
61
+ async def _request(self, method: str, path_or_url: str, **kwargs: Any) -> Any:
62
+ url = path_or_url if path_or_url.startswith("http") else f"{self.base_url}{path_or_url}"
63
+ client = await self._http()
64
+ response = await client.request(method, url, **kwargs)
65
+ if response.status_code >= 400:
66
+ try:
67
+ body = response.json()
68
+ except ValueError:
69
+ body = {}
70
+ raise AgentBadgeAPIError(
71
+ status_code=response.status_code,
72
+ error=body.get("error", "http_error"),
73
+ message=body.get("message", response.text[:200]),
74
+ request_id=body.get("requestId"),
75
+ )
76
+ if response.status_code == 204 or not response.content:
77
+ return None
78
+ try:
79
+ return response.json()
80
+ except ValueError as exc:
81
+ raise AgentBadgeAPIError(
82
+ status_code=response.status_code,
83
+ error="invalid_response",
84
+ message=f"Response from {url} is not valid JSON",
85
+ ) from exc
86
+
87
+ async def verify_badge(self, badge_id: str) -> VerificationResponse:
88
+ """GET /v1/verify/{badge_id} as a typed VerificationResponse."""
89
+ data = await self._request("GET", f"/v1/verify/{badge_id}")
90
+ return VerificationResponse.model_validate(data)
91
+
92
+ async def verify_agent_card(self, card_url: str) -> VerificationResponse:
93
+ """Fetch an AgentCard, find its Agent Badge extension, verify the badge."""
94
+ card = await self._request("GET", card_url)
95
+ if not isinstance(card, dict):
96
+ raise AgentBadgeAPIError(
97
+ 200, "card_invalid", f"AgentCard at {card_url} is not an object"
98
+ )
99
+ extension = AgentCard.find_extension(card, EXTENSION_URI)
100
+ if extension is None:
101
+ raise AgentBadgeAPIError(
102
+ 200,
103
+ "badge_extension_not_found",
104
+ f"AgentCard at {card_url} carries no Agent Badge extension ({EXTENSION_URI})",
105
+ )
106
+ badge_id = (extension.get("params") or {}).get("badgeId")
107
+ if not badge_id:
108
+ raise AgentBadgeAPIError(
109
+ 200,
110
+ "badge_id_missing",
111
+ "Agent Badge extension params carry no badgeId",
112
+ )
113
+ return await self.verify_badge(badge_id)
114
+
115
+ async def get_credential(self, badge_id: str) -> dict:
116
+ """GET /v1/credentials/{badge_id} — the full flattened JWS credential."""
117
+ return await self._request("GET", f"/v1/credentials/{badge_id}")
118
+
119
+ async def create_subscribe_intent(
120
+ self,
121
+ card_url: str,
122
+ plan: str = "monthly",
123
+ badge_type: str = "trust",
124
+ evidence: dict | None = None,
125
+ linked_credentials: list[dict] | None = None,
126
+ ) -> dict:
127
+ """POST /v1/subscribe/intents — free, and the first half of buying a badge.
128
+
129
+ Returns the terms the agent's own wallet needs in order to pay:
130
+ `subscribe_url`, `amount_usdc_base_units`, `pay_to`, `network`,
131
+ `asset_contract`. The SDK deliberately stops here — it holds no key and
132
+ never pays.
133
+ """
134
+ body: dict = {"card_url": card_url, "plan": plan, "badge_type": badge_type}
135
+ if evidence is not None:
136
+ body["evidence"] = evidence
137
+ if linked_credentials is not None:
138
+ body["linked_credentials"] = linked_credentials
139
+ return await self._request("POST", "/v1/subscribe/intents", json=body)
140
+
141
+ async def subscribe_status(self, intent_id: str) -> dict:
142
+ """GET /v1/subscribe/status — has this intent been paid?
143
+
144
+ A subscription carrying a settle tx is the proof of payment; poll this
145
+ rather than trusting any party's self-report.
146
+ """
147
+ return await self._request("GET", "/v1/subscribe/status", params={"intent_id": intent_id})
148
+
149
+ async def wear(self, intent_id: str, card_path: str, *, dry_run: bool = False) -> dict:
150
+ """Place the badge bought under `intent_id` onto this agent's own card.
151
+
152
+ Runs entirely in the caller's process against the caller's own file:
153
+ the SDK fetches the descriptor it already paid for, then patches the
154
+ local card. Nothing here writes to anybody else's document — placement
155
+ is the badge holder's own act, and this only removes the JSON surgery.
156
+
157
+ Raises if the intent has not settled: there is no descriptor to wear
158
+ until it has been paid for.
159
+ """
160
+ from agentbadge_core.wear import wear_badge
161
+
162
+ status = await self.subscribe_status(intent_id)
163
+ descriptor = status.get("badge")
164
+ if not descriptor:
165
+ raise AgentBadgeAPIError(
166
+ 409,
167
+ "not_settled",
168
+ f"Intent {intent_id} has no badge to wear yet (status: {status.get('status')}). "
169
+ "Pay the subscribe_url first.",
170
+ )
171
+ return wear_badge(descriptor, card_path, dry_run=dry_run)
172
+
173
+ async def enroll(
174
+ self, card_url: str, badge_type: str, evidence: dict | None = None
175
+ ) -> EnrollResponse:
176
+ """Removed — badges are bought, not enrolled.
177
+
178
+ Raises instead of calling the endpoint so an integration pinned to the
179
+ old SDK gets an error that says where issuance went, rather than a bare
180
+ 410 from the wire.
181
+ """
182
+ raise AgentBadgeAPIError(
183
+ 410,
184
+ "enroll_gone",
185
+ "Free enrollment was removed. Call create_subscribe_intent(...), have the "
186
+ "agent's own wallet pay the returned subscribe_url with an x402 `exact` "
187
+ "payment, then poll subscribe_status(intent_id).",
188
+ )
189
+
190
+ async def get_jwks(self) -> dict:
191
+ """GET /.well-known/jwks.json."""
192
+ return await self._request("GET", "/.well-known/jwks.json")
193
+
194
+ # ------------------------------------------------------------------
195
+ # Messaging channel
196
+
197
+ def _admin_headers(self) -> dict[str, str]:
198
+ if not self.admin_api_key:
199
+ raise AgentBadgeAPIError(
200
+ 0, "admin_api_key_required", "This call requires admin_api_key on the client"
201
+ )
202
+ return {"X-API-Key": self.admin_api_key}
203
+
204
+ async def send_message(
205
+ self,
206
+ badge_id: str,
207
+ payload: dict,
208
+ message_type: str | None = None,
209
+ expires_at: str | datetime | None = None,
210
+ ) -> SendMessageResponse:
211
+ """Operator → agent: POST /v1/agent-messages/{badge_id}.
212
+
213
+ The message type defaults to payload["type"] (or "arbitrary").
214
+ """
215
+ body: dict = {
216
+ "message_type": message_type or payload.get("type", "arbitrary"),
217
+ "payload": payload,
218
+ }
219
+ if expires_at is not None:
220
+ body["expires_at"] = (
221
+ expires_at.isoformat() if isinstance(expires_at, datetime) else expires_at
222
+ )
223
+ data = await self._request(
224
+ "POST",
225
+ f"/v1/agent-messages/{badge_id}",
226
+ json=body,
227
+ headers=self._admin_headers(),
228
+ )
229
+ return SendMessageResponse.model_validate(data)
230
+
231
+ async def poll_messages(
232
+ self,
233
+ badge_id: str,
234
+ keypair: Ed25519KeyPair,
235
+ since: str | datetime | None = None,
236
+ ) -> MessagesResponse:
237
+ """Agent: GET /v1/agent-messages/{badge_id} with a signed request."""
238
+ params: dict = {}
239
+ if since is not None:
240
+ params["since"] = since.isoformat() if isinstance(since, datetime) else since
241
+ data = await self._request(
242
+ "GET",
243
+ f"/v1/agent-messages/{badge_id}",
244
+ params=params,
245
+ headers=agent_auth_headers(badge_id, keypair),
246
+ )
247
+ return MessagesResponse.model_validate(data)
248
+
249
+ async def ack_messages(
250
+ self, badge_id: str, keypair: Ed25519KeyPair, message_ids: list[str]
251
+ ) -> AckResponse:
252
+ """Agent: POST /v1/agent-messages/{badge_id}/ack with a signed request."""
253
+ data = await self._request(
254
+ "POST",
255
+ f"/v1/agent-messages/{badge_id}/ack",
256
+ json={"message_ids": message_ids},
257
+ headers=agent_auth_headers(badge_id, keypair),
258
+ )
259
+ return AckResponse.model_validate(data)
260
+
261
+ async def register_webhook(
262
+ self, badge_id: str, keypair: Ed25519KeyPair, webhook_url: str
263
+ ) -> SubscribeResponse:
264
+ """Agent: POST /v1/agent-subscriptions. The returned HMAC secret is
265
+ shown exactly once — store it."""
266
+ data = await self._request(
267
+ "POST",
268
+ "/v1/agent-subscriptions",
269
+ json={"badge_id": badge_id, "webhook_url": webhook_url},
270
+ headers=agent_auth_headers(badge_id, keypair),
271
+ )
272
+ return SubscribeResponse.model_validate(data)
273
+
274
+ async def unregister_webhook(
275
+ self, subscription_id: str, badge_id: str, keypair: Ed25519KeyPair
276
+ ) -> None:
277
+ """Agent: DELETE /v1/agent-subscriptions/{id}, signed with the badge
278
+ that owns the subscription."""
279
+ await self._request(
280
+ "DELETE",
281
+ f"/v1/agent-subscriptions/{subscription_id}",
282
+ headers=agent_auth_headers(badge_id, keypair),
283
+ )
284
+
285
+ # ------------------------------------------------------------------
286
+ # Live metadata (Strategy B)
287
+
288
+ async def get_badge_metadata(self, badge_id: str) -> BadgeMetadata:
289
+ """GET /v1/badges/{badge_id}/metadata — live, never-frozen badge data."""
290
+ data = await self._request("GET", f"/v1/badges/{badge_id}/metadata")
291
+ return BadgeMetadata.model_validate(data)
292
+
293
+ async def get_badge_features(self, badge_id: str) -> BadgeFeatures:
294
+ """GET /v1/badges/{badge_id}/features — cacheable feature flags."""
295
+ data = await self._request("GET", f"/v1/badges/{badge_id}/features")
296
+ return BadgeFeatures.model_validate(data)
agentbadge/types.py ADDED
@@ -0,0 +1,101 @@
1
+ """Typed responses mirroring the canonical JSON Schemas."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class VerificationResponse(BaseModel):
9
+ badgeId: str
10
+ status: Literal["active", "expired", "revoked", "downgraded", "unknown"]
11
+ tier: Literal["unknown", "monitored", "verified", "certified"]
12
+ claims: list[str]
13
+ issuer: str
14
+ expires: str
15
+ cardHash: str
16
+ revocationReason: str | None = None
17
+
18
+
19
+ class BadgeDescriptor(BaseModel):
20
+ uri: str
21
+ description: str
22
+ required: bool = False
23
+ params: dict
24
+
25
+
26
+ class ChallengeInfo(BaseModel):
27
+ domain: str
28
+ method: Literal["dns_txt", "well_known_http"]
29
+ token: str
30
+ instructions: str
31
+ expires_at: str
32
+
33
+
34
+ class EnrollResponse(BaseModel):
35
+ status: Literal["enrolled", "pending_domain_verification"]
36
+ badge: BadgeDescriptor | None = None
37
+ badge_id: str | None = None
38
+ challenge: ChallengeInfo | None = None
39
+
40
+
41
+ class SendMessageResponse(BaseModel):
42
+ message_id: str
43
+ badge_id: str
44
+ status: Literal["pending", "delivered"]
45
+ webhook_deliveries: int = 0
46
+
47
+
48
+ class AgentMessage(BaseModel):
49
+ message_id: str
50
+ message_type: str
51
+ direction: str
52
+ payload: dict
53
+ status: str
54
+ created_at: str
55
+ expires_at: str | None = None
56
+
57
+
58
+ class MessagesResponse(BaseModel):
59
+ badge_id: str
60
+ messages: list[AgentMessage]
61
+
62
+
63
+ class AckResponse(BaseModel):
64
+ badge_id: str
65
+ acked: list[str]
66
+
67
+
68
+ class SubscribeResponse(BaseModel):
69
+ subscription_id: str
70
+ badge_id: str
71
+ webhook_url: str
72
+ secret: str
73
+
74
+
75
+ class BadgeMetadata(BaseModel):
76
+ badgeId: str
77
+ trustScore: float
78
+ verificationCount: int
79
+ features: list[str]
80
+ ambassadorCode: str | None = None
81
+ recruitUrl: str | None = None
82
+ linkedCredentials: list[dict] = []
83
+ openOffers: int
84
+ lastUpdated: str
85
+
86
+
87
+ class BadgeFeatures(BaseModel):
88
+ badgeId: str
89
+ features: list[str]
90
+ lastUpdated: str
91
+
92
+
93
+ class AgentBadgeAPIError(Exception):
94
+ """Raised when the API returns an error response."""
95
+
96
+ def __init__(self, status_code: int, error: str, message: str, request_id: str | None = None):
97
+ self.status_code = status_code
98
+ self.error = error
99
+ self.message = message
100
+ self.request_id = request_id
101
+ super().__init__(f"[{status_code}] {error}: {message}")
agentbadge/verify.py ADDED
@@ -0,0 +1,20 @@
1
+ """Convenience verification functions."""
2
+
3
+ from agentbadge.client import AgentBadgeClient
4
+ from agentbadge.types import VerificationResponse
5
+
6
+
7
+ async def verify_badge(
8
+ badge_id: str, api_url: str = "http://localhost:8000"
9
+ ) -> VerificationResponse:
10
+ """Verify a badge by ID against the given API."""
11
+ async with AgentBadgeClient(api_url) as client:
12
+ return await client.verify_badge(badge_id)
13
+
14
+
15
+ async def verify_agent_card(
16
+ card_url: str, api_url: str = "http://localhost:8000"
17
+ ) -> VerificationResponse:
18
+ """Fetch an AgentCard, find its badge extension, and verify the badge."""
19
+ async with AgentBadgeClient(api_url) as client:
20
+ return await client.verify_agent_card(card_url)
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentbadge
3
+ Version: 0.1.0
4
+ Summary: Agent Badge Python SDK — HTTP client for the Agent Badge API
5
+ Project-URL: Homepage, https://badge.swarmintel.xyz
6
+ Project-URL: Documentation, https://badge.swarmintel.xyz/.well-known/agentbadge.json
7
+ Project-URL: Repository, https://github.com/casey1088/agent-badge
8
+ Author: Swarm Intel
9
+ License-Expression: MIT
10
+ Keywords: a2a,agent,agentcard,identity,verification,x402
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Security
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: agentbadge-core~=0.1.0
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: pydantic>=2.5
20
+ Description-Content-Type: text/markdown
21
+
22
+ # agentbadge
23
+
24
+ Python HTTP client for the Agent Badge API.
25
+
26
+ ```python
27
+ from agentbadge import AgentBadgeClient
28
+
29
+ async with AgentBadgeClient("https://badge.swarmintel.xyz") as client:
30
+ status = await client.verify("bdg_...")
31
+ ```
32
+
33
+ Verification is public and needs no credentials. Buying a badge requires an
34
+ x402 payment from your own wallet; this client never holds a key and never
35
+ pays on your behalf.
36
+
37
+ Nothing here is required to integrate. The API is plain HTTP and the full
38
+ flow is published at
39
+ `https://badge.swarmintel.xyz/.well-known/agentbadge.json`.
@@ -0,0 +1,8 @@
1
+ agentbadge/__init__.py,sha256=RaNtQJmFtzDrD_iok3ao9IsnEhGBobrvdMWmflKfu1A,1232
2
+ agentbadge/auth.py,sha256=d8ekKUkrn6eNrJOb2ZJEB2d6f0aDQPHk47RJDkGSoEM,957
3
+ agentbadge/client.py,sha256=7nIOHBWgZrSoZSAfitJ_fVdYJ3W9qXibXlLLziGssk8,11720
4
+ agentbadge/types.py,sha256=SsEDRF_Sd-b-We079odUWX1CZ0PXCintUcz64gSeNaM,2299
5
+ agentbadge/verify.py,sha256=91T4n9l5GMSWbrE0Ke__Qtehum9nqI210Ve72d44D-c,710
6
+ agentbadge-0.1.0.dist-info/METADATA,sha256=SNyv2OCMFZho4gKl647IjNBfA07jugApX_3ZMie5tWw,1366
7
+ agentbadge-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ agentbadge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any