@clawpump/claw-agent 0.1.7 → 0.1.8

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.
@@ -12081,46 +12081,105 @@ def _parse_mcp_json(text: str) -> Any:
12081
12081
  return data
12082
12082
 
12083
12083
 
12084
+ # ── Warm ClawPump MCP session (shared by the dashboard's MCP routes) ──────
12085
+ # Keep ONE MCP session alive and reuse it — the same thing the agent runtime
12086
+ # does, which is why chat kept working while the dashboard didn't. Opening a
12087
+ # fresh connection per request stalls the shared MCP event loop under OAuth
12088
+ # token churn and wedges every page until a restart. One warm session plus a
12089
+ # single reconnect-on-failure stays fast and self-heals. The lock serializes
12090
+ # calls (one JSON-RPC stream per session); the short timeout means a stall
12091
+ # fails fast instead of hanging forever.
12092
+ _warm_mcp_lock = threading.Lock()
12093
+ _warm_mcp_server: Any = None # live MCPServerTask, kept warm by its keepalive ping
12094
+
12095
+
12096
+ class _McpToolError(RuntimeError):
12097
+ """The tool ran but returned an error — the session itself is healthy."""
12098
+
12099
+
12100
+ def _clawpump_call(tool: str, arguments: Optional[dict] = None, *, timeout: float = 20) -> Any:
12101
+ """Call a ClawPump MCP tool over the dashboard's warm, reused session.
12102
+
12103
+ Reuses one long-lived connection and reconnects only if the session has
12104
+ died, so the dashboard behaves like the agent's persistent connection
12105
+ instead of reconnecting (and stalling) per request. Returns parsed JSON;
12106
+ raises ``RuntimeError`` when the MCP is unconfigured or the tool errors.
12107
+ """
12108
+ global _warm_mcp_server
12109
+
12110
+ name, cfg = _clawpump_mcp()
12111
+ if not name:
12112
+ raise RuntimeError("ClawPump MCP is not configured. Run `hermes clawpump setup`.")
12113
+
12114
+ from tools.mcp_tool import _ensure_mcp_loop, _run_on_mcp_loop, _connect_server
12115
+ from hermes_cli.mcp_config import _resolve_mcp_server_config
12116
+
12117
+ cfg = _resolve_mcp_server_config(cfg)
12118
+ _ensure_mcp_loop()
12119
+
12120
+ def _invoke(server: Any) -> Any:
12121
+ async def _coro():
12122
+ result = await server.session.call_tool(tool, arguments=arguments or {})
12123
+ text = "".join(b.text for b in (result.content or []) if hasattr(b, "text"))
12124
+ if getattr(result, "isError", False):
12125
+ raise _McpToolError(text or "MCP tool returned an error")
12126
+ return text
12127
+
12128
+ data = _parse_mcp_json(_run_on_mcp_loop(_coro, timeout=timeout))
12129
+ if isinstance(data, dict) and isinstance(data.get("error"), str) and data["error"]:
12130
+ raise _McpToolError(data["error"])
12131
+ return data
12132
+
12133
+ with _warm_mcp_lock:
12134
+ server = _warm_mcp_server
12135
+ if server is not None:
12136
+ try:
12137
+ return _invoke(server)
12138
+ except _McpToolError:
12139
+ raise # the session is fine — the tool itself returned an error
12140
+ except Exception:
12141
+ _warm_mcp_server = None # transport died — drop it and reconnect
12142
+ try:
12143
+ _run_on_mcp_loop(lambda: server.shutdown(), timeout=5)
12144
+ except Exception:
12145
+ pass
12146
+ server = _run_on_mcp_loop(lambda: _connect_server(name, cfg), timeout=timeout)
12147
+ _warm_mcp_server = server
12148
+ return _invoke(server)
12149
+
12150
+
12084
12151
  @app.get("/api/wallet/balances")
12085
12152
  def get_wallet_balances():
12086
12153
  """Agent wallet balances (name + address + SOL + USDC) via the ClawPump MCP.
12087
12154
 
12088
12155
  Calls ``get_wallet_summaries`` (and ``list_agents`` for display names) over
12089
- a short-lived MCP session (OAuth/API-key from ``hermes clawpump setup``).
12090
- Read-only. Sync def so FastAPI runs it in a threadpool — MCP call blocks.
12156
+ the dashboard's warm MCP session. Read-only. Sync def MCP call blocks.
12091
12157
  """
12092
- from hermes_cli.mcp_config import _call_single_tool
12093
-
12094
- srv_name, srv_cfg = _clawpump_mcp()
12095
- if not srv_name:
12096
- return {
12097
- "ok": False,
12098
- "error": "ClawPump MCP is not configured. Run `hermes clawpump setup`.",
12099
- "wallets": [],
12100
- }
12101
-
12102
12158
  try:
12103
- text = _call_single_tool(srv_name, srv_cfg, "get_wallet_summaries", {})
12159
+ data = _clawpump_call("get_wallet_summaries", {})
12104
12160
  except Exception as exc: # surface any connection / tool error to the UI
12105
12161
  return {"ok": False, "error": str(exc), "wallets": []}
12106
12162
 
12107
- data = _parse_mcp_json(text)
12108
12163
  wallets = data if isinstance(data, list) else []
12109
12164
 
12110
12165
  # Enrich with the agent's display name — the summaries only carry the
12111
12166
  # agent_id UUID. Best-effort: a failure here just leaves ``name`` unset.
12112
12167
  try:
12113
- agents = _parse_mcp_json(_call_single_tool(srv_name, srv_cfg, "list_agents", {}))
12168
+ agents = _clawpump_call("list_agents", {})
12114
12169
  if isinstance(agents, dict):
12115
12170
  agents = agents.get("agents")
12116
- names = {
12117
- a["id"]: a.get("name")
12171
+ by_id = {
12172
+ a["id"]: a
12118
12173
  for a in (agents or [])
12119
12174
  if isinstance(a, dict) and a.get("id")
12120
12175
  }
12121
12176
  for w in wallets:
12122
12177
  if isinstance(w, dict):
12123
- w["name"] = names.get(w.get("agent_id"))
12178
+ meta = by_id.get(w.get("agent_id")) or {}
12179
+ w["name"] = meta.get("name")
12180
+ w["token_mint"] = meta.get("token_mint")
12181
+ # avatar_url is fetched lazily via /api/agent/avatar — list_agents
12182
+ # nulls it; only the single get_agent returns it.
12124
12183
  except Exception:
12125
12184
  pass
12126
12185
 
@@ -12196,31 +12255,16 @@ def x402_search(q: str = "", network: str = "solana"):
12196
12255
  The ClawPump wallet settles x402 in USDC on Solana, so the default is
12197
12256
  ``solana``; pass ``network=all`` to see every chain. Sync def — MCP blocks.
12198
12257
  """
12199
- from hermes_cli.mcp_config import _call_single_tool
12200
-
12201
12258
  query = (q or "").strip()
12202
12259
  if not query:
12203
12260
  return {"ok": True, "query": "", "network": network, "results": []}
12204
12261
 
12205
- srv_name, srv_cfg = _clawpump_mcp()
12206
- if not srv_name:
12207
- return {
12208
- "ok": False,
12209
- "error": "ClawPump MCP is not configured. Run `hermes clawpump setup`.",
12210
- "results": [],
12211
- }
12212
-
12213
12262
  try:
12214
- text = _call_single_tool(
12215
- srv_name, srv_cfg, "dexter_search", {"query": query, "network": network}
12216
- )
12263
+ data = _clawpump_call("dexter_search", {"query": query, "network": network})
12217
12264
  except Exception as exc: # surface any connection / tool error to the UI
12218
12265
  return {"ok": False, "error": str(exc), "results": []}
12219
12266
 
12220
- data = _parse_mcp_json(text)
12221
12267
  if isinstance(data, dict):
12222
- if isinstance(data.get("error"), str) and data["error"]:
12223
- return {"ok": False, "error": data["error"], "results": []}
12224
12268
  results = data.get("results")
12225
12269
  results = results if isinstance(results, list) else []
12226
12270
  elif isinstance(data, list):
@@ -12230,6 +12274,153 @@ def x402_search(q: str = "", network: str = "solana"):
12230
12274
  return {"ok": True, "query": query, "network": network, "results": results}
12231
12275
 
12232
12276
 
12277
+ # ---------------------------------------------------------------------------
12278
+ # Agent Mail (AgentMail) — agent email inbox via the ClawPump MCP
12279
+ # ---------------------------------------------------------------------------
12280
+ # Thin proxies over the ``agent_mail_*`` MCP tools, mirroring the x402 route
12281
+ # above. The two spending/outward actions (provision an inbox, send an email)
12282
+ # require an explicit ``confirm`` from the UI — the dashboard never auto-spends.
12283
+
12284
+
12285
+ def _agent_args(agent_id: Optional[str]) -> Dict[str, Any]:
12286
+ """Base args for a ClawPump MCP call. Omit ``agent_id`` entirely when the
12287
+ dashboard hasn't picked one so the MCP resolves the default/only agent."""
12288
+ aid = (agent_id or "").strip()
12289
+ return {"agent_id": aid} if aid else {}
12290
+
12291
+
12292
+ def _clawpump_call_once(tool: str, arguments: Optional[dict] = None, *, timeout: float = 30) -> Any:
12293
+ """Call a ClawPump MCP tool over a fresh, one-shot connection.
12294
+
12295
+ For rare, irreversible actions (wallet transfers, email sends, paid inbox
12296
+ provisioning): a fresh connect means current auth and — crucially — exactly
12297
+ one attempt, so a dropped response is never silently re-sent. Frequent reads
12298
+ use the warm ``_clawpump_call`` instead.
12299
+ """
12300
+ from hermes_cli.mcp_config import _call_single_tool
12301
+
12302
+ name, cfg = _clawpump_mcp()
12303
+ if not name:
12304
+ raise RuntimeError("ClawPump MCP is not configured. Run `hermes clawpump setup`.")
12305
+ data = _parse_mcp_json(_call_single_tool(name, cfg, tool, arguments or {}, connect_timeout=timeout))
12306
+ if isinstance(data, dict) and isinstance(data.get("error"), str) and data["error"]:
12307
+ raise RuntimeError(data["error"])
12308
+ return data
12309
+
12310
+
12311
+ class MailCreateBody(BaseModel):
12312
+ agent_id: Optional[str] = None
12313
+ username: Optional[str] = None
12314
+ confirm: bool = False
12315
+
12316
+
12317
+ class MailSendBody(BaseModel):
12318
+ agent_id: Optional[str] = None
12319
+ to: Any = None # str | list[str] — the MCP tool accepts either
12320
+ subject: str
12321
+ text: Optional[str] = None
12322
+ html: Optional[str] = None
12323
+ cc: Optional[List[str]] = None
12324
+ bcc: Optional[List[str]] = None
12325
+ reply_to: Optional[str] = None
12326
+ confirm: bool = False
12327
+
12328
+
12329
+ @app.get("/api/mail/address")
12330
+ def mail_address(agent_id: str = ""):
12331
+ """The agent's email address + inbox status via ``agent_mail_get_address``."""
12332
+ try:
12333
+ data = _clawpump_call("agent_mail_get_address", _agent_args(agent_id))
12334
+ except Exception as exc:
12335
+ return {"ok": False, "error": str(exc), "has_inbox": False, "inbox": None}
12336
+ inbox = data.get("inbox") if isinstance(data, dict) else None
12337
+ return {"ok": True, "has_inbox": bool(inbox), "inbox": inbox}
12338
+
12339
+
12340
+ @app.get("/api/mail/messages")
12341
+ def mail_messages(agent_id: str = "", direction: str = "", limit: int = 50):
12342
+ """List the agent's emails (synced inbound + sent) via ``agent_mail_list``."""
12343
+ args = _agent_args(agent_id)
12344
+ if direction in ("inbound", "outbound"):
12345
+ args["direction"] = direction
12346
+ args["limit"] = max(1, min(int(limit or 50), 200))
12347
+ try:
12348
+ data = _clawpump_call("agent_mail_list", args)
12349
+ except Exception as exc:
12350
+ return {"ok": False, "error": str(exc), "messages": []}
12351
+ messages = data.get("messages") if isinstance(data, dict) else None
12352
+ return {"ok": True, "messages": messages if isinstance(messages, list) else []}
12353
+
12354
+
12355
+ @app.get("/api/mail/message")
12356
+ def mail_message(message_id: str, agent_id: str = ""):
12357
+ """Read one email (full body) via ``agent_mail_read``."""
12358
+ args = _agent_args(agent_id)
12359
+ args["message_id"] = message_id
12360
+ try:
12361
+ data = _clawpump_call("agent_mail_read", args)
12362
+ except Exception as exc:
12363
+ return {"ok": False, "error": str(exc), "message": None}
12364
+ message = data.get("message") if isinstance(data, dict) else None
12365
+ return {"ok": True, "message": message}
12366
+
12367
+
12368
+ @app.post("/api/mail/create")
12369
+ def mail_create(body: MailCreateBody):
12370
+ """Provision the agent's inbox (~$2 USDC) via ``agent_mail_create``."""
12371
+ if not body.confirm:
12372
+ return {
12373
+ "ok": False,
12374
+ "error": "Provisioning an inbox costs ~$2 USDC from the agent wallet — confirm to proceed.",
12375
+ }
12376
+ args = _agent_args(body.agent_id)
12377
+ args["confirm_provision"] = True
12378
+ if body.username and body.username.strip():
12379
+ args["username"] = body.username.strip()
12380
+ try:
12381
+ data = _clawpump_call_once("agent_mail_create", args)
12382
+ except Exception as exc:
12383
+ return {"ok": False, "error": str(exc)}
12384
+ if not isinstance(data, dict):
12385
+ data = {}
12386
+ return {
12387
+ "ok": True,
12388
+ "inbox": data.get("inbox"),
12389
+ "alreadyExisted": bool(data.get("alreadyExisted")),
12390
+ "note": data.get("note"),
12391
+ }
12392
+
12393
+
12394
+ @app.post("/api/mail/send")
12395
+ def mail_send(body: MailSendBody):
12396
+ """Send an email from the agent's inbox via ``agent_mail_send`` (x402)."""
12397
+ if not body.confirm:
12398
+ return {"ok": False, "error": "Sending email is outward-facing — confirm to proceed."}
12399
+ if not (body.text and body.text.strip()) and not (body.html and body.html.strip()):
12400
+ return {"ok": False, "error": "Provide a message body."}
12401
+ if not body.to:
12402
+ return {"ok": False, "error": "At least one recipient is required."}
12403
+ args = _agent_args(body.agent_id)
12404
+ args["confirm_send"] = True
12405
+ args["to"] = body.to
12406
+ args["subject"] = body.subject
12407
+ if body.text:
12408
+ args["text"] = body.text
12409
+ if body.html:
12410
+ args["html"] = body.html
12411
+ if body.cc:
12412
+ args["cc"] = body.cc
12413
+ if body.bcc:
12414
+ args["bcc"] = body.bcc
12415
+ if body.reply_to and body.reply_to.strip():
12416
+ args["reply_to"] = body.reply_to.strip()
12417
+ try:
12418
+ data = _clawpump_call_once("agent_mail_send", args)
12419
+ except Exception as exc:
12420
+ return {"ok": False, "error": str(exc)}
12421
+ return {"ok": True, "result": data}
12422
+
12423
+
12233
12424
  _FONT_DEFAULT_ID = "space-grotesk"
12234
12425
  _FONT_CHOICES = frozenset({
12235
12426
  "system-sans", "system-serif", "system-mono",
Binary file
@@ -33,6 +33,7 @@ import {
33
33
  Globe,
34
34
  Heart,
35
35
  KeyRound,
36
+ Mail,
36
37
  Menu,
37
38
  MessageSquare,
38
39
  Package,
@@ -76,6 +77,7 @@ import type { SystemAction } from "@/contexts/system-actions-context";
76
77
  import ConfigPage from "@/pages/ConfigPage";
77
78
  import WalletPage from "@/pages/WalletPage";
78
79
  import X402Page from "@/pages/X402Page";
80
+ import AgentMailPage from "@/pages/AgentMailPage";
79
81
  import DocsPage from "@/pages/DocsPage";
80
82
  import EnvPage from "@/pages/EnvPage";
81
83
  import FilesPage from "@/pages/FilesPage";
@@ -137,6 +139,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
137
139
  "/": RootRedirect,
138
140
  "/wallet": WalletPage,
139
141
  "/x402": X402Page,
142
+ "/mail": AgentMailPage,
140
143
  "/sessions": SessionsPage,
141
144
  "/files": FilesPage,
142
145
  "/analytics": AnalyticsPage,
@@ -168,6 +171,7 @@ function ChatRouteSink() {
168
171
  const BUILTIN_NAV_REST: NavItem[] = [
169
172
  { path: "/wallet", label: "Wallet", icon: Wallet },
170
173
  { path: "/x402", label: "x402", icon: Zap },
174
+ { path: "/mail", label: "Mail", icon: Mail },
171
175
  {
172
176
  path: "/sessions",
173
177
  labelKey: "sessions",
@@ -522,10 +526,9 @@ export default function App() {
522
526
  </Button>
523
527
 
524
528
  <img
525
- src="/claw-logo.webp"
529
+ src="/claw-logo.png"
526
530
  alt=""
527
531
  className="h-7 w-7 shrink-0"
528
- style={{ mixBlendMode: "screen" }}
529
532
  />
530
533
  <Typography
531
534
  className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
@@ -587,10 +590,9 @@ export default function App() {
587
590
  <PluginSlot name="header-left" />
588
591
 
589
592
  <img
590
- src="/claw-logo.webp"
593
+ src="/claw-logo.png"
591
594
  alt="Claw Agent"
592
595
  className="h-8 w-8 shrink-0"
593
- style={{ mixBlendMode: "screen" }}
594
596
  />
595
597
  <Typography
596
598
  className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground uppercase"
@@ -313,6 +313,8 @@ function appendProfileParam(url: string, profile?: string): string {
313
313
  export interface AgentWalletBalance {
314
314
  agent_id: string;
315
315
  name?: string | null;
316
+ avatar_url?: string | null;
317
+ token_mint?: string | null;
316
318
  wallet_address: string | null;
317
319
  sol_balance: number | null;
318
320
  usdc_balance: number | null;
@@ -369,6 +371,86 @@ export interface X402SearchResponse {
369
371
  results: X402Result[];
370
372
  }
371
373
 
374
+ // ── Agent Mail (AgentMail, via the ClawPump MCP) ───────────────────────
375
+ export interface MailInbox {
376
+ id: string;
377
+ agentId: string;
378
+ provider: string;
379
+ inboxId: string;
380
+ emailAddress: string;
381
+ username: string;
382
+ domain: string;
383
+ webhookId: string | null;
384
+ verified: boolean;
385
+ status: string;
386
+ createdAt: string;
387
+ updatedAt: string;
388
+ }
389
+
390
+ export interface MailMessage {
391
+ id: string;
392
+ agentId: string;
393
+ inboxId: string;
394
+ messageId: string;
395
+ threadId: string | null;
396
+ direction: "inbound" | "outbound";
397
+ fromAddress: string | null;
398
+ toAddresses: string[];
399
+ ccAddresses: string[];
400
+ subject: string | null;
401
+ textBody: string | null;
402
+ htmlBody: string | null;
403
+ preview: string | null;
404
+ read: boolean;
405
+ agentmailCreatedAt: string | null;
406
+ createdAt: string;
407
+ }
408
+
409
+ export interface MailAddressResponse {
410
+ ok: boolean;
411
+ error?: string;
412
+ has_inbox: boolean;
413
+ inbox: MailInbox | null;
414
+ }
415
+
416
+ export interface MailMessagesResponse {
417
+ ok: boolean;
418
+ error?: string;
419
+ messages: MailMessage[];
420
+ }
421
+
422
+ export interface MailMessageResponse {
423
+ ok: boolean;
424
+ error?: string;
425
+ message: MailMessage | null;
426
+ }
427
+
428
+ export interface MailCreateResponse {
429
+ ok: boolean;
430
+ error?: string;
431
+ inbox?: MailInbox | null;
432
+ alreadyExisted?: boolean;
433
+ note?: string | null;
434
+ }
435
+
436
+ export interface MailSendBody {
437
+ agent_id: string;
438
+ to: string[];
439
+ subject: string;
440
+ text?: string;
441
+ html?: string;
442
+ cc?: string[];
443
+ bcc?: string[];
444
+ reply_to?: string;
445
+ confirm: boolean;
446
+ }
447
+
448
+ export interface MailSendResponse {
449
+ ok: boolean;
450
+ error?: string;
451
+ result?: unknown;
452
+ }
453
+
372
454
  export const api = {
373
455
  getStatus: () => fetchJSON<StatusResponse>("/api/status"),
374
456
  getWalletBalances: () =>
@@ -381,6 +463,36 @@ export const api = {
381
463
  headers: { "Content-Type": "application/json" },
382
464
  body: JSON.stringify(body),
383
465
  }),
466
+
467
+ // ── Agent Mail (AgentMail) ─────────────────────────────────────────
468
+ // Every call carries an explicit agent_id — the MCP requires it once the
469
+ // account has more than one agent.
470
+ getMailAddress: (agentId: string) =>
471
+ fetchJSON<MailAddressResponse>(
472
+ `/api/mail/address?agent_id=${encodeURIComponent(agentId)}`,
473
+ ),
474
+ listMail: (opts: { agentId: string; direction?: "inbound" | "outbound"; limit?: number }) => {
475
+ const qs = new URLSearchParams({ agent_id: opts.agentId });
476
+ if (opts.direction) qs.set("direction", opts.direction);
477
+ if (opts.limit) qs.set("limit", String(opts.limit));
478
+ return fetchJSON<MailMessagesResponse>(`/api/mail/messages?${qs.toString()}`);
479
+ },
480
+ readMail: (messageId: string, agentId: string) =>
481
+ fetchJSON<MailMessageResponse>(
482
+ `/api/mail/message?message_id=${encodeURIComponent(messageId)}&agent_id=${encodeURIComponent(agentId)}`,
483
+ ),
484
+ createInbox: (body: { agent_id: string; username?: string; confirm: boolean }) =>
485
+ fetchJSON<MailCreateResponse>("/api/mail/create", {
486
+ method: "POST",
487
+ headers: { "Content-Type": "application/json" },
488
+ body: JSON.stringify(body),
489
+ }),
490
+ sendMail: (body: MailSendBody) =>
491
+ fetchJSON<MailSendResponse>("/api/mail/send", {
492
+ method: "POST",
493
+ headers: { "Content-Type": "application/json" },
494
+ body: JSON.stringify(body),
495
+ }),
384
496
  /**
385
497
  * Identity probe for the dashboard auth gate (Phase 7).
386
498
  *
@@ -0,0 +1,684 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import {
3
+ ArrowLeft,
4
+ Check,
5
+ Copy,
6
+ Inbox,
7
+ Mail,
8
+ PenSquare,
9
+ RefreshCw,
10
+ Send,
11
+ ShieldCheck,
12
+ } from "lucide-react";
13
+ import { api } from "@/lib/api";
14
+ import type { AgentWalletBalance, MailInbox, MailMessage } from "@/lib/api";
15
+ import { Button } from "@nous-research/ui/ui/components/button";
16
+ import { Badge } from "@nous-research/ui/ui/components/badge";
17
+ import { Spinner } from "@nous-research/ui/ui/components/spinner";
18
+ import {
19
+ Card,
20
+ CardContent,
21
+ CardHeader,
22
+ CardTitle,
23
+ } from "@nous-research/ui/ui/components/card";
24
+
25
+ type View = "list" | "read" | "compose";
26
+ type Filter = "all" | "inbound" | "outbound";
27
+
28
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
29
+
30
+ function parseRecipients(raw: string): string[] {
31
+ return raw
32
+ .split(/[\s,;]+/)
33
+ .map((s) => s.trim())
34
+ .filter(Boolean);
35
+ }
36
+
37
+ function formatDate(iso: string | null): string {
38
+ if (!iso) return "";
39
+ const d = new Date(iso);
40
+ if (Number.isNaN(d.getTime())) return "";
41
+ return d.toLocaleString(undefined, {
42
+ month: "short",
43
+ day: "numeric",
44
+ hour: "2-digit",
45
+ minute: "2-digit",
46
+ });
47
+ }
48
+
49
+ const inputCls =
50
+ "w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary";
51
+
52
+ function CopyButton({ value }: { value: string }) {
53
+ const [copied, setCopied] = useState(false);
54
+ const onCopy = useCallback(() => {
55
+ navigator.clipboard
56
+ .writeText(value)
57
+ .then(() => {
58
+ setCopied(true);
59
+ setTimeout(() => setCopied(false), 1500);
60
+ })
61
+ .catch(() => {});
62
+ }, [value]);
63
+ return (
64
+ <button
65
+ type="button"
66
+ onClick={onCopy}
67
+ title="Copy email address"
68
+ aria-label="Copy email address"
69
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
70
+ >
71
+ {copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
72
+ </button>
73
+ );
74
+ }
75
+
76
+ export default function AgentMailPage() {
77
+ // ── Agent selection (the MCP requires an explicit agent_id) ────────
78
+ const [agents, setAgents] = useState<AgentWalletBalance[]>([]);
79
+ const [agentId, setAgentId] = useState("");
80
+
81
+ // ── Inbox state ────────────────────────────────────────────────────
82
+ const [inbox, setInbox] = useState<MailInbox | null>(null);
83
+ const [hasInbox, setHasInbox] = useState(false);
84
+ const [inboxLoading, setInboxLoading] = useState(true);
85
+ const [inboxError, setInboxError] = useState<string | null>(null);
86
+
87
+ // ── Provisioning ───────────────────────────────────────────────────
88
+ const [username, setUsername] = useState("");
89
+ const [creating, setCreating] = useState(false);
90
+ const [createArmed, setCreateArmed] = useState(false);
91
+ const [createError, setCreateError] = useState<string | null>(null);
92
+
93
+ // ── Messages ───────────────────────────────────────────────────────
94
+ const [view, setView] = useState<View>("list");
95
+ const [filter, setFilter] = useState<Filter>("all");
96
+ const [messages, setMessages] = useState<MailMessage[]>([]);
97
+ const [messagesLoading, setMessagesLoading] = useState(false);
98
+ const [messagesError, setMessagesError] = useState<string | null>(null);
99
+ const [selected, setSelected] = useState<MailMessage | null>(null);
100
+ const [selectedLoading, setSelectedLoading] = useState(false);
101
+
102
+ // ── Compose ────────────────────────────────────────────────────────
103
+ const [to, setTo] = useState("");
104
+ const [cc, setCc] = useState("");
105
+ const [bcc, setBcc] = useState("");
106
+ const [subject, setSubject] = useState("");
107
+ const [bodyText, setBodyText] = useState("");
108
+ const [replyTo, setReplyTo] = useState("");
109
+ const [sending, setSending] = useState(false);
110
+ const [sendArmed, setSendArmed] = useState(false);
111
+ const [sendError, setSendError] = useState<string | null>(null);
112
+
113
+ const loadInbox = useCallback(() => {
114
+ if (!agentId) return;
115
+ setInboxLoading(true);
116
+ setInboxError(null);
117
+ api
118
+ .getMailAddress(agentId)
119
+ .then((resp) => {
120
+ if (resp.ok) {
121
+ setInbox(resp.inbox);
122
+ setHasInbox(resp.has_inbox);
123
+ } else {
124
+ setInbox(null);
125
+ setHasInbox(false);
126
+ setInboxError(resp.error ?? "Could not load inbox");
127
+ }
128
+ })
129
+ .catch((e) => setInboxError(e instanceof Error ? e.message : String(e)))
130
+ .finally(() => setInboxLoading(false));
131
+ }, [agentId]);
132
+
133
+ const loadMessages = useCallback(() => {
134
+ if (!agentId) return;
135
+ setMessagesLoading(true);
136
+ setMessagesError(null);
137
+ api
138
+ .listMail({ agentId, direction: filter === "all" ? undefined : filter, limit: 100 })
139
+ .then((resp) => {
140
+ if (resp.ok) {
141
+ setMessages(resp.messages ?? []);
142
+ } else {
143
+ setMessagesError(resp.error ?? "Could not load messages");
144
+ setMessages([]);
145
+ }
146
+ })
147
+ .catch((e) => setMessagesError(e instanceof Error ? e.message : String(e)))
148
+ .finally(() => setMessagesLoading(false));
149
+ }, [agentId, filter]);
150
+
151
+ // Load the agent list once; default to the first agent.
152
+ useEffect(() => {
153
+ api
154
+ .getWalletBalances()
155
+ .then((r) => {
156
+ if (r.ok && r.wallets.length) {
157
+ setAgents(r.wallets);
158
+ setAgentId((cur) => cur || r.wallets[0].agent_id);
159
+ }
160
+ })
161
+ .catch(() => {});
162
+ }, []);
163
+
164
+ useEffect(() => {
165
+ // eslint-disable-next-line react-hooks/set-state-in-effect
166
+ loadInbox();
167
+ }, [loadInbox]);
168
+
169
+ useEffect(() => {
170
+ // eslint-disable-next-line react-hooks/set-state-in-effect
171
+ if (hasInbox) loadMessages();
172
+ }, [hasInbox, loadMessages]);
173
+
174
+ const createInbox = useCallback(() => {
175
+ setCreating(true);
176
+ setCreateError(null);
177
+ api
178
+ .createInbox({ agent_id: agentId, username: username.trim() || undefined, confirm: true })
179
+ .then((resp) => {
180
+ if (resp.ok) {
181
+ setCreateArmed(false);
182
+ if (resp.inbox) {
183
+ setInbox(resp.inbox);
184
+ setHasInbox(true);
185
+ } else {
186
+ loadInbox();
187
+ }
188
+ } else {
189
+ setCreateError(resp.error ?? "Could not create inbox");
190
+ }
191
+ })
192
+ .catch((e) => setCreateError(e instanceof Error ? e.message : String(e)))
193
+ .finally(() => setCreating(false));
194
+ }, [agentId, username, loadInbox]);
195
+
196
+ const openMessage = useCallback(
197
+ (m: MailMessage) => {
198
+ setSelected(m);
199
+ setView("read");
200
+ setSelectedLoading(true);
201
+ api
202
+ .readMail(m.messageId, agentId)
203
+ .then((resp) => {
204
+ if (resp.ok && resp.message) setSelected(resp.message);
205
+ })
206
+ .catch(() => {})
207
+ .finally(() => setSelectedLoading(false));
208
+ },
209
+ [agentId],
210
+ );
211
+
212
+ const recipients = useMemo(() => parseRecipients(to), [to]);
213
+ const selectedWallet = useMemo(
214
+ () => agents.find((a) => a.agent_id === agentId) ?? null,
215
+ [agents, agentId],
216
+ );
217
+
218
+ const startCompose = useCallback(() => {
219
+ setSendError(null);
220
+ setSendArmed(false);
221
+ setView("compose");
222
+ }, []);
223
+
224
+ const sendMail = useCallback(() => {
225
+ const toList = parseRecipients(to);
226
+ const ccList = parseRecipients(cc);
227
+ const bccList = parseRecipients(bcc);
228
+ const bad = [...toList, ...ccList, ...bccList].find((a) => !EMAIL_RE.test(a));
229
+ if (toList.length === 0) {
230
+ setSendError("Add at least one recipient.");
231
+ setSendArmed(false);
232
+ return;
233
+ }
234
+ if (bad) {
235
+ setSendError(`Not a valid email: ${bad}`);
236
+ setSendArmed(false);
237
+ return;
238
+ }
239
+ if (!subject.trim()) {
240
+ setSendError("Add a subject.");
241
+ setSendArmed(false);
242
+ return;
243
+ }
244
+ if (!bodyText.trim()) {
245
+ setSendError("Write a message.");
246
+ setSendArmed(false);
247
+ return;
248
+ }
249
+ setSending(true);
250
+ setSendError(null);
251
+ api
252
+ .sendMail({
253
+ agent_id: agentId,
254
+ to: toList,
255
+ cc: ccList.length ? ccList : undefined,
256
+ bcc: bccList.length ? bccList : undefined,
257
+ subject: subject.trim(),
258
+ text: bodyText,
259
+ reply_to: replyTo.trim() || undefined,
260
+ confirm: true,
261
+ })
262
+ .then((resp) => {
263
+ if (resp.ok) {
264
+ setTo("");
265
+ setCc("");
266
+ setBcc("");
267
+ setSubject("");
268
+ setBodyText("");
269
+ setReplyTo("");
270
+ setSendArmed(false);
271
+ setView("list");
272
+ loadMessages();
273
+ } else {
274
+ setSendError(resp.error ?? "Send failed");
275
+ setSendArmed(false);
276
+ }
277
+ })
278
+ .catch((e) => {
279
+ setSendError(e instanceof Error ? e.message : String(e));
280
+ setSendArmed(false);
281
+ })
282
+ .finally(() => setSending(false));
283
+ }, [agentId, to, cc, bcc, subject, bodyText, replyTo, loadMessages]);
284
+
285
+ // ── Render ─────────────────────────────────────────────────────────
286
+ return (
287
+ <div className="mx-auto max-w-3xl space-y-4 p-4">
288
+ <div className="flex flex-wrap items-center justify-between gap-2">
289
+ <div className="flex items-center gap-2">
290
+ <Mail className="h-5 w-5 text-muted-foreground" />
291
+ <h1 className="text-lg font-semibold">Agent Mail</h1>
292
+ {agents.length > 0 && (
293
+ <select
294
+ value={agentId}
295
+ onChange={(e) => {
296
+ setAgentId(e.target.value);
297
+ setView("list");
298
+ setSelected(null);
299
+ }}
300
+ title="Select agent"
301
+ className="ml-1 max-w-[180px] rounded-md border border-border bg-background px-2 py-1 text-sm outline-none focus:border-primary"
302
+ >
303
+ {agents.map((a) => (
304
+ <option key={a.agent_id} value={a.agent_id}>
305
+ {a.name || a.agent_id.slice(0, 8)}
306
+ </option>
307
+ ))}
308
+ </select>
309
+ )}
310
+ </div>
311
+ {hasInbox && view === "list" && (
312
+ <div className="flex items-center gap-2">
313
+ <button
314
+ type="button"
315
+ onClick={loadMessages}
316
+ title="Refresh"
317
+ aria-label="Refresh"
318
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
319
+ >
320
+ <RefreshCw className={`h-4 w-4 ${messagesLoading ? "animate-spin" : ""}`} />
321
+ </button>
322
+ <Button size="sm" prefix={<PenSquare className="h-4 w-4" />} onClick={startCompose}>
323
+ Compose
324
+ </Button>
325
+ </div>
326
+ )}
327
+ </div>
328
+
329
+ {/* Inbox address bar */}
330
+ {hasInbox && inbox && (
331
+ <Card>
332
+ <CardContent className="flex flex-wrap items-center justify-between gap-2 py-3">
333
+ <div className="flex min-w-0 items-center gap-2">
334
+ <Inbox className="h-4 w-4 shrink-0 text-muted-foreground" />
335
+ <span className="truncate font-mono text-sm text-emerald-300">
336
+ {inbox.emailAddress}
337
+ </span>
338
+ <CopyButton value={inbox.emailAddress} />
339
+ </div>
340
+ <div className="flex items-center gap-1.5">
341
+ {inbox.verified && (
342
+ <Badge tone="success" className="shrink-0">
343
+ <ShieldCheck className="mr-1 h-3 w-3" /> verified
344
+ </Badge>
345
+ )}
346
+ {inbox.status && inbox.status !== "active" && (
347
+ <Badge tone="secondary">{inbox.status}</Badge>
348
+ )}
349
+ </div>
350
+ </CardContent>
351
+ </Card>
352
+ )}
353
+
354
+ {inboxError && (
355
+ <Card className="border-destructive/40">
356
+ <CardContent className="py-3 text-sm text-destructive">{inboxError}</CardContent>
357
+ </Card>
358
+ )}
359
+
360
+ {inboxLoading ? (
361
+ <div className="flex justify-center py-12">
362
+ <Spinner />
363
+ </div>
364
+ ) : !hasInbox ? (
365
+ /* ── Provision an inbox ─────────────────────────────────────── */
366
+ <Card>
367
+ <CardHeader className="pb-2">
368
+ <CardTitle className="text-sm font-semibold">No inbox yet</CardTitle>
369
+ </CardHeader>
370
+ <CardContent className="space-y-3">
371
+ <p className="text-sm text-muted-foreground">
372
+ Give this agent a real email address (e.g.{" "}
373
+ <span className="font-mono text-foreground">name@agentmail.to</span>) so it can
374
+ send and receive mail. Provisioning is a one-time{" "}
375
+ <span className="font-semibold text-foreground">~$2 USDC</span> payment from the
376
+ agent&apos;s own wallet over x402.
377
+ </p>
378
+ {selectedWallet && (
379
+ <div className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2">
380
+ <span className="text-sm text-muted-foreground">This agent&apos;s USDC balance</span>
381
+ <span
382
+ className={`font-mono text-sm font-semibold ${
383
+ (selectedWallet.usdc_balance ?? 0) >= 2 ? "text-emerald-300" : "text-amber-300"
384
+ }`}
385
+ >
386
+ {selectedWallet.usdc_balance != null
387
+ ? `$${selectedWallet.usdc_balance.toFixed(2)}`
388
+ : "—"}
389
+ </span>
390
+ </div>
391
+ )}
392
+ {selectedWallet && (selectedWallet.usdc_balance ?? 0) < 2 && (
393
+ <p className="text-xs text-amber-300">
394
+ Not enough USDC for the ~$2 fee — add USDC to this agent&apos;s wallet (or swap
395
+ SOL&nbsp;→&nbsp;USDC) before creating the inbox.
396
+ </p>
397
+ )}
398
+ <div className="flex flex-col gap-2 sm:flex-row">
399
+ <input
400
+ value={username}
401
+ onChange={(e) => setUsername(e.target.value)}
402
+ placeholder="optional username (a-z, 0-9, dot, dash) — omit to auto-generate"
403
+ className={inputCls + " sm:flex-1"}
404
+ />
405
+ </div>
406
+ {createError && <p className="text-sm text-destructive">{createError}</p>}
407
+ {createArmed ? (
408
+ <div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
409
+ <span className="text-sm text-amber-200">
410
+ This pays <span className="font-semibold">~$2 USDC</span> from the agent wallet.
411
+ Continue?
412
+ </span>
413
+ <div className="ml-auto flex gap-2">
414
+ <button
415
+ type="button"
416
+ onClick={() => setCreateArmed(false)}
417
+ className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
418
+ >
419
+ Cancel
420
+ </button>
421
+ <Button size="sm" onClick={createInbox} disabled={creating}>
422
+ {creating ? "Creating…" : "Confirm & pay"}
423
+ </Button>
424
+ </div>
425
+ </div>
426
+ ) : (
427
+ <Button onClick={() => setCreateArmed(true)} disabled={creating}>
428
+ Create inbox
429
+ </Button>
430
+ )}
431
+ </CardContent>
432
+ </Card>
433
+ ) : view === "compose" ? (
434
+ /* ── Compose ────────────────────────────────────────────────── */
435
+ <Card>
436
+ <CardHeader className="flex flex-row items-center justify-between gap-2 pb-2">
437
+ <CardTitle className="text-sm font-semibold">New email</CardTitle>
438
+ <button
439
+ type="button"
440
+ onClick={() => setView("list")}
441
+ className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
442
+ >
443
+ <ArrowLeft className="h-4 w-4" /> Back
444
+ </button>
445
+ </CardHeader>
446
+ <CardContent className="space-y-3">
447
+ <div className="space-y-1">
448
+ <label className="text-xs text-muted-foreground">To</label>
449
+ <input
450
+ value={to}
451
+ onChange={(e) => setTo(e.target.value)}
452
+ placeholder="alice@example.com, bob@example.com"
453
+ className={inputCls}
454
+ />
455
+ </div>
456
+ <div className="grid gap-3 sm:grid-cols-2">
457
+ <div className="space-y-1">
458
+ <label className="text-xs text-muted-foreground">Cc (optional)</label>
459
+ <input value={cc} onChange={(e) => setCc(e.target.value)} className={inputCls} />
460
+ </div>
461
+ <div className="space-y-1">
462
+ <label className="text-xs text-muted-foreground">Bcc (optional)</label>
463
+ <input value={bcc} onChange={(e) => setBcc(e.target.value)} className={inputCls} />
464
+ </div>
465
+ </div>
466
+ <div className="space-y-1">
467
+ <label className="text-xs text-muted-foreground">Subject</label>
468
+ <input
469
+ value={subject}
470
+ onChange={(e) => setSubject(e.target.value)}
471
+ className={inputCls}
472
+ />
473
+ </div>
474
+ <div className="space-y-1">
475
+ <label className="text-xs text-muted-foreground">Message</label>
476
+ <textarea
477
+ value={bodyText}
478
+ onChange={(e) => setBodyText(e.target.value)}
479
+ rows={10}
480
+ className={inputCls + " resize-y font-sans"}
481
+ />
482
+ </div>
483
+ <div className="space-y-1">
484
+ <label className="text-xs text-muted-foreground">Reply-To (optional)</label>
485
+ <input
486
+ value={replyTo}
487
+ onChange={(e) => setReplyTo(e.target.value)}
488
+ className={inputCls}
489
+ />
490
+ </div>
491
+
492
+ {sendError && <p className="text-sm text-destructive">{sendError}</p>}
493
+
494
+ {sendArmed ? (
495
+ <div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
496
+ <span className="text-sm text-amber-200">
497
+ Send a real email from{" "}
498
+ <span className="font-mono">{inbox?.emailAddress}</span> to{" "}
499
+ {recipients.length} recipient{recipients.length === 1 ? "" : "s"}? Any per-send
500
+ fee is paid in USDC from the agent wallet.
501
+ </span>
502
+ <div className="ml-auto flex gap-2">
503
+ <button
504
+ type="button"
505
+ onClick={() => setSendArmed(false)}
506
+ className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
507
+ >
508
+ Cancel
509
+ </button>
510
+ <Button
511
+ size="sm"
512
+ prefix={<Send className="h-4 w-4" />}
513
+ onClick={sendMail}
514
+ disabled={sending}
515
+ >
516
+ {sending ? "Sending…" : "Send now"}
517
+ </Button>
518
+ </div>
519
+ </div>
520
+ ) : (
521
+ <Button
522
+ prefix={<Send className="h-4 w-4" />}
523
+ onClick={() => {
524
+ setSendError(null);
525
+ setSendArmed(true);
526
+ }}
527
+ disabled={sending}
528
+ >
529
+ Send
530
+ </Button>
531
+ )}
532
+ </CardContent>
533
+ </Card>
534
+ ) : view === "read" && selected ? (
535
+ /* ── Read one message ───────────────────────────────────────── */
536
+ <Card>
537
+ <CardHeader className="space-y-2 pb-2">
538
+ <button
539
+ type="button"
540
+ onClick={() => setView("list")}
541
+ className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
542
+ >
543
+ <ArrowLeft className="h-4 w-4" /> Back to inbox
544
+ </button>
545
+ <CardTitle className="text-base font-semibold">
546
+ {selected.subject || "(no subject)"}
547
+ </CardTitle>
548
+ <div className="space-y-0.5 text-xs text-muted-foreground">
549
+ <div>
550
+ <Badge
551
+ tone={selected.direction === "inbound" ? "secondary" : "success"}
552
+ className="mr-2"
553
+ >
554
+ {selected.direction === "inbound" ? "received" : "sent"}
555
+ </Badge>
556
+ {formatDate(selected.agentmailCreatedAt || selected.createdAt)}
557
+ </div>
558
+ {selected.fromAddress && (
559
+ <div>
560
+ <span className="text-foreground/70">From:</span> {selected.fromAddress}
561
+ </div>
562
+ )}
563
+ {selected.toAddresses?.length > 0 && (
564
+ <div>
565
+ <span className="text-foreground/70">To:</span> {selected.toAddresses.join(", ")}
566
+ </div>
567
+ )}
568
+ {selected.ccAddresses?.length > 0 && (
569
+ <div>
570
+ <span className="text-foreground/70">Cc:</span> {selected.ccAddresses.join(", ")}
571
+ </div>
572
+ )}
573
+ </div>
574
+ </CardHeader>
575
+ <CardContent>
576
+ {selectedLoading ? (
577
+ <div className="flex justify-center py-8">
578
+ <Spinner />
579
+ </div>
580
+ ) : selected.textBody ? (
581
+ <pre className="whitespace-pre-wrap break-words font-sans text-sm text-foreground/90">
582
+ {selected.textBody}
583
+ </pre>
584
+ ) : selected.htmlBody ? (
585
+ <iframe
586
+ title="email body"
587
+ sandbox=""
588
+ srcDoc={selected.htmlBody}
589
+ className="h-[60vh] w-full rounded-md border border-border bg-white"
590
+ />
591
+ ) : (
592
+ <p className="text-sm text-muted-foreground">{selected.preview || "(empty message)"}</p>
593
+ )}
594
+ </CardContent>
595
+ </Card>
596
+ ) : (
597
+ /* ── Inbox list ─────────────────────────────────────────────── */
598
+ <div className="space-y-3">
599
+ <div className="flex gap-1">
600
+ {(["all", "inbound", "outbound"] as Filter[]).map((f) => (
601
+ <button
602
+ key={f}
603
+ type="button"
604
+ onClick={() => setFilter(f)}
605
+ className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
606
+ filter === f
607
+ ? "bg-muted font-medium text-foreground"
608
+ : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
609
+ }`}
610
+ >
611
+ {f === "all" ? "All" : f === "inbound" ? "Inbox" : "Sent"}
612
+ </button>
613
+ ))}
614
+ </div>
615
+
616
+ {messagesError && (
617
+ <Card className="border-destructive/40">
618
+ <CardContent className="py-3 text-sm text-destructive">{messagesError}</CardContent>
619
+ </Card>
620
+ )}
621
+
622
+ {messagesLoading ? (
623
+ <div className="flex justify-center py-12">
624
+ <Spinner />
625
+ </div>
626
+ ) : messages.length === 0 ? (
627
+ <Card>
628
+ <CardContent className="py-8 text-center text-sm text-muted-foreground">
629
+ No messages yet.
630
+ </CardContent>
631
+ </Card>
632
+ ) : (
633
+ <div className="space-y-2">
634
+ {messages.map((m) => {
635
+ const who =
636
+ m.direction === "inbound"
637
+ ? m.fromAddress || "unknown sender"
638
+ : `To: ${m.toAddresses?.join(", ") || "—"}`;
639
+ return (
640
+ <button
641
+ key={m.id}
642
+ type="button"
643
+ onClick={() => openMessage(m)}
644
+ className="w-full rounded-lg border border-border bg-background p-3 text-left transition-colors hover:border-primary/50 hover:bg-muted/30"
645
+ >
646
+ <div className="flex items-center justify-between gap-2">
647
+ <div className="flex min-w-0 items-center gap-2">
648
+ {m.direction === "inbound" ? (
649
+ <Inbox className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
650
+ ) : (
651
+ <Send className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
652
+ )}
653
+ <span
654
+ className={`truncate text-sm ${
655
+ m.direction === "inbound" && !m.read
656
+ ? "font-semibold text-foreground"
657
+ : "text-foreground/80"
658
+ }`}
659
+ >
660
+ {who}
661
+ </span>
662
+ </div>
663
+ <span className="shrink-0 text-xs text-muted-foreground">
664
+ {formatDate(m.agentmailCreatedAt || m.createdAt)}
665
+ </span>
666
+ </div>
667
+ <div className="mt-1 truncate text-sm text-foreground/90">
668
+ {m.subject || "(no subject)"}
669
+ </div>
670
+ {m.preview && (
671
+ <div className="mt-0.5 truncate text-xs text-muted-foreground">
672
+ {m.preview}
673
+ </div>
674
+ )}
675
+ </button>
676
+ );
677
+ })}
678
+ </div>
679
+ )}
680
+ </div>
681
+ )}
682
+ </div>
683
+ );
684
+ }
@@ -1,8 +1,10 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
- import { Check, Copy, RefreshCw, Send, Wallet, X } from "lucide-react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { Check, Coins, Copy, RefreshCw, Send, Wallet, X } from "lucide-react";
3
4
  import { api } from "@/lib/api";
4
5
  import type { AgentWalletBalance } from "@/lib/api";
5
6
  import { Button } from "@nous-research/ui/ui/components/button";
7
+ import { Badge } from "@nous-research/ui/ui/components/badge";
6
8
  import { Spinner } from "@nous-research/ui/ui/components/spinner";
7
9
  import {
8
10
  Card,
@@ -11,6 +13,23 @@ import {
11
13
  CardTitle,
12
14
  } from "@nous-research/ui/ui/components/card";
13
15
 
16
+ function AgentAvatar({ url }: { url?: string | null }) {
17
+ // ``url`` is a signed avatar URL when the agent has one, else null → fall
18
+ // back to the ClawPump claw so every card shows a real logo.
19
+ return (
20
+ <img
21
+ src={url || "/claw-logo.png"}
22
+ alt=""
23
+ className="h-7 w-7 shrink-0 rounded-full border border-border bg-background object-cover"
24
+ />
25
+ );
26
+ }
27
+
28
+ function tokenizePrompt(w: AgentWalletBalance): string {
29
+ const name = w.name || w.agent_id;
30
+ return `Launch a ClawPump token for my agent "${name}" (agent_id ${w.agent_id}). Ask me for the ticker/symbol and any details you need, then launch it.`;
31
+ }
32
+
14
33
  /* ── Token logos (inline SVG, no network) ─────────────────────────────── */
15
34
 
16
35
  function SolLogo({ className = "h-5 w-5" }: { className?: string }) {
@@ -277,6 +296,7 @@ export default function WalletPage() {
277
296
  const [loading, setLoading] = useState(true);
278
297
  const [error, setError] = useState<string | null>(null);
279
298
  const [transfer, setTransfer] = useState<AgentWalletBalance | null>(null);
299
+ const navigate = useNavigate();
280
300
 
281
301
  const load = useCallback(() => {
282
302
  setLoading(true);
@@ -296,6 +316,7 @@ export default function WalletPage() {
296
316
  }, []);
297
317
 
298
318
  useEffect(() => {
319
+ // eslint-disable-next-line react-hooks/set-state-in-effect
299
320
  load();
300
321
  }, [load]);
301
322
 
@@ -334,13 +355,27 @@ export default function WalletPage() {
334
355
  </CardContent>
335
356
  </Card>
336
357
  ) : (
337
- <div className="space-y-3">
358
+ <div className="grid gap-3 sm:grid-cols-2">
338
359
  {wallets.map((w) => (
339
360
  <Card key={w.agent_id}>
340
361
  <CardHeader className="flex flex-row items-center justify-between gap-2 pb-2">
341
- <CardTitle className="truncate text-sm font-semibold">
342
- {w.name || shortAddress(w.agent_id)}
343
- </CardTitle>
362
+ <div className="flex min-w-0 items-center gap-2">
363
+ <AgentAvatar url={w.avatar_url} />
364
+ <CardTitle className="truncate text-sm font-semibold">
365
+ {w.name || shortAddress(w.agent_id)}
366
+ </CardTitle>
367
+ {w.token_mint && (
368
+ <a
369
+ href={`https://solscan.io/token/${w.token_mint}`}
370
+ target="_blank"
371
+ rel="noreferrer"
372
+ title={w.token_mint}
373
+ className="shrink-0"
374
+ >
375
+ <Badge tone="success">tokenized</Badge>
376
+ </a>
377
+ )}
378
+ </div>
344
379
  {w.wallet_address && (
345
380
  <Button
346
381
  outlined
@@ -383,6 +418,19 @@ export default function WalletPage() {
383
418
  </div>
384
419
  </div>
385
420
  </div>
421
+ {!w.token_mint && (
422
+ <Button
423
+ outlined
424
+ size="sm"
425
+ className="w-full"
426
+ onClick={() =>
427
+ navigate(`/chat?prompt=${encodeURIComponent(tokenizePrompt(w))}`)
428
+ }
429
+ prefix={<Coins className="h-4 w-4" />}
430
+ >
431
+ Tokenize this agent
432
+ </Button>
433
+ )}
386
434
  </CardContent>
387
435
  </Card>
388
436
  ))}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawpump/claw-agent",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },