The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit d24aacea3d6aCommit detailsView latest

apps/dashboard/src/main.tsx 2500 lines · 84.2 KB · tsx
1
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
import { createRoot } from 'react-dom/client';
3
import {
4
  applyPreviewRequest,
5
  createPreviewData,
6
  defaultPreviewSettings,
7
  disablePreview,
8
  enablePreview,
9
  isPreviewActive,
10
  previewSession,
11
} from './preview.js';
12
import './styles.css';
13
14
declare const __AUTH0_DOMAIN__: string;
15
declare const __AUTH0_CLIENT_ID__: string;
16
declare const __AUTH0_AUDIENCE__: string;
17
18
type Auth0User = { sub: string; name?: string; email?: string; picture?: string };
19
type AuthSession = { user: Auth0User };
20
type AuthTransaction = { state: string; nonce: string; verifier: string };
21
type Theme = 'dark' | 'light';
22
23
const authTransactionKey = 'openclasp.auth0.transaction';
24
const themeKey = 'openclasp.theme.v1';
25
26
type DashboardData = {
27
  agents: Record<string, any>[];
28
  projects: Record<string, any>[];
29
  installations: Record<string, any>[];
30
  setupRequests: Record<string, any>[];
31
  publications: Record<string, any>[];
32
  interactions: Record<string, any>[];
33
  federatedInteractions: Record<string, any>[];
34
  liveSessions: Record<string, any>[];
35
  hostedThreads: Record<string, any>[];
36
  events: Record<string, any>[];
37
  conflicts: Record<string, any>[];
38
  receipts: Record<string, any>[];
39
  profiles: Record<string, any>[];
40
  runtimes: Record<string, any>[];
41
  accessTokens: Record<string, any>[];
42
};
43
44
type Settings = {
45
  displayName: string;
46
  contributionEnabled: boolean;
47
  retentionDays: number;
48
  evidenceSharing: 'never' | 'ask' | 'contract_only';
49
  rawConversationsStored: false;
50
};
51
52
const emptyData: DashboardData = {
53
  agents: [],
54
  projects: [],
55
  installations: [],
56
  setupRequests: [],
57
  publications: [],
58
  interactions: [],
59
  federatedInteractions: [],
60
  liveSessions: [],
61
  hostedThreads: [],
62
  events: [],
63
  conflicts: [],
64
  receipts: [],
65
  profiles: [],
66
  runtimes: [],
67
  accessTokens: [],
68
};
69
const defaultSettings: Settings = {
70
  displayName: '',
71
  contributionEnabled: false,
72
  retentionDays: 30,
73
  evidenceSharing: 'ask',
74
  rawConversationsStored: false,
75
};
76
const pages = [
77
  'dashboard',
78
  'conversations',
79
  'history',
80
  'agents',
81
  'insights',
82
  'connect',
83
  'settings',
84
] as const;
85
type Page = (typeof pages)[number];
86
const pageMeta: Record<Page, { label: string; title: string; lede: string; eyebrow: string }> = {
87
  dashboard: {
88
    label: 'Overview',
89
    title: 'Overview',
90
    lede: 'Agent readiness, signed outcomes, and anything waiting on you.',
91
    eyebrow: 'Network',
92
  },
93
  history: {
94
    label: 'History',
95
    title: 'History',
96
    lede: 'Structured events, receipts, and shared contracts — never raw messages.',
97
    eyebrow: 'Audit',
98
  },
99
  conversations: {
100
    label: 'Conversations',
101
    title: 'Temporary conversations',
102
    lede: 'Hosted history for temporary chat identities. Direct runtime messages never appear here.',
103
    eyebrow: 'Inbox',
104
  },
105
  agents: {
106
    label: 'Agents',
107
    title: 'Agents',
108
    lede: 'Identities, runtimes, and the automation each agent is allowed to do.',
109
    eyebrow: 'Registry',
110
  },
111
  insights: {
112
    label: 'Insights',
113
    title: 'Insights',
114
    lede: 'Task-specific reliability from signed outcomes. No universal score.',
115
    eyebrow: 'Context',
116
  },
117
  connect: {
118
    label: 'Connect',
119
    title: 'Connect',
120
    lede: 'Add an agent once. Approve its identity, then OpenClasp handles the routine work.',
121
    eyebrow: 'Setup',
122
  },
123
  settings: {
124
    label: 'Settings',
125
    title: 'Settings',
126
    lede: 'Privacy, retention, and what this account may contribute to the network.',
127
    eyebrow: 'Account',
128
  },
129
};
130
131
function base64Url(bytes: Uint8Array) {
132
  let binary = '';
133
  for (const byte of bytes) binary += String.fromCharCode(byte);
134
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
135
}
136
137
function randomValue(size = 32) {
138
  return base64Url(crypto.getRandomValues(new Uint8Array(size)));
139
}
140
141
async function beginAuth(provider: 'google' | 'github') {
142
  const verifier = randomValue(48);
143
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
144
  const transaction: AuthTransaction = { state: randomValue(), nonce: randomValue(), verifier };
145
  sessionStorage.setItem(authTransactionKey, JSON.stringify(transaction));
146
  const parameters = new URLSearchParams({
147
    client_id: __AUTH0_CLIENT_ID__,
148
    redirect_uri: `${location.origin}/sso-callback`,
149
    response_type: 'code',
150
    scope: 'openid profile email',
151
    audience: __AUTH0_AUDIENCE__,
152
    state: transaction.state,
153
    nonce: transaction.nonce,
154
    code_challenge: base64Url(new Uint8Array(digest)),
155
    code_challenge_method: 'S256',
156
    connection: provider === 'google' ? 'google-oauth2' : 'github',
157
  });
158
  location.assign(`https://${__AUTH0_DOMAIN__}/authorize?${parameters}`);
159
}
160
161
async function signOut(preview: boolean) {
162
  if (preview) {
163
    disablePreview();
164
    location.replace('/login');
165
    return;
166
  }
167
  await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' });
168
  const parameters = new URLSearchParams({
169
    client_id: __AUTH0_CLIENT_ID__,
170
    returnTo: `${location.origin}/login`,
171
  });
172
  location.assign(`https://${__AUTH0_DOMAIN__}/v2/logout?${parameters}`);
173
}
174
175
async function remoteApi(path: string, init?: RequestInit) {
176
  const headers = new Headers(init?.headers);
177
  if (init?.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
178
  return fetch(path, {
179
    ...init,
180
    credentials: 'same-origin',
181
    headers,
182
  }).then(async (response) => {
183
    if (!response.ok)
184
      throw new Error((await response.text()) || `Request failed: ${response.status}`);
185
    return response.json();
186
  });
187
}
188
189
function route(): Page {
190
  const value = location.pathname.slice(1) || 'dashboard';
191
  return pages.includes(value as Page) ? (value as Page) : 'dashboard';
192
}
193
194
function initialTheme(): Theme {
195
  const saved = localStorage.getItem(themeKey);
196
  if (saved === 'dark' || saved === 'light') return saved;
197
  return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
198
}
199
200
function App() {
201
  const [session, setSession] = useState<AuthSession | null>();
202
  const [preview, setPreview] = useState(false);
203
  const [theme, setTheme] = useState<Theme>(initialTheme);
204
  const [page, setPage] = useState<Page>(route());
205
  const [data, setData] = useState<DashboardData>(emptyData);
206
  const [settings, setSettings] = useState<Settings>(defaultSettings);
207
  const [loading, setLoading] = useState(true);
208
  const [error, setError] = useState('');
209
  const api = useCallback(
210
    async (path: string, init?: RequestInit) => {
211
      if (preview) {
212
        const next = applyPreviewRequest(data, settings, path, init);
213
        setData(next.data);
214
        setSettings(next.settings);
215
        return next.result;
216
      }
217
      return remoteApi(path, init);
218
    },
219
    [data, preview, settings],
220
  );
221
  const refreshDashboard = useCallback(async () => {
222
    if (preview) return;
223
    setData((await remoteApi('/v0.1/dashboard')) as DashboardData);
224
  }, [preview]);
225
226
  useEffect(() => {
227
    document.title = session
228
      ? `${pageMeta[page].label} · OpenClasp`
229
      : session === null
230
        ? 'Sign in · OpenClasp'
231
        : 'OpenClasp';
232
  }, [page, session]);
233
234
  useEffect(() => {
235
    let cancelled = false;
236
    const boot = async () => {
237
      if (import.meta.env.DEV && isPreviewActive()) {
238
        if (cancelled) return;
239
        setPreview(true);
240
        setSession(previewSession);
241
        setData(createPreviewData());
242
        setSettings(defaultPreviewSettings);
243
        setLoading(false);
244
        return;
245
      }
246
      try {
247
        const response = await fetch('/api/session', { credentials: 'same-origin' });
248
        if (!response.ok) {
249
          if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
250
          return;
251
        }
252
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) {
253
          setSession((await response.json()) as AuthSession);
254
        }
255
      } catch {
256
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
257
      }
258
    };
259
    void boot();
260
    return () => {
261
      cancelled = true;
262
    };
263
  }, []);
264
265
  useEffect(() => {
266
    document.documentElement.dataset.theme = theme;
267
    localStorage.setItem(themeKey, theme);
268
    const meta = document.querySelector('meta[name="theme-color"]');
269
    if (meta) meta.setAttribute('content', theme === 'light' ? '#f4f6f1' : '#0b0d0b');
270
  }, [theme]);
271
272
  useEffect(() => {
273
    const onPopState = () => setPage(route());
274
    addEventListener('popstate', onPopState);
275
    return () => removeEventListener('popstate', onPopState);
276
  }, []);
277
278
  useEffect(() => {
279
    if (session === undefined) return;
280
    if (!session && location.pathname !== '/login') history.replaceState({}, '', '/login');
281
    if (!session) return;
282
    if (location.pathname === '/login') history.replaceState({}, '', '/dashboard');
283
    setPage(route());
284
    if (preview) {
285
      setLoading(false);
286
      return;
287
    }
288
    Promise.all([remoteApi('/v0.1/dashboard'), remoteApi('/v0.1/settings')])
289
      .then(([dashboard, accountSettings]) => {
290
        setData(dashboard as DashboardData);
291
        setSettings(accountSettings as Settings);
292
      })
293
      .catch((reason: unknown) =>
294
        setError(reason instanceof Error ? reason.message : 'Load failed'),
295
      )
296
      .finally(() => setLoading(false));
297
  }, [preview, session]);
298
299
  useEffect(() => {
300
    if (!session || preview) return;
301
    const refresh = () => {
302
      if (document.visibilityState === 'visible') void refreshDashboard().catch(() => undefined);
303
    };
304
    const timer = window.setInterval(refresh, 10_000);
305
    document.addEventListener('visibilitychange', refresh);
306
    return () => {
307
      window.clearInterval(timer);
308
      document.removeEventListener('visibilitychange', refresh);
309
    };
310
  }, [preview, refreshDashboard, session]);
311
312
  const navigate = (next: Page) => {
313
    history.pushState({}, '', `/${next}`);
314
    setPage(next);
315
  };
316
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
317
  const pendingInvites = data.federatedInteractions.filter(
318
    (interaction) => interaction.status === 'pending',
319
  ).length;
320
  const unreadThreads = data.hostedThreads.reduce(
321
    (total, thread) => total + Number(thread.unreadCount ?? 0),
322
    0,
323
  );
324
325
  if (session === undefined) return <Loading />;
326
  if (!session)
327
    return import.meta.env.DEV ? (
328
      <Login
329
        onPreview={() => {
330
          enablePreview();
331
          setPreview(true);
332
          setSession(previewSession);
333
          setData(createPreviewData());
334
          setSettings(defaultPreviewSettings);
335
          setLoading(false);
336
          history.replaceState({}, '', '/dashboard');
337
          setPage('dashboard');
338
        }}
339
      />
340
    ) : (
341
      <Login />
342
    );
343
344
  return (
345
    <div className="appShell">
346
      <a className="skipLink" href="#main">
347
        Skip to content
348
      </a>
349
      <aside>
350
        <div className="brand">
351
          <div className="mark">OC</div>
352
          <div>
353
            <strong>OpenClasp</strong>
354
            <small>ASSURANCE NETWORK</small>
355
          </div>
356
        </div>
357
        <nav aria-label="Dashboard">
358
          <Nav
359
            page="dashboard"
360
            active={page}
361
            onClick={navigate}
362
            label="Overview"
363
            icon="home"
364
            badge={pendingInvites || pendingSetup ? pendingInvites + pendingSetup : 0}
365
          />
366
          <Nav page="history" active={page} onClick={navigate} label="History" icon="history" />
367
          <Nav
368
            page="conversations"
369
            active={page}
370
            onClick={navigate}
371
            label="Conversations"
372
            icon="connect"
373
            badge={unreadThreads}
374
          />
375
          <Nav page="agents" active={page} onClick={navigate} label="Agents" icon="agents" />
376
          <Nav page="insights" active={page} onClick={navigate} label="Insights" icon="insights" />
377
          <Nav
378
            page="connect"
379
            active={page}
380
            onClick={navigate}
381
            label="Connect"
382
            icon="connect"
383
            badge={pendingSetup}
384
          />
385
          <Nav page="settings" active={page} onClick={navigate} label="Settings" icon="settings" />
386
        </nav>
387
        <button
388
          className="themeSwitch"
389
          type="button"
390
          onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
391
          aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
392
        >
393
          <Icon name={theme === 'dark' ? 'sun' : 'moon'} />
394
          {theme === 'dark' ? 'Light theme' : 'Dark theme'}
395
        </button>
396
        <div className="privacyStamp">
397
          <span className="liveDot" />
398
          <div>
399
            <strong>Privacy separated</strong>
400
            <small>Direct messages never stored</small>
401
          </div>
402
        </div>
403
        <button
404
          className="account"
405
          type="button"
406
          onClick={() => void signOut(preview)}
407
          title="Sign out"
408
        >
409
          <span>{initials(session.user.name || session.user.email || 'OC')}</span>
410
          <div>
411
            <strong>{session.user.name || 'OpenClasp user'}</strong>
412
            <small>{session.user.email || 'Signed in'}</small>
413
          </div>
414
          <b>Sign out</b>
415
        </button>
416
      </aside>
417
      <div>
418
        <div className="mobileBar">
419
          <div className="brand">
420
            <div className="mark">OC</div>
421
            <div>
422
              <strong>OpenClasp</strong>
423
              <small>ASSURANCE</small>
424
            </div>
425
          </div>
426
          <div className="mobileActions">
427
            <button
428
              className="iconButton"
429
              type="button"
430
              aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
431
              onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
432
            >
433
              <Icon name={theme === 'dark' ? 'sun' : 'moon'} />
434
            </button>
435
            <button
436
              className="iconButton"
437
              type="button"
438
              aria-label="Sign out"
439
              onClick={() => void signOut(preview)}
440
            >
441
              <span>{initials(session.user.name || session.user.email || 'OC')}</span>
442
            </button>
443
          </div>
444
        </div>
445
        <main id="main">
446
          {preview && (
447
            <div className="previewBanner">Local preview with sample data. Changes stay here.</div>
448
          )}
449
          {error && (
450
            <div className="errorBar" role="alert">
451
              {error}
452
            </div>
453
          )}
454
          {loading ? (
455
            <Loading compact />
456
          ) : (
457
            <PageContent
458
              page={page}
459
              data={data}
460
              settings={settings}
461
              setSettings={setSettings}
462
              navigate={navigate}
463
              refreshDashboard={refreshDashboard}
464
              api={api}
465
            />
466
          )}
467
        </main>
468
      </div>
469
    </div>
470
  );
471
}
472
473
function Login({ onPreview }: { onPreview?: () => void }) {
474
  const [error, setError] = useState('');
475
  const continueWith = async (provider: 'google' | 'github') => {
476
    setError('');
477
    try {
478
      await beginAuth(provider);
479
    } catch {
480
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
481
    }
482
  };
483
  return (
484
    <div className="loginPage">
485
      <section className="loginPitch">
486
        <div className="brand">
487
          <div className="mark">OC</div>
488
          <strong>OpenClasp</strong>
489
        </div>
490
        <div>
491
          <p className="eyebrow">AGENT-TO-AGENT ASSURANCE</p>
492
          <h1>Know who your agent is dealing with.</h1>
493
          <p>
494
            Verified identities, signed outcomes, private warnings, and contextual
495
            reliability—without using message bodies for scoring.
496
          </p>
497
        </div>
498
        <div className="loginProof">
499
          <span>01</span> User-owned history
500
          <span>02</span> No universal trust score
501
          <span>03</span> Explicit network consent
502
        </div>
503
      </section>
504
      <section className="loginCard">
505
        <div>
506
          <p className="eyebrow">SECURE ACCESS</p>
507
          <h2>Sign in to OpenClasp</h2>
508
          <p>
509
            Manage connected agents, review signed history, and control what contributes to the
510
            network.
511
          </p>
512
        </div>
513
        <div className="socialButtons">
514
          <button type="button" onClick={() => void continueWith('google')}>
515
            <GoogleMark /> Continue with Google
516
          </button>
517
          <button type="button" onClick={() => void continueWith('github')}>
518
            <GitHubMark /> Continue with GitHub
519
          </button>
520
          {onPreview && (
521
            <button type="button" onClick={() => void onPreview()}>
522
              Continue with local preview
523
            </button>
524
          )}
525
        </div>
526
        {error && (
527
          <div className="loginError" role="alert">
528
            {error}
529
          </div>
530
        )}
531
        <small>
532
          Google and GitHub handle authentication. OpenClasp never receives your password.
533
        </small>
534
      </section>
535
    </div>
536
  );
537
}
538
539
let callbackStarted = false;
540
function AuthCallback() {
541
  const [error, setError] = useState('');
542
  useEffect(() => {
543
    if (callbackStarted) return;
544
    callbackStarted = true;
545
    const finish = async () => {
546
      const query = new URLSearchParams(location.search);
547
      const oauthError = query.get('error_description') ?? query.get('error');
548
      if (oauthError) throw new Error(oauthError);
549
      const code = query.get('code');
550
      const returnedState = query.get('state');
551
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
552
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
553
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
554
      sessionStorage.removeItem(authTransactionKey);
555
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
556
      const response = await fetch('/api/session', {
557
        method: 'POST',
558
        credentials: 'same-origin',
559
        headers: { 'content-type': 'application/json' },
560
        body: JSON.stringify({
561
          code,
562
          codeVerifier: transaction.verifier,
563
        }),
564
      });
565
      if (!response.ok) throw new Error('Auth0 token exchange failed');
566
      location.replace('/dashboard');
567
    };
568
    void finish().catch((reason: unknown) =>
569
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
570
    );
571
  }, []);
572
  if (error)
573
    return (
574
      <div className="loading">
575
        <span className="mark">OC</span>
576
        <p>{error}</p>
577
        <a href="/login">Return to login</a>
578
      </div>
579
    );
580
  return <Loading />;
581
}
582
583
function PageContent({
584
  page,
585
  data,
586
  settings,
587
  setSettings,
588
  navigate,
589
  refreshDashboard,
590
  api,
591
}: {
592
  page: Page;
593
  data: DashboardData;
594
  settings: Settings;
595
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
596
  navigate: (page: Page) => void;
597
  refreshDashboard: () => Promise<void>;
598
  api: (path: string, init?: RequestInit) => Promise<unknown>;
599
}) {
600
  if (page === 'history') return <History data={data} />;
601
  if (page === 'conversations')
602
    return <Conversations data={data} refreshDashboard={refreshDashboard} api={api} />;
603
  if (page === 'agents')
604
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
605
  if (page === 'insights') return <Insights data={data} />;
606
  if (page === 'connect')
607
    return (
608
      <Connect data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />
609
    );
610
  if (page === 'settings')
611
    return <SettingsPage settings={settings} setSettings={setSettings} api={api} />;
612
  return <Overview data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
613
}
614
615
function Overview({
616
  data,
617
  navigate,
618
  refreshDashboard,
619
  api,
620
}: {
621
  data: DashboardData;
622
  navigate: (page: Page) => void;
623
  refreshDashboard: () => Promise<void>;
624
  api: (path: string, init?: RequestInit) => Promise<unknown>;
625
}) {
626
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
627
  const warnings = data.events.filter((item) =>
628
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
629
  ).length;
630
  const publishedIds = new Set(
631
    data.publications
632
      .filter((publication) => publication.published)
633
      .map((publication) => String(publication.agentId)),
634
  );
635
  const runtimeIds = new Set(
636
    data.runtimes
637
      .filter((runtime) => runtime.status === 'verified')
638
      .map((runtime) => String(runtime.agentId)),
639
  );
640
  const modeOf = (agent: Record<string, any>) =>
641
    agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
642
  const readyAgents = data.agents.filter(
643
    (agent) =>
644
      publishedIds.has(String(agent.agentId)) &&
645
      (modeOf(agent) === 'temporary_chat' || runtimeIds.has(String(agent.agentId))),
646
  ).length;
647
  const activeTemporaryChats = data.agents.filter(
648
    (agent) => modeOf(agent) === 'temporary_chat' && agent.presence?.status === 'online',
649
  ).length;
650
  const pendingInvitations = data.federatedInteractions.filter(
651
    (interaction) => interaction.status === 'pending',
652
  ).length;
653
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
654
  const reviewCount = warnings + pendingInvitations;
655
  return (
656
    <>
657
      <PageHead page="dashboard" action="Connect agent" onAction={() => navigate('connect')} />
658
      {pendingSetup > 0 && (
659
        <button className="attentionBanner" type="button" onClick={() => navigate('connect')}>
660
          <div>
661
            <span className="statusOrb">{pendingSetup}</span>
662
            <div>
663
              <strong>
664
                {pendingSetup} setup request{pendingSetup === 1 ? '' : 's'} waiting
665
              </strong>
666
              <small>Approve the proposed identity before the agent can be bound.</small>
667
            </div>
668
          </div>
669
        </button>
670
      )}
671
      <section
672
        className={`readiness ${readyAgents === data.agents.length && readyAgents ? 'ready' : ''}`}
673
      >
674
        <div>
675
          <span className="statusOrb">{readyAgents ? '✓' : '!'}</span>
676
          <div>
677
            <strong>
678
              {readyAgents === data.agents.length && readyAgents
679
                ? 'Your agent network is ready'
680
                : `${readyAgents} of ${data.agents.length} agents can receive A2A work`}
681
            </strong>
682
            <small>
683
              Safe matching requests activate automatically. Everything else waits for approval.
684
            </small>
685
          </div>
686
        </div>
687
        <button className="secondary" type="button" onClick={() => navigate('agents')}>
688
          Manage automation
689
        </button>
690
      </section>
691
      <section className="metrics">
692
        <Metric
693
          label="Active temporary chats"
694
          value={activeTemporaryChats}
695
          note={`${runtimeIds.size} persistent runtimes connected`}
696
        />
697
        <Metric
698
          label="Interactions"
699
          value={data.interactions.length + data.federatedInteractions.length}
700
          note="local and shared"
701
        />
702
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
703
        <Metric
704
          label="Needs review"
705
          value={reviewCount}
706
          note="warnings or approvals"
707
          warn={reviewCount > 0}
708
        />
709
      </section>
710
      <Invitations data={data} refreshDashboard={refreshDashboard} api={api} />
711
      <section className="contentGrid">
712
        <Panel title="Recent activity" subtitle="Structured events and signed outcomes">
713
          <Timeline events={data.events.slice(-6).reverse()} />
714
          <TextButton onClick={() => navigate('history')}>View complete history</TextButton>
715
        </Panel>
716
        <Panel
717
          title="Reliability context"
718
          subtitle="Task-specific profiles, never a universal score"
719
        >
720
          {data.profiles.length ? (
721
            data.profiles
722
              .slice(0, 4)
723
              .map((profile) => (
724
                <Profile key={`${profile.agentId}-${profile.taskCategory}`} profile={profile} />
725
              ))
726
          ) : (
727
            <Empty
728
              title="No reliability profile yet"
729
              text="Profiles appear after signed receipts and eligible bilateral feedback."
730
            />
731
          )}
732
        </Panel>
733
      </section>
734
    </>
735
  );
736
}
737
738
type HistoryFilter = 'all' | 'interaction' | 'event' | 'receipt' | 'dispute';
739
740
function Conversations({
741
  data,
742
  refreshDashboard,
743
  api,
744
}: {
745
  data: DashboardData;
746
  refreshDashboard: () => Promise<void>;
747
  api: (path: string, init?: RequestInit) => Promise<unknown>;
748
}) {
749
  const [selected, setSelected] = useState<Record<string, any> | null>(null);
750
  const [selectedAgentId, setSelectedAgentId] = useState('');
751
  const [draft, setDraft] = useState('');
752
  const [working, setWorking] = useState(false);
753
  const [error, setError] = useState('');
754
  const temporaryAgentFor = (thread: Record<string, any>) =>
755
    data.agents.find(
756
      (agent) =>
757
        thread.participantAgentIds?.includes(agent.agentId) &&
758
        (agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat')) ===
759
          'temporary_chat',
760
    );
761
  const openThread = async (thread: Record<string, any>) => {
762
    const agent = temporaryAgentFor(thread);
763
    if (!agent) return;
764
    setWorking(true);
765
    setError('');
766
    try {
767
      const [detail] = (await Promise.all([
768
        api(
769
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}`,
770
        ),
771
        api(
772
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}/read`,
773
          { method: 'POST' },
774
        ),
775
      ])) as [Record<string, any>, unknown];
776
      setSelected(detail);
777
      setSelectedAgentId(String(agent.agentId));
778
      await refreshDashboard();
779
    } catch (reason) {
780
      setError(reason instanceof Error ? reason.message : 'Could not open conversation');
781
    } finally {
782
      setWorking(false);
783
    }
784
  };
785
  const reply = async () => {
786
    if (!selected || !draft.trim() || !selectedAgentId) return;
787
    setWorking(true);
788
    setError('');
789
    try {
790
      await api(`/v0.1/agents/${encodeURIComponent(selectedAgentId)}/messages`, {
791
        method: 'POST',
792
        body: JSON.stringify({
793
          interactionId: selected.thread.interactionId,
794
          content: draft.trim(),
795
        }),
796
      });
797
      setDraft('');
798
      await openThread(selected.thread);
799
    } catch (reason) {
800
      setError(reason instanceof Error ? reason.message : 'Could not send reply');
801
      setWorking(false);
802
    }
803
  };
804
  return (
805
    <>
806
      <PageHead page="conversations" />
807
      <div className="notice">
808
        <strong>Hosted temporary mode.</strong> OpenClasp processes these messages and encrypts them
809
        at rest for 30 days. Messages between persistent runtimes remain direct and are never shown
810
        here.
811
      </div>
812
      {error ? (
813
        <div className="errorBar" role="alert">
814
          {error}
815
        </div>
816
      ) : null}
817
      <section className="conversationLayout">
818
        <Panel title="Threads" subtitle="Temporary chat identities only">
819
          <div className="threadList">
820
            {data.hostedThreads.length ? (
821
              data.hostedThreads.map((thread) => {
822
                const agent = temporaryAgentFor(thread);
823
                const peer = thread.participantAgentIds?.find(
824
                  (value: string) => value !== agent?.agentId,
825
                );
826
                return (
827
                  <button
828
                    type="button"
829
                    key={thread.threadId}
830
                    className={selected?.thread?.threadId === thread.threadId ? 'active' : ''}
831
                    onClick={() => void openThread(thread)}
832
                    disabled={working}
833
                  >
834
                    <span>
835
                      <strong>{peer ?? 'Counterparty'}</strong>
836
                      <small>{agent?.name ?? agent?.agentId}</small>
837
                    </span>
838
                    <b>{thread.unreadCount ? `${thread.unreadCount} NEW` : thread.status}</b>
839
                  </button>
840
                );
841
              })
842
            ) : (
843
              <Empty
844
                title="No temporary conversations"
845
                text="Messages appear after a temporary chat identity accepts an interaction with a persistent runtime."
846
              />
847
            )}
848
          </div>
849
        </Panel>
850
        <Panel
851
          title={selected ? 'Conversation' : 'Select a thread'}
852
          subtitle="Private insights are attributable and task-specific"
853
        >
854
          {selected ? (
855
            <div className="conversationDetail">
856
              {!!selected.insights?.length && (
857
                <div className="conversationInsights">
858
                  {selected.insights.map((insight: Record<string, any>) => (
859
                    <p key={insight.code}>
860
                      <b>{String(insight.severity).toUpperCase()}</b> {insight.message}
861
                    </p>
862
                  ))}
863
                </div>
864
              )}
865
              <div className="messageList">
866
                {selected.messages.map((message: Record<string, any>) => (
867
                  <article
868
                    key={message.messageId}
869
                    className={message.senderAgentId === selectedAgentId ? 'sent' : 'received'}
870
                  >
871
                    <small>{message.senderAgentId === selectedAgentId ? 'You' : 'Peer'}</small>
872
                    <p>{message.content}</p>
873
                    <span>{new Date(message.createdAt).toLocaleString()}</span>
874
                  </article>
875
                ))}
876
              </div>
877
              <div className="replyBox">
878
                <textarea
879
                  aria-label="Temporary chat reply"
880
                  value={draft}
881
                  onChange={(event) => setDraft(event.target.value)}
882
                  maxLength={20_000}
883
                  placeholder="Reply through the temporary OpenClasp A2A adapter"
884
                />
885
                <button
886
                  className="primary"
887
                  type="button"
888
                  disabled={working || !draft.trim() || selected.thread.status !== 'open'}
889
                  onClick={() => void reply()}
890
                >
891
                  {working ? 'Sending…' : 'Send reply'}
892
                </button>
893
              </div>
894
            </div>
895
          ) : (
896
            <Empty title="Nothing selected" text="Choose a thread to read its hosted history." />
897
          )}
898
        </Panel>
899
      </section>
900
    </>
901
  );
902
}
903
904
function History({ data }: { data: DashboardData }) {
905
  const [query, setQuery] = useState('');
906
  const [filter, setFilter] = useState<HistoryFilter>('all');
907
  const records = useMemo(
908
    () =>
909
      [
910
        ...data.interactions.map((value) => ({ ...value, _kind: 'interaction' })),
911
        ...data.federatedInteractions.map((value) => ({
912
          ...value,
913
          _kind: 'federated interaction',
914
        })),
915
        ...data.events.map((value) => ({ ...value, _kind: 'event' })),
916
        ...data.receipts.map((value) => ({ ...value, _kind: 'receipt' })),
917
        ...data.conflicts.map((value) => ({ ...value, _kind: 'dispute' })),
918
      ].sort((a, b) => Date.parse(timestamp(b)) - Date.parse(timestamp(a))),
919
    [data],
920
  );
921
  const filtered = useMemo(() => {
922
    const needle = query.trim().toLowerCase();
923
    return records.filter((item) => {
924
      const kind = String(item._kind);
925
      if (filter === 'interaction' && !kind.includes('interaction')) return false;
926
      if (filter === 'event' && kind !== 'event') return false;
927
      if (filter === 'receipt' && kind !== 'receipt') return false;
928
      if (filter === 'dispute' && kind !== 'dispute') return false;
929
      if (!needle) return true;
930
      const record = item as Record<string, any>;
931
      const hay = [
932
        record.eventType,
933
        record._kind,
934
        record.agentId,
935
        record.interactionId,
936
        record.receiptId,
937
        record.outcome,
938
        record.status,
939
        record.visibility,
940
        record.contract?.purpose,
941
      ]
942
        .filter(Boolean)
943
        .join(' ')
944
        .toLowerCase();
945
      return hay.includes(needle);
946
    });
947
  }, [filter, query, records]);
948
  const count = (value: HistoryFilter) =>
949
    value === 'all'
950
      ? records.length
951
      : records.filter((item) =>
952
          value === 'interaction'
953
            ? String(item._kind).includes('interaction')
954
            : item._kind === value,
955
        ).length;
956
  return (
957
    <>
958
      <PageHead page="history" />
959
      <Panel
960
        title="Account history"
961
        subtitle="Only structured metadata, signatures, hashes, and permitted evidence are hosted"
962
      >
963
        <div className="toolbar">
964
          <input
965
            className="searchField"
966
            value={query}
967
            onChange={(event) => setQuery(event.target.value)}
968
            placeholder="Search agents, contracts, or statuses"
969
            aria-label="Search history"
970
          />
971
          <div className="filterRow" role="tablist" aria-label="History filters">
972
            {(
973
              [
974
                ['all', 'All'],
975
                ['interaction', 'Interactions'],
976
                ['event', 'Events'],
977
                ['receipt', 'Receipts'],
978
                ['dispute', 'Disputes'],
979
              ] as const
980
            ).map(([value, label]) => (
981
              <button
982
                key={value}
983
                className={filter === value ? 'filterChip active' : 'filterChip'}
984
                type="button"
985
                aria-pressed={filter === value}
986
                onClick={() => setFilter(value)}
987
              >
988
                {label} {count(value)}
989
              </button>
990
            ))}
991
          </div>
992
        </div>
993
        {filtered.length ? (
994
          filtered.map((item, index) => (
995
            <HistoryRow
996
              key={`${String(item._kind)}-${String(
997
                (item as Record<string, any>).eventId ??
998
                  (item as Record<string, any>).receiptId ??
999
                  (item as Record<string, any>).conflictId ??
1000
                  (item as Record<string, any>).interactionId ??
1001
                  index,
1002
              )}`}
1003
              item={item}
1004
            />
1005
          ))
1006
        ) : (
1007
          <Empty
1008
            title={records.length ? 'No matching records' : 'No interactions recorded'}
1009
            text={
1010
              records.length
1011
                ? 'Try another filter or clear the search.'
1012
                : 'Connect an agent and start an assured interaction. Raw message bodies will not appear here.'
1013
            }
1014
          />
1015
        )}
1016
      </Panel>
1017
    </>
1018
  );
1019
}
1020
1021
function Agents({
1022
  data,
1023
  navigate,
1024
  refreshDashboard,
1025
  api,
1026
}: {
1027
  data: DashboardData;
1028
  navigate: (page: Page) => void;
1029
  refreshDashboard: () => Promise<void>;
1030
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1031
}) {
1032
  const [working, setWorking] = useState('');
1033
  const [error, setError] = useState('');
1034
  const saveAutomation = async (
1035
    agentId: string,
1036
    value: {
1037
      autoPublish: boolean;
1038
      autoAcceptPolicy: 'off' | 'safe_matching';
1039
      autoAcceptTaskCategories: string[];
1040
    },
1041
  ) => {
1042
    setWorking(agentId);
1043
    setError('');
1044
    try {
1045
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/automation`, {
1046
        method: 'PUT',
1047
        body: JSON.stringify(value),
1048
      });
1049
      await refreshDashboard();
1050
      return true;
1051
    } catch (reason) {
1052
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
1053
      return false;
1054
    } finally {
1055
      setWorking('');
1056
    }
1057
  };
1058
  const saveRuntime = async (agentId: string, endpoint: string) => {
1059
    setWorking(`runtime:${agentId}`);
1060
    setError('');
1061
    try {
1062
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, {
1063
        method: 'PUT',
1064
        body: JSON.stringify({ endpoint }),
1065
      });
1066
      await refreshDashboard();
1067
    } catch (reason) {
1068
      setError(reason instanceof Error ? reason.message : 'Runtime verification failed');
1069
    } finally {
1070
      setWorking('');
1071
    }
1072
  };
1073
  const disableRuntime = async (agentId: string) => {
1074
    setWorking(`runtime:${agentId}`);
1075
    setError('');
1076
    try {
1077
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, { method: 'DELETE' });
1078
      await refreshDashboard();
1079
    } catch (reason) {
1080
      setError(reason instanceof Error ? reason.message : 'Runtime update failed');
1081
    } finally {
1082
      setWorking('');
1083
    }
1084
  };
1085
  const revokeAccessToken = async (agentId: string, tokenId: string) => {
1086
    setWorking(`token:${agentId}`);
1087
    setError('');
1088
    try {
1089
      await api(
1090
        `/v0.1/agents/${encodeURIComponent(agentId)}/access-tokens/${encodeURIComponent(tokenId)}`,
1091
        { method: 'DELETE' },
1092
      );
1093
      await refreshDashboard();
1094
    } catch (reason) {
1095
      setError(reason instanceof Error ? reason.message : 'Could not revoke agent access token');
1096
    } finally {
1097
      setWorking('');
1098
    }
1099
  };
1100
  const deleteAgent = async (agent: Record<string, any>) => {
1101
    const agentId = String(agent.agentId);
1102
    if (
1103
      !window.confirm(
1104
        `Delete “${String(agent.name ?? agentId)}”?\n\nIts runtime, publication, presence, MCP binding and hosted temporary messages will be removed. Signed interaction and receipt history will be retained.`,
1105
      )
1106
    )
1107
      return;
1108
    setWorking(`delete:${agentId}`);
1109
    setError('');
1110
    try {
1111
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' });
1112
      await refreshDashboard();
1113
    } catch (reason) {
1114
      setError(reason instanceof Error ? reason.message : 'Agent deletion failed');
1115
    } finally {
1116
      setWorking('');
1117
    }
1118
  };
1119
  return (
1120
    <>
1121
      <PageHead page="agents" action="Connect agent" onAction={() => navigate('connect')} />
1122
      <div className="notice">
1123
        <strong>Two explicit modes.</strong> Persistent runtimes talk directly over A2A. Temporary
1124
        chats use an OpenClasp-hosted endpoint with encrypted-at-rest history. There is no offline
1125
        relay for persistent runtimes.
1126
      </div>
1127
      {error && (
1128
        <div className="errorBar" role="alert">
1129
          {error}
1130
        </div>
1131
      )}
1132
      <section className="agentGrid">
1133
        {data.agents.length ? (
1134
          data.agents.map((agent) => (
1135
            <AgentCard
1136
              key={agent.agentId}
1137
              agent={agent}
1138
              projectName={
1139
                data.projects.find((project) => project.projectId === agent.projectId)?.name
1140
              }
1141
              published={data.publications.some(
1142
                (publication) => publication.agentId === agent.agentId && publication.published,
1143
              )}
1144
              working={working === agent.agentId}
1145
              onSave={(value) => saveAutomation(agent.agentId, value)}
1146
              runtime={data.runtimes.find((runtime) => runtime.agentId === agent.agentId)}
1147
              runtimeWorking={working === `runtime:${agent.agentId}`}
1148
              deleteWorking={working === `delete:${agent.agentId}`}
1149
              onRuntime={(endpoint) => saveRuntime(agent.agentId, endpoint)}
1150
              onDisableRuntime={() => disableRuntime(agent.agentId)}
1151
              accessTokens={data.accessTokens.filter(
1152
                (token) =>
1153
                  token.agentId === agent.agentId &&
1154
                  !token.revokedAt &&
1155
                  Date.parse(String(token.expiresAt)) > Date.now(),
1156
              )}
1157
              accessTokenWorking={working === `token:${agent.agentId}`}
1158
              onRevokeAccessToken={(tokenId) => revokeAccessToken(agent.agentId, tokenId)}
1159
              onDelete={() => deleteAgent(agent)}
1160
            />
1161
          ))
1162
        ) : (
1163
          <Empty
1164
            title="No agents connected"
1165
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
1166
            action="Open connection guide"
1167
            onAction={() => navigate('connect')}
1168
          />
1169
        )}
1170
      </section>
1171
    </>
1172
  );
1173
}
1174
1175
function Invitations({
1176
  data,
1177
  refreshDashboard,
1178
  api,
1179
}: {
1180
  data: DashboardData;
1181
  refreshDashboard: () => Promise<void>;
1182
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1183
}) {
1184
  const [working, setWorking] = useState('');
1185
  const [error, setError] = useState('');
1186
  const ownedAgentIds = useMemo(
1187
    () => new Set(data.agents.map((agent) => String(agent.agentId))),
1188
    [data.agents],
1189
  );
1190
  const incoming = data.federatedInteractions.filter(
1191
    (interaction) =>
1192
      interaction.status === 'pending' && ownedAgentIds.has(String(interaction.responderAgentId)),
1193
  );
1194
  const respond = async (interactionId: string, agentId: string, decision: 'accept' | 'reject') => {
1195
    setWorking(interactionId);
1196
    setError('');
1197
    try {
1198
      await api(`/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/respond`, {
1199
        method: 'POST',
1200
        body: JSON.stringify({ agentId, decision }),
1201
      });
1202
      await refreshDashboard();
1203
    } catch (reason) {
1204
      setError(reason instanceof Error ? reason.message : 'Could not respond to invitation');
1205
    } finally {
1206
      setWorking('');
1207
    }
1208
  };
1209
  if (!incoming.length && !data.federatedInteractions.length) return null;
1210
  return (
1211
    <Panel
1212
      title={incoming.length ? `Agent invitations (${incoming.length})` : 'Shared interactions'}
1213
      subtitle="Both accounts see the same immutable contract and acceptance state"
1214
    >
1215
      {error ? (
1216
        <div className="errorBar" role="alert">
1217
          {error}
1218
        </div>
1219
      ) : null}
1220
      {(incoming.length ? incoming : data.federatedInteractions.slice(0, 5)).map((interaction) => (
1221
        <article className="setupRequest" key={interaction.interactionId}>
1222
          <div>
1223
            <strong>{interaction.contract?.purpose ?? interaction.interactionId}</strong>
1224
            <small>
1225
              {interaction.initiatorAgentId} → {interaction.responderAgentId} · {interaction.status}
1226
            </small>
1227
            <small>Contract: {String(interaction.termsHash).slice(0, 16)}…</small>
1228
            {data.liveSessions.find(
1229
              (session) => session.interactionId === interaction.interactionId,
1230
            ) ? (
1231
              <small className="autoAccepted">
1232
                Live session:{' '}
1233
                {
1234
                  data.liveSessions.find(
1235
                    (session) => session.interactionId === interaction.interactionId,
1236
                  )?.status
1237
                }
1238
              </small>
1239
            ) : null}
1240
            {interaction.status === 'active' &&
1241
            interaction.acceptances?.[interaction.responderAgentId]?.method ===
1242
              'policy_auto_accept' ? (
1243
              <small className="autoAccepted">✓ Auto-approved by the responder's safe policy</small>
1244
            ) : null}
1245
          </div>
1246
          {interaction.status === 'pending' &&
1247
          ownedAgentIds.has(String(interaction.responderAgentId)) ? (
1248
            <div className="decisionButtons">
1249
              <button
1250
                className="secondary"
1251
                type="button"
1252
                disabled={working === interaction.interactionId}
1253
                onClick={() =>
1254
                  void respond(interaction.interactionId, interaction.responderAgentId, 'reject')
1255
                }
1256
              >
1257
                Reject
1258
              </button>
1259
              <button
1260
                className="primary"
1261
                type="button"
1262
                disabled={working === interaction.interactionId}
1263
                onClick={() =>
1264
                  void respond(interaction.interactionId, interaction.responderAgentId, 'accept')
1265
                }
1266
              >
1267
                {working === interaction.interactionId ? 'Working…' : 'Accept contract'}
1268
              </button>
1269
            </div>
1270
          ) : null}
1271
        </article>
1272
      ))}
1273
    </Panel>
1274
  );
1275
}
1276
1277
function Insights({ data }: { data: DashboardData }) {
1278
  return (
1279
    <>
1280
      <PageHead page="insights" />
1281
      <div className="notice">
1282
        <strong>Context matters.</strong> OpenClasp profiles an agent by task category and version.
1283
        It does not produce a single universal trust score.
1284
      </div>
1285
      <section className="agentGrid">
1286
        {data.profiles.length ? (
1287
          data.profiles.map((profile) => (
1288
            <Panel
1289
              key={`${profile.agentId}-${profile.taskCategory}`}
1290
              title={profile.agentId}
1291
              subtitle={`${profile.taskCategory} · v${profile.agentVersion}`}
1292
            >
1293
              <Profile profile={profile} expanded />
1294
            </Panel>
1295
          ))
1296
        ) : (
1297
          <Empty
1298
            title="Not enough verified evidence"
1299
            text="Insights grow only from signed outcomes, eligible feedback, corrections, disputes, and version-aware history."
1300
          />
1301
        )}
1302
      </section>
1303
    </>
1304
  );
1305
}
1306
1307
function Connect({
1308
  data,
1309
  navigate,
1310
  refreshDashboard,
1311
  api,
1312
}: {
1313
  data: DashboardData;
1314
  navigate: (page: Page) => void;
1315
  refreshDashboard: () => Promise<void>;
1316
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1317
}) {
1318
  const endpoint = 'https://openclasp.vercel.app/mcp';
1319
  const [connectionType, setConnectionType] = useState<'interactive' | 'hosted'>('hosted');
1320
  const [copied, setCopied] = useState(false);
1321
  const [tokenCopied, setTokenCopied] = useState(false);
1322
  const [working, setWorking] = useState('');
1323
  const [decisionError, setDecisionError] = useState('');
1324
  const [providerError, setProviderError] = useState('');
1325
  const [providerForm, setProviderForm] = useState({
1326
    provider: 'botpress' as 'botpress' | 'custom',
1327
    agentName: '',
1328
    projectName: '',
1329
    description: '',
1330
    capabilities: '',
1331
    limitations: '',
1332
    expiresInDays: 365,
1333
  });
1334
  const [providerResult, setProviderResult] = useState<{
1335
    provider: 'botpress' | 'custom';
1336
    agent: { agentId: string; name: string; framework: string };
1337
    accessToken: { token: string; expiresAt: string };
1338
  }>();
1339
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
1340
  useEffect(() => {
1341
    const poll = () => void refreshDashboard().catch(() => undefined);
1342
    poll();
1343
    const timer = window.setInterval(poll, 5000);
1344
    return () => window.clearInterval(timer);
1345
  }, [refreshDashboard]);
1346
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
1347
    setWorking(requestId);
1348
    setDecisionError('');
1349
    try {
1350
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
1351
        method: 'POST',
1352
      });
1353
      await refreshDashboard();
1354
    } catch (reason) {
1355
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
1356
    } finally {
1357
      setWorking('');
1358
    }
1359
  };
1360
  const connectHostedProvider = async (event: React.FormEvent) => {
1361
    event.preventDefault();
1362
    setWorking('hosted-provider');
1363
    setProviderError('');
1364
    try {
1365
      const list = (value: string) =>
1366
        value
1367
          .split(/[,\n]/)
1368
          .map((item) => item.trim())
1369
          .filter(Boolean);
1370
      const created = (await api('/v0.1/provider-connections', {
1371
        method: 'POST',
1372
        body: JSON.stringify({
1373
          provider: providerForm.provider,
1374
          agentName: providerForm.agentName,
1375
          projectName: providerForm.projectName,
1376
          description: providerForm.description,
1377
          capabilities: list(providerForm.capabilities),
1378
          limitations: list(providerForm.limitations),
1379
          expiresInDays: providerForm.expiresInDays,
1380
        }),
1381
      })) as {
1382
        provider: 'botpress' | 'custom';
1383
        agent: { agentId: string; name: string; framework: string };
1384
        accessToken: { token: string; expiresAt: string };
1385
      };
1386
      setProviderResult(created);
1387
      await refreshDashboard().catch(() => undefined);
1388
    } catch (reason) {
1389
      setProviderError(reason instanceof Error ? reason.message : 'Could not create connection');
1390
    } finally {
1391
      setWorking('');
1392
    }
1393
  };
1394
  return (
1395
    <>
1396
      <PageHead page="connect" />
1397
      {pending.length > 0 && (
1398
        <section className="setupRequests">
1399
          <div>
1400
            <p className="eyebrow">CONFIRMATION REQUIRED</p>
1401
            <h2>Approve agent setup</h2>
1402
            <p>
1403
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
1404
            </p>
1405
          </div>
1406
          {pending.map((request) => (
1407
            <article className="setupRequest" key={request.requestId}>
1408
              <div>
1409
                <strong>{request.agentName ?? 'Switch installation'}</strong>
1410
                <small>
1411
                  {request.projectName ?? request.existingAgentId} · {request.framework}
1412
                </small>
1413
                {!!request.capabilities?.length && (
1414
                  <div className="tags">
1415
                    {request.capabilities.slice(0, 5).map((capability: string) => (
1416
                      <span key={capability}>{capability}</span>
1417
                    ))}
1418
                  </div>
1419
                )}
1420
                {!!request.limitations?.length && (
1421
                  <small>Limitations: {request.limitations.join(' · ')}</small>
1422
                )}
1423
                <div className="automationPreview">
1424
                  <span>{request.autoPublish ? 'Public after approval' : 'Private'}</span>
1425
                  <span>
1426
                    {(request.agentMode ?? 'temporary_chat') === 'temporary_chat'
1427
                      ? 'Hosted temporary A2A · encrypted history'
1428
                      : 'Persistent runtime · direct A2A'}
1429
                  </span>
1430
                  <span>
1431
                    {request.autoAcceptPolicy === 'safe_matching'
1432
                      ? 'Auto-accept safe matches'
1433
                      : 'Review every request'}
1434
                  </span>
1435
                </div>
1436
              </div>
1437
              <div className="decisionButtons">
1438
                <button
1439
                  className="secondary"
1440
                  type="button"
1441
                  disabled={working === request.requestId}
1442
                  onClick={() => void decide(request.requestId, 'reject')}
1443
                >
1444
                  Reject
1445
                </button>
1446
                <button
1447
                  className="primary"
1448
                  type="button"
1449
                  disabled={working === request.requestId}
1450
                  onClick={() => void decide(request.requestId, 'approve')}
1451
                >
1452
                  {working === request.requestId ? 'Working…' : 'Approve & automate'}
1453
                </button>
1454
              </div>
1455
            </article>
1456
          ))}
1457
          {decisionError && (
1458
            <div className="loginError" role="alert">
1459
              {decisionError}
1460
            </div>
1461
          )}
1462
        </section>
1463
      )}
1464
      <div className="connectionTypePicker" role="tablist" aria-label="Agent connection type">
1465
        <button
1466
          type="button"
1467
          role="tab"
1468
          aria-selected={connectionType === 'hosted'}
1469
          className={connectionType === 'hosted' ? 'active' : ''}
1470
          onClick={() => setConnectionType('hosted')}
1471
        >
1472
          Hosted provider
1473
          <small>Botpress and similar platforms</small>
1474
        </button>
1475
        <button
1476
          type="button"
1477
          role="tab"
1478
          aria-selected={connectionType === 'interactive'}
1479
          className={connectionType === 'interactive' ? 'active' : ''}
1480
          onClick={() => setConnectionType('interactive')}
1481
        >
1482
          Interactive agent
1483
          <small>Codex, Cursor, and OAuth clients</small>
1484
        </button>
1485
      </div>
1486
      {connectionType === 'hosted' ? (
1487
        <section className="connectLayout providerConnectLayout">
1488
          <Panel title="Add a hosted agent" subtitle="Creates an isolated identity and credential">
1489
            {providerResult ? (
1490
              <div className="providerResult">
1491
                <div className="successBanner">
1492
                  <strong>{providerResult.agent.name} created</strong>
1493
                  <span>{providerResult.agent.agentId}</span>
1494
                </div>
1495
                <label>
1496
                  <span>MCP URL</span>
1497
                  <code>{endpoint}</code>
1498
                </label>
1499
                <label>
1500
                  <span>Agent token — copy it now; it will not be shown again</span>
1501
                  <code>{providerResult.accessToken.token}</code>
1502
                </label>
1503
                <small>
1504
                  This agent-bound token authorizes MCP and automatic runtime registration. It
1505
                  cannot connect or manage another agent.
1506
                </small>
1507
                <div className="agentActions">
1508
                  <button
1509
                    className="secondary"
1510
                    type="button"
1511
                    onClick={() => {
1512
                      void navigator.clipboard.writeText(providerResult.accessToken.token);
1513
                      setTokenCopied(true);
1514
                      window.setTimeout(() => setTokenCopied(false), 2000);
1515
                    }}
1516
                  >
1517
                    {tokenCopied ? 'Copied' : 'Copy token'}
1518
                  </button>
1519
                  <button className="primary" type="button" onClick={() => navigate('agents')}>
1520
                    View agent
1521
                  </button>
1522
                </div>
1523
              </div>
1524
            ) : (
1525
              <form
1526
                className="providerForm"
1527
                onSubmit={(event) => void connectHostedProvider(event)}
1528
              >
1529
                <label>
1530
                  <span>Provider</span>
1531
                  <select
1532
                    value={providerForm.provider}
1533
                    onChange={(event) =>
1534
                      setProviderForm((value) => ({
1535
                        ...value,
1536
                        provider: event.target.value as 'botpress' | 'custom',
1537
                      }))
1538
                    }
1539
                  >
1540
                    <option value="botpress">Botpress</option>
1541
                    <option value="custom">Custom / self-hosted</option>
1542
                  </select>
1543
                </label>
1544
                <label>
1545
                  <span>Agent name</span>
1546
                  <input
1547
                    required
1548
                    maxLength={100}
1549
                    value={providerForm.agentName}
1550
                    onChange={(event) =>
1551
                      setProviderForm((value) => ({ ...value, agentName: event.target.value }))
1552
                    }
1553
                    placeholder="Recruiting agent"
1554
                  />
1555
                </label>
1556
                <label>
1557
                  <span>Project</span>
1558
                  <input
1559
                    required
1560
                    maxLength={100}
1561
                    value={providerForm.projectName}
1562
                    onChange={(event) =>
1563
                      setProviderForm((value) => ({ ...value, projectName: event.target.value }))
1564
                    }
1565
                    placeholder="Recruiting"
1566
                  />
1567
                </label>
1568
                <label className="fullWidth">
1569
                  <span>Purpose</span>
1570
                  <textarea
1571
                    maxLength={500}
1572
                    value={providerForm.description}
1573
                    onChange={(event) =>
1574
                      setProviderForm((value) => ({ ...value, description: event.target.value }))
1575
                    }
1576
                    placeholder="Matches suitable candidates with open roles"
1577
                  />
1578
                </label>
1579
                <label>
1580
                  <span>Capabilities, comma-separated</span>
1581
                  <input
1582
                    value={providerForm.capabilities}
1583
                    onChange={(event) =>
1584
                      setProviderForm((value) => ({ ...value, capabilities: event.target.value }))
1585
                    }
1586
                    placeholder="candidate matching, interview coordination"
1587
                  />
1588
                </label>
1589
                <label>
1590
                  <span>Limitations, comma-separated</span>
1591
                  <input
1592
                    value={providerForm.limitations}
1593
                    onChange={(event) =>
1594
                      setProviderForm((value) => ({ ...value, limitations: event.target.value }))
1595
                    }
1596
                    placeholder="no final hiring decisions"
1597
                  />
1598
                </label>
1599
                <label>
1600
                  <span>Credential expiry</span>
1601
                  <select
1602
                    value={providerForm.expiresInDays}
1603
                    onChange={(event) =>
1604
                      setProviderForm((value) => ({
1605
                        ...value,
1606
                        expiresInDays: Number(event.target.value),
1607
                      }))
1608
                    }
1609
                  >
1610
                    <option value={30}>30 days</option>
1611
                    <option value={90}>90 days</option>
1612
                    <option value={365}>1 year</option>
1613
                  </select>
1614
                </label>
1615
                <div className="providerSubmit fullWidth">
1616
                  <p>This does not reuse or modify your Codex agent.</p>
1617
                  <button
1618
                    className="primary"
1619
                    type="submit"
1620
                    disabled={working === 'hosted-provider'}
1621
                  >
1622
                    {working === 'hosted-provider' ? 'Creating…' : 'Create agent credentials'}
1623
                  </button>
1624
                </div>
1625
                {providerError ? (
1626
                  <div className="loginError fullWidth" role="alert">
1627
                    {providerError}
1628
                  </div>
1629
                ) : null}
1630
              </form>
1631
            )}
1632
          </Panel>
1633
          <Panel
1634
            title={
1635
              providerForm.provider === 'botpress' ? 'Configure Botpress' : 'Deploy the sidecar'
1636
            }
1637
            subtitle={
1638
              providerForm.provider === 'botpress'
1639
                ? 'MCP and inbound runtime are separate connections'
1640
                : 'Runs beside an agent on any cloud'
1641
            }
1642
          >
1643
            {providerForm.provider === 'botpress' ? (
1644
              <>
1645
                <div className="connectSteps">
1646
                  <div className="connectStep">
1647
                    <b>1</b>
1648
                    <span>Add the displayed MCP URL with Bearer authentication.</span>
1649
                  </div>
1650
                  <div className="connectStep">
1651
                    <b>2</b>
1652
                    <span>
1653
                      Paste the generated <code>oc_at_…</code> agent token.
1654
                    </span>
1655
                  </div>
1656
                  <div className="connectStep">
1657
                    <b>3</b>
1658
                    <span>Install the OpenClasp Botpress runtime integration for inbound A2A.</span>
1659
                  </div>
1660
                </div>
1661
                <div className="notice providerNotice">
1662
                  MCP proves outbound tool access only. Autonomous runtime becomes connected only
1663
                  after the Botpress integration registers its webhook successfully.
1664
                </div>
1665
              </>
1666
            ) : (
1667
              <>
1668
                <div className="connectSteps">
1669
                  <div className="connectStep">
1670
                    <b>1</b>
1671
                    <span>
1672
                      Deploy <code>Dockerfile.sidecar</code> beside the agent.
1673
                    </span>
1674
                  </div>
1675
                  <div className="connectStep">
1676
                    <b>2</b>
1677
                    <span>
1678
                      Set <code>OPENCLASP_AGENT_TOKEN</code>, <code>OPENCLASP_RUNTIME_URL</code>,
1679
                      and <code>AGENT_ADAPTER_URL</code>.
1680
                    </span>
1681
                  </div>
1682
                  <div className="connectStep">
1683
                    <b>3</b>
1684
                    <span>
1685
                      The sidecar verifies and registers itself; no endpoint paste is needed.
1686
                    </span>
1687
                  </div>
1688
                </div>
1689
                <div className="notice providerNotice">
1690
                  The application implements three internal POST hooks: session-offer,
1691
                  session-activated, and message. Agent-to-agent content remains off OpenClasp.
1692
                </div>
1693
              </>
1694
            )}
1695
          </Panel>
1696
        </section>
1697
      ) : (
1698
        <section className="connectLayout">
1699
          <Panel title="Connect with OAuth" subtitle="For interactive MCP clients">
1700
            <div className="endpoint">
1701
              <code>{endpoint}</code>
1702
              <button
1703
                type="button"
1704
                onClick={() => {
1705
                  void navigator.clipboard.writeText(endpoint);
1706
                  setCopied(true);
1707
                  window.setTimeout(() => setCopied(false), 2000);
1708
                }}
1709
              >
1710
                {copied ? 'Copied' : 'Copy'}
1711
              </button>
1712
            </div>
1713
            <div className="connectSteps">
1714
              <div className="connectStep">
1715
                <b>1</b>
1716
                <span>Add this MCP URL and complete OAuth.</span>
1717
              </div>
1718
              <div className="connectStep">
1719
                <b>2</b>
1720
                <span>
1721
                  Tell the agent: <code>Set yourself up on OpenClasp</code>.
1722
                </span>
1723
              </div>
1724
              <div className="connectStep">
1725
                <b>3</b>
1726
                <span>Approve the proposed identity and mode here.</span>
1727
              </div>
1728
            </div>
1729
          </Panel>
1730
          <Panel title="Interactive clients" subtitle="Best for temporary user-driven sessions">
1731
            <ul className="checkList">
1732
              <li>OAuth opens in the browser</li>
1733
              <li>Each installation is explicitly approved</li>
1734
              <li>Temporary chats receive hosted A2A history</li>
1735
              <li>Persistent runtimes can switch to direct A2A</li>
1736
            </ul>
1737
          </Panel>
1738
        </section>
1739
      )}
1740
    </>
1741
  );
1742
}
1743
1744
function SettingsPage({
1745
  settings,
1746
  setSettings,
1747
  api,
1748
}: {
1749
  settings: Settings;
1750
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
1751
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1752
}) {
1753
  const [saving, setSaving] = useState(false);
1754
  const [saved, setSaved] = useState(false);
1755
  const [error, setError] = useState('');
1756
  const save = useCallback(async () => {
1757
    setSaving(true);
1758
    setSaved(false);
1759
    setError('');
1760
    try {
1761
      const updated = (await api('/v0.1/settings', {
1762
        method: 'PUT',
1763
        body: JSON.stringify(settings),
1764
      })) as Settings;
1765
      setSettings(updated);
1766
      setSaved(true);
1767
    } catch (reason) {
1768
      setError(reason instanceof Error ? reason.message : 'Could not save settings');
1769
    } finally {
1770
      setSaving(false);
1771
    }
1772
  }, [api, settings, setSettings]);
1773
  return (
1774
    <>
1775
      <PageHead page="settings" />
1776
      <section className="settingsCard">
1777
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
1778
          <input
1779
            value={settings.displayName}
1780
            onChange={(event) =>
1781
              setSettings((value) => ({ ...value, displayName: event.target.value }))
1782
            }
1783
            placeholder="Team or operator name"
1784
          />
1785
        </Setting>
1786
        <Setting
1787
          label="Network contribution"
1788
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
1789
        >
1790
          <Toggle
1791
            checked={settings.contributionEnabled}
1792
            label="Network contribution"
1793
            onChange={(checked) =>
1794
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
1795
            }
1796
          />
1797
        </Setting>
1798
        <Setting
1799
          label="Evidence sharing"
1800
          description="Controls when permitted evidence references may be shared with interaction participants."
1801
        >
1802
          <select
1803
            value={settings.evidenceSharing}
1804
            onChange={(event) =>
1805
              setSettings((value) => ({
1806
                ...value,
1807
                evidenceSharing: event.target.value as Settings['evidenceSharing'],
1808
              }))
1809
            }
1810
          >
1811
            <option value="never">Never</option>
1812
            <option value="ask">Ask every time</option>
1813
            <option value="contract_only">When contract permits</option>
1814
          </select>
1815
        </Setting>
1816
        <Setting
1817
          label="Structured record retention"
1818
          description="Retention for hosted account records. Signed protocol records may require separate revocation markers."
1819
        >
1820
          <select
1821
            value={settings.retentionDays}
1822
            onChange={(event) =>
1823
              setSettings((value) => ({ ...value, retentionDays: Number(event.target.value) }))
1824
            }
1825
          >
1826
            <option value={7}>7 days</option>
1827
            <option value={30}>30 days</option>
1828
            <option value={90}>90 days</option>
1829
            <option value={365}>1 year</option>
1830
            <option value={0}>No automatic deletion</option>
1831
          </select>
1832
        </Setting>
1833
        <Setting
1834
          label="Raw agent messages"
1835
          description="Persistent runtimes remain direct and are not stored. Temporary chat messages pass through OpenClasp and are encrypted at rest for 30 days."
1836
        >
1837
          <span className="locked">MODE DEPENDENT</span>
1838
        </Setting>
1839
        <div className="saveRow">
1840
          <button className="primary" type="button" onClick={() => void save()} disabled={saving}>
1841
            {saving ? 'Saving…' : 'Save settings'}
1842
          </button>
1843
          {saved && <span role="status">Saved</span>}
1844
          {error && (
1845
            <span className="loginError" role="alert">
1846
              {error}
1847
            </span>
1848
          )}
1849
        </div>
1850
      </section>
1851
    </>
1852
  );
1853
}
1854
1855
function PageHead({
1856
  page,
1857
  action,
1858
  onAction,
1859
}: {
1860
  page: Page;
1861
  action?: string;
1862
  onAction?: () => void;
1863
}) {
1864
  const meta = pageMeta[page];
1865
  return (
1866
    <header className="pageHead">
1867
      <div>
1868
        <p className="eyebrow">{meta.eyebrow}</p>
1869
        <h1>{meta.title}</h1>
1870
        <p className="lede">{meta.lede}</p>
1871
      </div>
1872
      {action && (
1873
        <button className="primary" type="button" onClick={onAction}>
1874
          {action}
1875
        </button>
1876
      )}
1877
    </header>
1878
  );
1879
}
1880
function Nav({
1881
  page,
1882
  active,
1883
  onClick,
1884
  label,
1885
  icon,
1886
  badge = 0,
1887
}: {
1888
  page: Page;
1889
  active: Page;
1890
  onClick: (page: Page) => void;
1891
  label: string;
1892
  icon: IconName;
1893
  badge?: number;
1894
}) {
1895
  return (
1896
    <button
1897
      className={active === page ? 'active' : ''}
1898
      type="button"
1899
      aria-current={active === page ? 'page' : undefined}
1900
      onClick={() => onClick(page)}
1901
    >
1902
      <Icon name={icon} />
1903
      {label}
1904
      {badge > 0 ? <span className="navBadge">{badge}</span> : null}
1905
    </button>
1906
  );
1907
}
1908
function Metric({
1909
  label,
1910
  value,
1911
  note,
1912
  warn,
1913
}: {
1914
  label: string;
1915
  value: number;
1916
  note: string;
1917
  warn?: boolean;
1918
}) {
1919
  return (
1920
    <article className={warn ? 'metric warn' : 'metric'}>
1921
      <span>{label}</span>
1922
      <strong>{value}</strong>
1923
      <small>{note}</small>
1924
    </article>
1925
  );
1926
}
1927
function Panel({
1928
  title,
1929
  subtitle,
1930
  children,
1931
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
1932
  return (
1933
    <section className="panel">
1934
      <div className="panelHead">
1935
        <div>
1936
          <h2>{title}</h2>
1937
          <p>{subtitle}</p>
1938
        </div>
1939
      </div>
1940
      {children}
1941
    </section>
1942
  );
1943
}
1944
function Timeline({ events }: { events: Record<string, any>[] }) {
1945
  return events.length ? (
1946
    <div className="timeline">
1947
      {events.map((event) => (
1948
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
1949
      ))}
1950
    </div>
1951
  ) : (
1952
    <Empty
1953
      title="Nothing recorded yet"
1954
      text="Activity appears after your connected agents submit signed structured events."
1955
    />
1956
  );
1957
}
1958
function HistoryRow({ item }: { item: Record<string, any> }) {
1959
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
1960
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
1961
  return (
1962
    <article className="historyRow">
1963
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
1964
      <div>
1965
        <strong>{kind}</strong>
1966
        <small>
1967
          {id} · {new Date(timestamp(item)).toLocaleString()}
1968
        </small>
1969
      </div>
1970
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
1971
    </article>
1972
  );
1973
}
1974
function AgentCard({
1975
  agent,
1976
  projectName,
1977
  published,
1978
  working,
1979
  onSave,
1980
  runtime,
1981
  runtimeWorking,
1982
  deleteWorking,
1983
  onRuntime,
1984
  onDisableRuntime,
1985
  accessTokens,
1986
  accessTokenWorking,
1987
  onRevokeAccessToken,
1988
  onDelete,
1989
}: {
1990
  agent: Record<string, any>;
1991
  projectName?: string;
1992
  published: boolean;
1993
  working: boolean;
1994
  onSave: (value: {
1995
    autoPublish: boolean;
1996
    autoAcceptPolicy: 'off' | 'safe_matching';
1997
    autoAcceptTaskCategories: string[];
1998
  }) => Promise<boolean>;
1999
  runtime: Record<string, any> | undefined;
2000
  runtimeWorking: boolean;
2001
  deleteWorking: boolean;
2002
  onRuntime: (endpoint: string) => void;
2003
  onDisableRuntime: () => void;
2004
  accessTokens: Record<string, any>[];
2005
  accessTokenWorking: boolean;
2006
  onRevokeAccessToken: (tokenId: string) => Promise<void>;
2007
  onDelete: () => void;
2008
}) {
2009
  const [editing, setEditing] = useState(false);
2010
  const [autoPublish, setAutoPublish] = useState(Boolean(agent.autoPublish ?? published));
2011
  const [autoAcceptPolicy, setAutoAcceptPolicy] = useState<'off' | 'safe_matching'>(
2012
    agent.autoAcceptPolicy === 'safe_matching' ? 'safe_matching' : 'off',
2013
  );
2014
  const [categories, setCategories] = useState<string>(
2015
    (agent.autoAcceptTaskCategories?.length
2016
      ? agent.autoAcceptTaskCategories
2017
      : (agent.capabilities ?? [])
2018
    ).join(', '),
2019
  );
2020
  const [runtimeEndpoint, setRuntimeEndpoint] = useState(String(runtime?.endpoint ?? ''));
2021
  const mode = agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
2022
  const temporary = mode === 'temporary_chat';
2023
  const ready = published && (temporary || runtime?.status === 'verified');
2024
  const online = agent.presence?.status === 'online';
2025
  const providerConnected = accessTokens.length > 0;
2026
  const endpoint = String(
2027
    temporary
2028
      ? `/a2a/temporary/${encodeURIComponent(agent.agentId)}`
2029
      : (runtime?.a2aEndpoint ?? agent.a2aEndpoint ?? 'Connect a runtime first'),
2030
  );
2031
  const identityLabel = agent.revoked
2032
    ? 'REVOKED'
2033
    : agent.identityMode === 'owner_managed'
2034
      ? 'OWNER VERIFIED'
2035
      : agent.identityMode === 'oauth_installation'
2036
        ? 'AUTHENTICATED'
2037
        : 'VERIFIED';
2038
  return (
2039
    <article className="agentCard">
2040
      <div className="agentTop">
2041
        <span className="agentGlyph">
2042
          <Icon name="agents" />
2043
        </span>
2044
        <div className="agentBadges">
2045
          <b className={online && temporary ? 'onlineBadge' : 'offlineBadge'}>
2046
            {temporary
2047
              ? online
2048
                ? 'CHAT ACTIVE'
2049
                : 'CHAT IDLE'
2050
              : runtime?.status === 'verified'
2051
                ? 'ENDPOINT VERIFIED'
2052
                : providerConnected
2053
                  ? 'TOKEN ONLY'
2054
                  : 'ENDPOINT MISSING'}
2055
          </b>
2056
          <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
2057
          <b className={ready ? 'readyBadge' : 'needsBadge'}>{ready ? 'READY' : 'SETUP NEEDED'}</b>
2058
        </div>
2059
      </div>
2060
      <h2>{agent.name ?? agent.agentId}</h2>
2061
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
2062
      <div className="tags">
2063
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
2064
          <span key={capability}>{capability}</span>
2065
        ))}
2066
      </div>
2067
      <small>
2068
        {agent.framework ? `${agent.framework} · ` : ''}
2069
        {agent.identityMode === 'owner_managed'
2070
          ? 'Owner-created · '
2071
          : agent.identityMode === 'oauth_installation'
2072
            ? 'OAuth-bound · '
2073
            : 'Ed25519 · '}
2074
        Created {new Date(agent.createdAt).toLocaleDateString()}
2075
      </small>
2076
      <small>
2077
        {agent.presence?.lastSeenAt
2078
          ? `Last agent activity ${new Date(agent.presence.lastSeenAt).toLocaleString()}`
2079
          : 'No agent activity recorded yet'}
2080
      </small>
2081
      {published ? (
2082
        <a
2083
          href={`/agents/${encodeURIComponent(agent.agentId)}/card.json`}
2084
          target="_blank"
2085
          rel="noreferrer"
2086
        >
2087
          Public Agent Card
2088
        </a>
2089
      ) : null}
2090
      <div className="automationSummary">
2091
        <span>{published ? 'Public discovery' : 'Private'}</span>
2092
        <span>{temporary ? 'Hosted temporary A2A' : 'Direct agent-owned A2A'}</span>
2093
        <span>
2094
          {agent.autoAcceptPolicy === 'safe_matching' ? 'Safe tasks automatic' : 'Manual approval'}
2095
        </span>
2096
      </div>
2097
      <div className="runtimeBox">
2098
        <div>
2099
          <strong>{temporary ? 'Temporary chat endpoint' : 'Autonomous runtime'}</strong>
2100
          <span
2101
            className={
2102
              temporary || runtime?.status === 'verified' ? 'runtimeLive' : 'runtimeMissing'
2103
            }
2104
          >
2105
            {temporary
2106
              ? 'HOSTED BY OPENCLASP'
2107
              : runtime?.status === 'verified'
2108
                ? 'VERIFIED'
2109
                : 'NOT CONNECTED'}
2110
          </span>
2111
        </div>
2112
        <p>
2113
          {temporary
2114
            ? 'OpenClasp receives A2A for this temporary identity and encrypts its history at rest. Connect a runtime below to switch to direct A2A.'
2115
            : runtime?.status === 'verified'
2116
              ? 'The agent runtime registered itself successfully. Live message bodies travel directly between agents.'
2117
              : providerConnected
2118
                ? 'MCP is connected, but inbound A2A is not. Install the provider connector or deploy the sidecar; it registers this endpoint automatically.'
2119
                : 'Install a provider connector, deploy the sidecar, or register a native A2A endpoint.'}
2120
        </p>
2121
        <input
2122
          type="url"
2123
          value={runtimeEndpoint}
2124
          onChange={(event) => setRuntimeEndpoint(event.target.value)}
2125
          placeholder="https://agent.example.com/openclasp"
2126
        />
2127
        <small>
2128
          Manual registration is only for a runtime that already implements OpenClasp A2A.
2129
        </small>
2130
        <div className="agentActions">
2131
          {runtime?.status === 'verified' ? (
2132
            <button
2133
              className="secondary"
2134
              type="button"
2135
              disabled={runtimeWorking}
2136
              onClick={onDisableRuntime}
2137
            >
2138
              Disable runtime
2139
            </button>
2140
          ) : null}
2141
          <button
2142
            className="primary"
2143
            type="button"
2144
            disabled={runtimeWorking || !runtimeEndpoint.trim()}
2145
            onClick={() => onRuntime(runtimeEndpoint.trim())}
2146
          >
2147
            {runtimeWorking ? 'Verifying…' : runtime ? 'Rotate connection' : 'Verify & connect'}
2148
          </button>
2149
        </div>
2150
        {runtime?.lastError ? <small>Last session error: {runtime.lastError}</small> : null}
2151
      </div>
2152
      {accessTokens.length ? (
2153
        <div className="accessTokenBox">
2154
          <div className="accessTokenHead">
2155
            <div>
2156
              <strong>Provider connections</strong>
2157
              <span>{accessTokens.length} ACTIVE</span>
2158
            </div>
2159
            <p>Created through Connect. Revoke a provider here if its credential is exposed.</p>
2160
          </div>
2161
          <div className="tokenList">
2162
            {accessTokens.map((token) => (
2163
              <div key={token.tokenId}>
2164
                <span>
2165
                  <strong>{token.name}</strong>
2166
                  <small>
2167
                    Created {new Date(token.createdAt).toLocaleDateString()} · expires{' '}
2168
                    {new Date(token.expiresAt).toLocaleDateString()}
2169
                    {token.lastUsedAt
2170
                      ? ` · used ${new Date(token.lastUsedAt).toLocaleString()}`
2171
                      : ' · never used'}
2172
                  </small>
2173
                </span>
2174
                <button
2175
                  className="secondary dangerButton"
2176
                  type="button"
2177
                  disabled={accessTokenWorking}
2178
                  onClick={() => {
2179
                    if (window.confirm(`Revoke “${String(token.name)}”? Botpress will disconnect.`))
2180
                      void onRevokeAccessToken(String(token.tokenId));
2181
                  }}
2182
                >
2183
                  Revoke
2184
                </button>
2185
              </div>
2186
            ))}
2187
          </div>
2188
        </div>
2189
      ) : null}
2190
      {editing ? (
2191
        <div className="automationForm">
2192
          <label>
2193
            <span>
2194
              {temporary ? 'OpenClasp temporary A2A endpoint' : 'Agent-owned A2A endpoint'}
2195
            </span>
2196
            <input type="url" value={endpoint} readOnly />
2197
          </label>
2198
          <label>
2199
            <span>Invitation policy</span>
2200
            <select
2201
              value={autoAcceptPolicy}
2202
              onChange={(event) =>
2203
                setAutoAcceptPolicy(event.target.value as 'off' | 'safe_matching')
2204
              }
2205
            >
2206
              <option value="safe_matching">Auto-accept safe matches</option>
2207
              <option value="off">Review every request</option>
2208
            </select>
2209
          </label>
2210
          <label>
2211
            <span>Safe task categories</span>
2212
            <input
2213
              value={categories}
2214
              onChange={(event) => setCategories(event.target.value)}
2215
              placeholder="research, planning, coding"
2216
            />
2217
          </label>
2218
          <label className="checkControl">
2219
            <input
2220
              type="checkbox"
2221
              checked={autoPublish}
2222
              onChange={(event) => setAutoPublish(event.target.checked)}
2223
            />
2224
            <span>Publish and keep Agent Card updated</span>
2225
          </label>
2226
          <div className="agentActions">
2227
            <button className="secondary" type="button" onClick={() => setEditing(false)}>
2228
              Cancel
2229
            </button>
2230
            <button
2231
              className="primary"
2232
              type="button"
2233
              disabled={working || agent.status === 'revoked'}
2234
              onClick={() =>
2235
                void onSave({
2236
                  autoPublish,
2237
                  autoAcceptPolicy,
2238
                  autoAcceptTaskCategories: categories
2239
                    .split(',')
2240
                    .map((value) => value.trim())
2241
                    .filter(Boolean),
2242
                }).then((ok) => {
2243
                  if (ok) setEditing(false);
2244
                })
2245
              }
2246
            >
2247
              {working ? 'Saving…' : 'Save automation'}
2248
            </button>
2249
          </div>
2250
        </div>
2251
      ) : (
2252
        <div className="agentActions agentManagementActions">
2253
          <button
2254
            className="secondary dangerButton"
2255
            type="button"
2256
            disabled={working || runtimeWorking || deleteWorking}
2257
            onClick={onDelete}
2258
          >
2259
            {deleteWorking ? 'Deleting…' : 'Delete agent'}
2260
          </button>
2261
          <button
2262
            className="secondary"
2263
            type="button"
2264
            disabled={working || runtimeWorking || deleteWorking || agent.status === 'revoked'}
2265
            onClick={() => setEditing(true)}
2266
          >
2267
            Configure automation
2268
          </button>
2269
        </div>
2270
      )}
2271
    </article>
2272
  );
2273
}
2274
function Profile({ profile, expanded }: { profile: Record<string, any>; expanded?: boolean }) {
2275
  const dimensions = [
2276
    ['Completion', profile.completion],
2277
    ['Scope', profile.scope],
2278
    ['Evidence', profile.evidence],
2279
    ...(expanded
2280
      ? [
2281
          ['Communication', profile.communication],
2282
          ['Deadline', profile.deadline],
2283
        ]
2284
      : []),
2285
  ] as [string, number][];
2286
  return (
2287
    <article className="profile">
2288
      <div>
2289
        <strong>{profile.agentId}</strong>
2290
        <small>
2291
          {profile.taskCategory} · {profile.sampleSize} outcome(s)
2292
        </small>
2293
      </div>
2294
      {dimensions.map(([label, value]) => (
2295
        <Meter key={label} label={label} value={value ?? 0} />
2296
      ))}
2297
    </article>
2298
  );
2299
}
2300
function Meter({ label, value }: { label: string; value: number }) {
2301
  return (
2302
    <div className="meter">
2303
      <span>{label}</span>
2304
      <i>
2305
        <b style={{ width: `${Math.round(value * 100)}%` }} />
2306
      </i>
2307
      <strong>{Math.round(value * 100)}%</strong>
2308
    </div>
2309
  );
2310
}
2311
function Empty({
2312
  title,
2313
  text,
2314
  action,
2315
  onAction,
2316
}: {
2317
  title: string;
2318
  text: string;
2319
  action?: string;
2320
  onAction?: () => void;
2321
}) {
2322
  return (
2323
    <div className="empty">
2324
      <Icon name="agents" />
2325
      <strong>{title}</strong>
2326
      <p>{text}</p>
2327
      {action && (
2328
        <button className="secondary" type="button" onClick={onAction}>
2329
          {action}
2330
        </button>
2331
      )}
2332
    </div>
2333
  );
2334
}
2335
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
2336
  return (
2337
    <button className="textButton" type="button" onClick={onClick}>
2338
      {children}
2339
    </button>
2340
  );
2341
}
2342
function Setting({
2343
  label,
2344
  description,
2345
  children,
2346
}: React.PropsWithChildren<{ label: string; description: string }>) {
2347
  return (
2348
    <div className="setting">
2349
      <div>
2350
        <strong>{label}</strong>
2351
        <p>{description}</p>
2352
      </div>
2353
      <div>{children}</div>
2354
    </div>
2355
  );
2356
}
2357
function Toggle({
2358
  checked,
2359
  onChange,
2360
  label,
2361
}: {
2362
  checked: boolean;
2363
  onChange: (checked: boolean) => void;
2364
  label?: string;
2365
}) {
2366
  return (
2367
    <button
2368
      className={checked ? 'toggle on' : 'toggle'}
2369
      type="button"
2370
      role="switch"
2371
      aria-checked={checked}
2372
      aria-label={label}
2373
      onClick={() => onChange(!checked)}
2374
    >
2375
      <span />
2376
    </button>
2377
  );
2378
}
2379
function Loading({ compact }: { compact?: boolean }) {
2380
  return (
2381
    <div className={compact ? 'loading compact' : 'loading'}>
2382
      <span className="spinner" aria-hidden="true" />
2383
      <p>Verifying session…</p>
2384
    </div>
2385
  );
2386
}
2387
2388
type IconName =
2389
  'home' | 'history' | 'agents' | 'insights' | 'connect' | 'settings' | 'sun' | 'moon';
2390
2391
function Icon({ name }: { name: IconName }) {
2392
  return (
2393
    <svg
2394
      viewBox="0 0 24 24"
2395
      fill="none"
2396
      stroke="currentColor"
2397
      strokeWidth="1.8"
2398
      strokeLinecap="round"
2399
      strokeLinejoin="round"
2400
      aria-hidden="true"
2401
    >
2402
      {name === 'home' && <path d="M4 11 12 4l8 7v9a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z" />}
2403
      {name === 'history' && (
2404
        <>
2405
          <path d="M4 12a8 8 0 1 0 2.2-5.5" />
2406
          <path d="M4 4v5h5" />
2407
          <path d="M12 8v5l3 2" />
2408
        </>
2409
      )}
2410
      {name === 'agents' && (
2411
        <>
2412
          <path d="M8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" />
2413
          <path d="M16 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" />
2414
          <path d="M4 20v-1a4 4 0 0 1 4-4h1" />
2415
          <path d="M20 20v-1a4 4 0 0 0-4-4h-1" />
2416
        </>
2417
      )}
2418
      {name === 'insights' && (
2419
        <>
2420
          <path d="M4 19h16" />
2421
          <path d="M7 16V9" />
2422
          <path d="M12 16V5" />
2423
          <path d="M17 16v-6" />
2424
        </>
2425
      )}
2426
      {name === 'connect' && (
2427
        <>
2428
          <path d="M12 5v14" />
2429
          <path d="M5 12h14" />
2430
        </>
2431
      )}
2432
      {name === 'settings' && (
2433
        <>
2434
          <circle cx="12" cy="12" r="3" />
2435
          <path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M16.9 16.9l1.5 1.5M5.6 18.4l1.4-1.4M16.9 7.1l1.5-1.5" />
2436
        </>
2437
      )}
2438
      {name === 'sun' && (
2439
        <>
2440
          <circle cx="12" cy="12" r="4" />
2441
          <path d="M12 3v2M12 19v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M3 12h2M19 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
2442
        </>
2443
      )}
2444
      {name === 'moon' && <path d="M16 13a6 6 0 0 1-7.8-7.8A7 7 0 1 0 16 13" />}
2445
    </svg>
2446
  );
2447
}
2448
2449
function GoogleMark() {
2450
  return (
2451
    <svg viewBox="0 0 24 24" aria-hidden="true">
2452
      <path
2453
        fill="#4285F4"
2454
        d="M22.6 12.2c0-.8-.1-1.6-.2-2.3H12v4.4h5.9c-.3 1.4-1 2.6-2.2 3.4v2.8h3.6c2.1-1.9 3.3-4.8 3.3-8.3"
2455
      />
2456
      <path
2457
        fill="#34A853"
2458
        d="M12 23c3 0 5.5-1 7.3-2.7l-3.6-2.8c-1 .7-2.3 1.1-3.7 1.1-2.8 0-5.2-1.9-6.1-4.4H2.2v2.9C4 20.5 7.7 23 12 23"
2459
      />
2460
      <path
2461
        fill="#FBBC05"
2462
        d="M5.9 14.2c-.2-.7-.4-1.4-.4-2.2s.1-1.5.4-2.2V7H2.2C1.4 8.6 1 10.3 1 12s.4 3.4 1.2 5z"
2463
      />
2464
      <path
2465
        fill="#EA4335"
2466
        d="M12 4.8c1.6 0 3.1.6 4.2 1.7l3.2-3.2C17.5 1.5 15 0.5 12 .5 7.7.5 4 3 2.2 7l3.7 2.9C6.8 6.7 9.2 4.8 12 4.8"
2467
      />
2468
    </svg>
2469
  );
2470
}
2471
2472
function GitHubMark() {
2473
  return (
2474
    <svg viewBox="0 0 24 24" aria-hidden="true">
2475
      <path
2476
        fill="currentColor"
2477
        d="M12 2a10 10 0 0 0-3.2 19.5c.5.1.7-.2.7-.5v-1.7c-2.8.6-3.4-1.2-3.4-1.2-.5-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.4 1.1 3 .8.1-.6.4-1.1.6-1.3-2.2-.3-4.6-1.1-4.6-5a3.9 3.9 0 0 1 1-2.7 3.6 3.6 0 0 1 .1-2.6s.8-.3 2.7 1a9.3 9.3 0 0 1 5 0c1.9-1.3 2.7-1 2.7-1a3.6 3.6 0 0 1 .1 2.6 3.9 3.9 0 0 1 1 2.7c0 3.9-2.3 4.7-4.6 5 .4.3.7.9.7 1.9v2.8c0 .3.2.6.7.5A10 10 0 0 0 12 2"
2478
      />
2479
    </svg>
2480
  );
2481
}
2482
2483
function initials(value: string) {
2484
  return value
2485
    .split(/\s+|@/)
2486
    .slice(0, 2)
2487
    .map((part) => part[0]?.toUpperCase())
2488
    .join('');
2489
}
2490
function timestamp(item: Record<string, any>) {
2491
  return String(item.timestamp ?? item.completedAt ?? item.createdAt ?? new Date(0).toISOString());
2492
}
2493
2494
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
2495
  throw new Error('Auth0 public configuration is incomplete');
2496
createRoot(document.getElementById('root')!).render(
2497
  <React.StrictMode>
2498
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
2499
  </React.StrictMode>,
2500
);