agent-relay-server 0.4.27 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,6 +40,21 @@ Agent Relay has two parts:
40
40
 
41
41
  Pick your provider: install the Claude Code plugin, the Codex connector, or both. The shared config is the same four env vars either way. See the [full mental model](docs/mental-model.md) for routing, identity, and task lifecycle details, or the [provider spec](docs/provider-spec.md) if you want to build your own integration.
42
42
 
43
+ ## Extension Model
44
+
45
+ Agent Relay is built around four extension points:
46
+
47
+ > **Agents do work. Providers host agents. Integrations create work. Channels carry conversations.**
48
+
49
+ That split keeps the system small without making everything a one-off plugin:
50
+
51
+ - **Agents** are running AI sessions or workers that can receive messages, claim tasks, and produce results.
52
+ - **Providers** connect an AI runtime to the relay. The Claude plugin and Codex connector are providers.
53
+ - **Integrations** connect external systems that create or update work. CI, monitoring, support desks, and deployment tools fit here.
54
+ - **Channels** connect communication surfaces where humans and systems already talk. Telegram is the first channel; Slack, email, SMS, Matrix, Discord, and web chat are natural next ones.
55
+
56
+ If you want to contribute, pick the shape that matches your system. Building a new AI runtime adapter? Build a provider. Sending alerts or tickets into Agent Relay? Build an integration. Opening Agent Relay to another messaging app? Build a channel.
57
+
43
58
  ## Quick Start
44
59
 
45
60
  ### 1. Start the relay server
@@ -193,7 +208,7 @@ When an integration has `callbackUrl`, Agent Relay posts task lifecycle events
193
208
 
194
209
  Integration tokens can also be used as scoped API tokens when the full admin
195
210
  `AGENT_RELAY_TOKEN` would be too broad. Supported scopes include `stats:read`,
196
- `health:read`, `events:read`, `agents:read`, `agents:write`, `messages:read`,
211
+ `health:read`, `events:read`, `channels:read`, `agents:read`, `agents:write`, `messages:read`,
197
212
  `messages:write`, `tasks:read`, `tasks:write`, `pairs:read`, `pairs:write`,
198
213
  `system:write`, and `*`.
199
214
  The event ingress endpoint also accepts the legacy `tasks:create` and
@@ -370,6 +385,8 @@ relay asks for a more specific target such as `id:...`, `label:...`, `tag:...`,
370
385
 
371
386
  | Method | Path | Purpose |
372
387
  |--------|------|---------|
388
+ | `GET` | `/channels` | List registered communication channels such as Telegram |
389
+ | `GET` | `/integrations` | List configured and observed task/system integrations |
373
390
  | `POST` | `/integrations/events` | Integration event ingress |
374
391
  | `GET` | `/tasks` | List tasks |
375
392
  | `GET` | `/tasks/:id` | Get one task |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -50,6 +50,8 @@
50
50
  pairs: [],
51
51
  messages: [],
52
52
  tasks: [],
53
+ integrations: [],
54
+ channels: [],
53
55
  taskEvents: [],
54
56
  taskEventCache: {},
55
57
  stats: {},
@@ -211,6 +213,8 @@
211
213
  if (view === "activity") await Promise.all([this.fetchMessages(), this.fetchPairs(), this.fetchTasks(), this.fetchActivityEvents()]);
212
214
  if (view === "work") await Promise.all([this.fetchMessages(), this.fetchTasks()]);
213
215
  if (view === "pairs") this.fetchPairs();
216
+ if (view === "channels") await this.fetchChannels();
217
+ if (view === "integrations") await this.fetchIntegrations();
214
218
  if (view === "tasks") this.fetchTasks();
215
219
  },
216
220
 
@@ -353,7 +357,7 @@
353
357
  },
354
358
 
355
359
  async refresh() {
356
- await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchInboxState(), this.fetchActivityEvents()]);
360
+ await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchChannels(), this.fetchIntegrations(), this.fetchInboxState(), this.fetchActivityEvents()]);
357
361
  },
358
362
 
359
363
  async refreshLiveData() {
@@ -424,6 +428,18 @@
424
428
  } catch {}
425
429
  },
426
430
 
431
+ async fetchIntegrations() {
432
+ try {
433
+ this.integrations = await this.api("GET", "/integrations");
434
+ } catch {}
435
+ },
436
+
437
+ async fetchChannels() {
438
+ try {
439
+ this.channels = await this.api("GET", "/channels");
440
+ } catch {}
441
+ },
442
+
427
443
  async fetchInboxState() {
428
444
  try {
429
445
  const state = await this.api("GET", "/inbox/state?operatorId=" + encodeURIComponent(INBOX_OPERATOR_ID));
@@ -486,6 +502,8 @@
486
502
  attentionAgentCount: { get: getAttentionAgentCount },
487
503
  activityItems: { get: getActivityItems },
488
504
  workQueueItems: { get: getWorkQueueItems },
505
+ channelCards: { get: getChannelCards },
506
+ integrationCards: { get: getIntegrationCards },
489
507
  filteredMessages: { get: getFilteredMessages },
490
508
  groupedMessages: { get: getGroupedMessages },
491
509
  filteredTasks: { get: getFilteredTasks },
@@ -1004,6 +1022,28 @@
1004
1022
  return this.tasks.filter((task) => !CLOSED_TASK_STATUSES.has(task.status));
1005
1023
  }
1006
1024
 
1025
+ function getIntegrationCards() {
1026
+ return [...(this.integrations || [])].sort((a, b) => {
1027
+ const aStats = a.taskStats || {};
1028
+ const bStats = b.taskStats || {};
1029
+ const openDiff = (bStats.openTasks || 0) - (aStats.openTasks || 0);
1030
+ if (openDiff !== 0) return openDiff;
1031
+ const waitingDiff = (bStats.waitingTasks || 0) - (aStats.waitingTasks || 0);
1032
+ if (waitingDiff !== 0) return waitingDiff;
1033
+ return String(a.name || "").localeCompare(String(b.name || ""));
1034
+ });
1035
+ }
1036
+
1037
+ function getChannelCards() {
1038
+ return [...(this.channels || [])].sort((a, b) => {
1039
+ const readyDiff = Number(Boolean(b.ready)) - Number(Boolean(a.ready));
1040
+ if (readyDiff !== 0) return readyDiff;
1041
+ const statusDiff = String(a.status || "").localeCompare(String(b.status || ""));
1042
+ if (statusDiff !== 0) return statusDiff;
1043
+ return String(a.name || "").localeCompare(String(b.name || ""));
1044
+ });
1045
+ }
1046
+
1007
1047
  function getComposeAgents() {
1008
1048
  const list = visibleAgents(this);
1009
1049
  return this.showOffline ? list : list.filter((agent) => agent.status !== "offline");
@@ -1177,6 +1217,8 @@
1177
1217
  agentStatusClass,
1178
1218
  severityClass,
1179
1219
  agentStatusTitle,
1220
+ channelPresence,
1221
+ integrationPresence,
1180
1222
  timeAgo,
1181
1223
  fmtTime,
1182
1224
  healthAlertClass,
@@ -1389,6 +1431,23 @@
1389
1431
  return "bg-info-lt";
1390
1432
  }
1391
1433
 
1434
+ function integrationPresence(integration) {
1435
+ const stats = integration?.taskStats || {};
1436
+ if ((stats.waitingTasks || 0) > 0) return { label: "waiting", tone: "warning", icon: "ti-alert-circle" };
1437
+ if ((stats.openTasks || 0) > 0) return { label: "active", tone: "info", icon: "ti-activity" };
1438
+ if (!integration?.configured) return { label: "observed only", tone: "secondary", icon: "ti-eye" };
1439
+ if (integration.observed) return { label: "quiet", tone: "success", icon: "ti-circle-check" };
1440
+ return { label: "configured", tone: "primary", icon: "ti-plug-connected" };
1441
+ }
1442
+
1443
+ function channelPresence(channel) {
1444
+ if (!channel) return { label: "unknown", tone: "secondary", icon: "ti-plug-off" };
1445
+ if (channel.status === "offline") return { label: "offline", tone: "secondary", icon: "ti-plug-off" };
1446
+ if (!channel.ready) return { label: "not ready", tone: "warning", icon: "ti-loader" };
1447
+ if (channel.status === "busy") return { label: "busy", tone: "warning", icon: "ti-activity" };
1448
+ return { label: "ready", tone: "success", icon: "ti-circle-check" };
1449
+ }
1450
+
1392
1451
  function agentStatusTitle(agent) {
1393
1452
  if (!agent) return "";
1394
1453
  if (agent.status === "offline") return "offline";
package/public/index.html CHANGED
@@ -190,6 +190,16 @@
190
190
  <span class="badge bg-warning text-white ms-auto" x-show="attentionAgentCount > 0" x-text="attentionAgentCount"></span>
191
191
  <span class="badge bg-success text-white ms-1" x-text="onlineCount"></span>
192
192
  </a>
193
+ <a href="#" class="nav-link" :class="{ active: view === 'channels' }" @click.prevent="switchView('channels')">
194
+ <i class="ti ti-messages"></i>Channels
195
+ <span class="badge bg-success text-white ms-auto" x-show="channelCards.filter((item) => item.ready).length > 0" x-text="channelCards.filter((item) => item.ready).length"></span>
196
+ <span class="badge bg-secondary text-white ms-1" x-text="channelCards.length"></span>
197
+ </a>
198
+ <a href="#" class="nav-link" :class="{ active: view === 'integrations' }" @click.prevent="switchView('integrations')">
199
+ <i class="ti ti-plug-connected"></i>Integrations
200
+ <span class="badge bg-warning text-white ms-auto" x-show="integrationCards.some((item) => (item.taskStats?.waitingTasks || 0) > 0)" x-text="integrationCards.reduce((total, item) => total + (item.taskStats?.waitingTasks || 0), 0)"></span>
201
+ <span class="badge bg-secondary text-white ms-1" x-text="integrationCards.length"></span>
202
+ </a>
193
203
  <a href="#" class="nav-link" :class="{ active: view === 'inbox' }" @click.prevent="switchView('inbox')">
194
204
  <i class="ti ti-inbox"></i>Inbox
195
205
  <span class="badge bg-danger text-white ms-auto" x-show="attentionSummary.unreadInbox > 0" x-text="attentionSummary.unreadInbox"></span>
@@ -251,7 +261,7 @@
251
261
 
252
262
  <!-- Mobile nav -->
253
263
  <div class="mobile-nav d-none border-bottom p-2 gap-1 position-fixed top-0 w-100 bg-dark" style="z-index:50">
254
- <template x-for="v in ['overview','agents','inbox','activity','pairs','messages','work','tasks','analytics']">
264
+ <template x-for="v in ['overview','agents','channels','integrations','inbox','activity','pairs','messages','work','tasks','analytics']">
255
265
  <button class="btn btn-sm" :class="view === v ? 'btn-primary' : 'btn-ghost-secondary'" @click="switchView(v)" x-text="v.charAt(0).toUpperCase() + v.slice(1)"></button>
256
266
  </template>
257
267
  </div>
@@ -660,6 +670,164 @@
660
670
  </template>
661
671
  </div>
662
672
 
673
+ <!-- ==================== CHANNELS ==================== -->
674
+ <div x-show="view === 'channels'" x-cloak class="fade-in">
675
+ <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
676
+ <h2 class="page-title mb-0">Channels</h2>
677
+ <span class="badge bg-success-lt" x-show="channelCards.filter((item) => item.ready).length > 0" x-text="channelCards.filter((item) => item.ready).length + ' ready'"></span>
678
+ <div class="ms-auto d-flex gap-2 align-items-center">
679
+ <button class="btn btn-sm btn-ghost-secondary" @click="fetchChannels()">
680
+ <i class="ti ti-refresh"></i>
681
+ </button>
682
+ </div>
683
+ </div>
684
+
685
+ <div class="row g-3">
686
+ <template x-for="channel in channelCards" :key="channel.id">
687
+ <div class="col-md-6 col-xl-4">
688
+ <div class="card">
689
+ <div class="card-body">
690
+ <div class="d-flex align-items-start gap-2">
691
+ <span class="agent-type-icon agent mt-0">
692
+ <i class="ti" :class="channelPresence(channel).icon"></i>
693
+ </span>
694
+ <div class="flex-grow-1 min-width-0">
695
+ <div class="d-flex align-items-center gap-2">
696
+ <span class="fw-bold text-truncate" x-text="channel.name"></span>
697
+ <span class="badge" :class="'bg-' + channelPresence(channel).tone + '-lt'">
698
+ <i class="ti me-1" :class="channelPresence(channel).icon"></i><span x-text="channelPresence(channel).label"></span>
699
+ </span>
700
+ </div>
701
+ <div class="d-flex gap-1 mt-2 flex-wrap">
702
+ <span class="badge bg-info-lt" x-text="channel.type"></span>
703
+ <span class="badge bg-cyan-lt" x-text="channel.direction"></span>
704
+ <span class="badge bg-secondary-lt" x-text="displayTarget(channel.target || channel.agentId)"></span>
705
+ </div>
706
+ <div class="d-flex gap-1 mt-2 flex-wrap" x-show="channel.capabilities?.length">
707
+ <template x-for="capability in (channel.capabilities || [])" :key="capability">
708
+ <span class="badge bg-secondary-lt" x-text="capability"></span>
709
+ </template>
710
+ </div>
711
+ <div class="d-flex gap-1 mt-2 flex-wrap" x-show="channel.topicChannels?.length">
712
+ <template x-for="topic in (channel.topicChannels || [])" :key="topic">
713
+ <span class="badge bg-warning-lt" x-text="'#' + topic"></span>
714
+ </template>
715
+ </div>
716
+ <div class="text-secondary small mt-2 d-flex gap-2 flex-wrap">
717
+ <span x-text="'Agent: ' + channel.agentId"></span>
718
+ <span x-text="'Last seen: ' + timeAgo(channel.lastSeen)"></span>
719
+ </div>
720
+ </div>
721
+ <button class="btn btn-sm btn-ghost-secondary p-1" title="Show agent" @click="openAgentDetail(agentsById[channel.agentId])">
722
+ <i class="ti ti-robot"></i>
723
+ </button>
724
+ </div>
725
+ </div>
726
+ </div>
727
+ </div>
728
+ </template>
729
+ </div>
730
+
731
+ <template x-if="channelCards.length === 0">
732
+ <div class="card">
733
+ <div class="card-body text-center text-secondary py-5">
734
+ <i class="ti ti-message-off" style="font-size:48px; opacity:0.3"></i>
735
+ <p class="mt-2">No channels registered</p>
736
+ </div>
737
+ </div>
738
+ </template>
739
+ </div>
740
+
741
+ <!-- ==================== INTEGRATIONS ==================== -->
742
+ <div x-show="view === 'integrations'" x-cloak class="fade-in">
743
+ <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
744
+ <h2 class="page-title mb-0">Integrations</h2>
745
+ <span class="badge bg-warning-lt" x-show="integrationCards.some((item) => (item.taskStats?.waitingTasks || 0) > 0)" x-text="integrationCards.reduce((total, item) => total + (item.taskStats?.waitingTasks || 0), 0) + ' waiting'"></span>
746
+ <div class="ms-auto d-flex gap-2 align-items-center">
747
+ <button class="btn btn-sm btn-ghost-secondary" @click="fetchIntegrations()">
748
+ <i class="ti ti-refresh"></i>
749
+ </button>
750
+ </div>
751
+ </div>
752
+
753
+ <div class="row g-3">
754
+ <template x-for="integration in integrationCards" :key="integration.name">
755
+ <div class="col-md-6 col-xl-4">
756
+ <div class="card">
757
+ <div class="card-body">
758
+ <div class="d-flex align-items-start gap-2">
759
+ <span class="agent-type-icon agent mt-0">
760
+ <i class="ti" :class="integrationPresence(integration).icon"></i>
761
+ </span>
762
+ <div class="flex-grow-1 min-width-0">
763
+ <div class="d-flex align-items-center gap-2">
764
+ <span class="fw-bold text-truncate" x-text="integration.name"></span>
765
+ <span class="badge" :class="'bg-' + integrationPresence(integration).tone + '-lt'">
766
+ <i class="ti me-1" :class="integrationPresence(integration).icon"></i><span x-text="integrationPresence(integration).label"></span>
767
+ </span>
768
+ </div>
769
+ <div class="d-flex gap-1 mt-2 flex-wrap">
770
+ <span class="badge" :class="integration.configured ? 'bg-success-lt' : 'bg-secondary-lt'" x-text="integration.configured ? 'configured' : 'not configured'"></span>
771
+ <span class="badge" :class="integration.observed ? 'bg-info-lt' : 'bg-secondary-lt'" x-text="integration.observed ? 'observed' : 'no tasks'"></span>
772
+ <span class="badge" :class="integration.callbackConfigured ? 'bg-purple-lt' : 'bg-secondary-lt'" x-text="integration.callbackConfigured ? 'callback' : 'no callback'"></span>
773
+ </div>
774
+ <div class="row g-2 mt-2 text-center">
775
+ <div class="col-3">
776
+ <div class="text-secondary small">Tasks</div>
777
+ <div class="h3 mb-0" x-text="integration.taskStats?.tasks || 0"></div>
778
+ </div>
779
+ <div class="col-3">
780
+ <div class="text-secondary small">Open</div>
781
+ <div class="h3 mb-0" x-text="integration.taskStats?.openTasks || 0"></div>
782
+ </div>
783
+ <div class="col-3">
784
+ <div class="text-secondary small">Waiting</div>
785
+ <div class="h3 mb-0" :class="(integration.taskStats?.waitingTasks || 0) ? 'text-warning' : ''" x-text="integration.taskStats?.waitingTasks || 0"></div>
786
+ </div>
787
+ <div class="col-3">
788
+ <div class="text-secondary small">Failed</div>
789
+ <div class="h3 mb-0" :class="(integration.taskStats?.failedTasks || 0) ? 'text-danger' : ''" x-text="integration.taskStats?.failedTasks || 0"></div>
790
+ </div>
791
+ </div>
792
+ <div class="d-flex gap-1 mt-2 flex-wrap" x-show="integration.scopes?.length">
793
+ <template x-for="scope in (integration.scopes || [])" :key="scope">
794
+ <span class="badge bg-secondary-lt" x-text="scope"></span>
795
+ </template>
796
+ </div>
797
+ <div class="d-flex gap-1 mt-2 flex-wrap" x-show="integration.targets?.length || integration.channels?.length">
798
+ <template x-for="target in (integration.targets || [])" :key="target">
799
+ <span class="badge bg-cyan-lt" x-text="displayTarget(target)"></span>
800
+ </template>
801
+ <template x-for="channel in (integration.channels || [])" :key="channel">
802
+ <span class="badge bg-warning-lt" x-text="'#' + channel"></span>
803
+ </template>
804
+ </div>
805
+ <div class="text-secondary small mt-2 d-flex gap-2 flex-wrap">
806
+ <span x-show="integration.taskStats?.lastSeenAt" x-text="'Last task: ' + timeAgo(integration.taskStats?.lastSeenAt)"></span>
807
+ <span x-show="integration.callbackHost" x-text="integration.callbackHost"></span>
808
+ <span x-text="'Rate: ' + (integration.rateLimit?.currentWindowCount || 0) + '/' + (integration.rateLimit?.limitPerMinute || 0) + ' min'"></span>
809
+ </div>
810
+ </div>
811
+ <button class="btn btn-sm btn-ghost-secondary p-1" title="Show tasks" @click="taskSourceFilter = integration.name; taskStatusFilter = ''; switchView('tasks')">
812
+ <i class="ti ti-list-details"></i>
813
+ </button>
814
+ </div>
815
+ </div>
816
+ </div>
817
+ </div>
818
+ </template>
819
+ </div>
820
+
821
+ <template x-if="integrationCards.length === 0">
822
+ <div class="card">
823
+ <div class="card-body text-center text-secondary py-5">
824
+ <i class="ti ti-plug-off" style="font-size:48px; opacity:0.3"></i>
825
+ <p class="mt-2">No integrations configured or observed</p>
826
+ </div>
827
+ </div>
828
+ </template>
829
+ </div>
830
+
663
831
  <!-- ==================== INBOX ==================== -->
664
832
  <div x-show="view === 'inbox'" x-cloak class="fade-in">
665
833
  <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
package/src/db.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  TaskSeverity,
24
24
  TaskStatus,
25
25
  IntegrationEventInput,
26
+ IntegrationTaskStats,
26
27
  InboxDraft,
27
28
  InboxState,
28
29
  InboxThreadState,
@@ -856,6 +857,32 @@ export function listTasks(filter?: { status?: string; source?: string; target?:
856
857
  return (db.prepare(`${TASK_SELECT} ${where} ORDER BY updated_at DESC LIMIT ?`).all(...params) as any[]).map(rowToTask);
857
858
  }
858
859
 
860
+ export function listIntegrationTaskStats(): IntegrationTaskStats[] {
861
+ const rows = db.prepare(`
862
+ SELECT
863
+ source,
864
+ COUNT(*) AS tasks,
865
+ SUM(CASE WHEN status NOT IN ('done', 'failed', 'canceled') THEN 1 ELSE 0 END) AS open_tasks,
866
+ SUM(CASE WHEN status IN ('open', 'blocked') AND claimed_by IS NULL THEN 1 ELSE 0 END) AS waiting_tasks,
867
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_tasks,
868
+ MAX(last_seen_at) AS last_seen_at,
869
+ MAX(updated_at) AS last_updated_at
870
+ FROM tasks
871
+ GROUP BY source
872
+ ORDER BY last_seen_at DESC, source ASC
873
+ `).all() as any[];
874
+
875
+ return rows.map((row) => ({
876
+ source: row.source,
877
+ tasks: Number(row.tasks ?? 0),
878
+ openTasks: Number(row.open_tasks ?? 0),
879
+ waitingTasks: Number(row.waiting_tasks ?? 0),
880
+ failedTasks: Number(row.failed_tasks ?? 0),
881
+ lastSeenAt: row.last_seen_at ?? undefined,
882
+ lastUpdatedAt: row.last_updated_at ?? undefined,
883
+ }));
884
+ }
885
+
859
886
  export function listTaskEvents(taskId: number): TaskEvent[] {
860
887
  return (db.prepare("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[]).map(rowToTaskEvent);
861
888
  }
package/src/routes.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  ingestIntegrationEvent,
25
25
  listTasks,
26
26
  getTask,
27
+ listIntegrationTaskStats,
27
28
  listTaskEvents,
28
29
  claimTask,
29
30
  renewTaskClaim,
@@ -49,7 +50,7 @@ import {
49
50
  validateAgentSession,
50
51
  ValidationError,
51
52
  } from "./db";
52
- import type { ActivityEventInput, ActivityKind, AgentSessionGuard, CreatePairInput, IntegrationEventInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
53
+ import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
53
54
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
54
55
  import {
55
56
  getIntegrationAuth,
@@ -481,6 +482,61 @@ function checkIntegrationRateLimit(name: string): boolean {
481
482
  return bucket.count <= INTEGRATION_RATE_LIMIT_PER_MINUTE;
482
483
  }
483
484
 
485
+ function safeCallbackHost(url: string | undefined): string | undefined {
486
+ if (!url) return undefined;
487
+ try {
488
+ return new URL(url).host;
489
+ } catch {
490
+ return undefined;
491
+ }
492
+ }
493
+
494
+ function metaString(meta: Record<string, unknown> | undefined, key: string): string | undefined {
495
+ const value = meta?.[key];
496
+ return typeof value === "string" && value.trim() ? value : undefined;
497
+ }
498
+
499
+ function metaStringArray(meta: Record<string, unknown> | undefined, key: string): string[] {
500
+ const value = meta?.[key];
501
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
502
+ }
503
+
504
+ function metaDirection(meta: Record<string, unknown> | undefined): ChannelDirection {
505
+ const value = metaString(meta, "direction");
506
+ return value === "inbound" || value === "outbound" || value === "bidirectional" ? value : "bidirectional";
507
+ }
508
+
509
+ function isChannelAgent(agent: AgentCard): boolean {
510
+ const kind = metaString(agent.meta, "kind");
511
+ return kind === "channel" ||
512
+ kind === "communication-channel" ||
513
+ agent.tags.includes("channel") ||
514
+ agent.capabilities.includes("channel");
515
+ }
516
+
517
+ function agentToChannel(agent: AgentCard): ChannelSummary {
518
+ const type = metaString(agent.meta, "channelType") ?? metaString(agent.meta, "type") ?? agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ?? "custom";
519
+ const transport = metaString(agent.meta, "transport") ?? type;
520
+ const topicChannels = metaStringArray(agent.meta, "topicChannels");
521
+
522
+ return {
523
+ id: metaString(agent.meta, "channelId") ?? agent.id,
524
+ name: metaString(agent.meta, "displayName") ?? agent.name,
525
+ type,
526
+ transport,
527
+ agentId: agent.id,
528
+ status: agent.status,
529
+ ready: agent.ready,
530
+ direction: metaDirection(agent.meta),
531
+ target: metaString(agent.meta, "target"),
532
+ topicChannels,
533
+ capabilities: agent.capabilities,
534
+ tags: agent.tags,
535
+ lastSeen: agent.lastSeen,
536
+ meta: agent.meta,
537
+ };
538
+ }
539
+
484
540
  async function dispatchTaskCallbacks(taskId: number, eventType: string): Promise<void> {
485
541
  const task = getTask(taskId);
486
542
  if (!task) return;
@@ -956,6 +1012,58 @@ const postActivityEvent: Handler = async (req) => {
956
1012
 
957
1013
  // --- Tasks and integrations ---
958
1014
 
1015
+ const getIntegrations: Handler = () => {
1016
+ const configured = new Map(getIntegrationTokens().map((integration) => [integration.name, integration]));
1017
+ const observedStats = new Map(listIntegrationTaskStats().map((stats) => [stats.source, stats]));
1018
+ const names = [...new Set([...configured.keys(), ...observedStats.keys()])].sort((a, b) => a.localeCompare(b));
1019
+ const now = Date.now();
1020
+
1021
+ const integrations: IntegrationSummary[] = names.map((name) => {
1022
+ const config = configured.get(name);
1023
+ const taskStats = observedStats.get(name) ?? {
1024
+ source: name,
1025
+ tasks: 0,
1026
+ openTasks: 0,
1027
+ waitingTasks: 0,
1028
+ failedTasks: 0,
1029
+ };
1030
+ const bucket = integrationRateBuckets.get(name);
1031
+ const bucketCurrent = Boolean(bucket && now - bucket.windowStart < 60_000);
1032
+
1033
+ return {
1034
+ name,
1035
+ configured: Boolean(config),
1036
+ observed: observedStats.has(name),
1037
+ scopes: config?.scopes ?? [],
1038
+ targets: config?.targets ?? [],
1039
+ channels: config?.channels ?? [],
1040
+ callbackHost: safeCallbackHost(config?.callbackUrl),
1041
+ callbackConfigured: Boolean(config?.callbackUrl),
1042
+ rateLimit: {
1043
+ limitPerMinute: INTEGRATION_RATE_LIMIT_PER_MINUTE,
1044
+ currentWindowCount: bucketCurrent ? bucket!.count : 0,
1045
+ windowStartedAt: bucketCurrent ? bucket!.windowStart : undefined,
1046
+ },
1047
+ taskStats,
1048
+ };
1049
+ });
1050
+
1051
+ return json(integrations);
1052
+ };
1053
+
1054
+ const getChannels: Handler = () => {
1055
+ const channels = listAgents()
1056
+ .filter(isChannelAgent)
1057
+ .map(agentToChannel)
1058
+ .sort((a, b) => {
1059
+ const readyDiff = Number(b.ready) - Number(a.ready);
1060
+ if (readyDiff !== 0) return readyDiff;
1061
+ return a.name.localeCompare(b.name);
1062
+ });
1063
+
1064
+ return json(channels);
1065
+ };
1066
+
959
1067
  const postIntegrationEvent: Handler = async (req) => {
960
1068
  const auth = getIntegrationAuth(req);
961
1069
  if (!auth) return error("integration token required", 401);
@@ -1333,6 +1441,8 @@ const routes: Route[] = [
1333
1441
  route("GET", "/api/activity", getActivityEvents),
1334
1442
  route("POST", "/api/activity", postActivityEvent),
1335
1443
 
1444
+ route("GET", "/api/channels", getChannels),
1445
+ route("GET", "/api/integrations", getIntegrations),
1336
1446
  route("POST", "/api/integrations/events", postIntegrationEvent),
1337
1447
  route("GET", "/api/tasks", getTasks),
1338
1448
  route("GET", "/api/tasks/:id", getTaskById),
package/src/security.ts CHANGED
@@ -95,7 +95,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
95
95
  if (pathname === "/api/stats") return "stats:read";
96
96
  if (pathname === "/api/health") return "health:read";
97
97
  if (pathname === "/api/events") return "events:read";
98
- if (pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
98
+ if (pathname === "/api/channels") return "channels:read";
99
+ if (pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
99
100
  if (pathname.startsWith("/api/agents")) return method === "GET" ? "agents:read" : "agents:write";
100
101
  if (pathname.startsWith("/api/activity")) return method === "GET" ? "activity:read" : "activity:write";
101
102
  if (pathname.startsWith("/api/inbox")) return method === "GET" ? "messages:read" : "messages:write";
@@ -110,7 +111,7 @@ export function isScopedRequestAuthorized(req: Request): boolean {
110
111
  const auth = getIntegrationAuth(req);
111
112
  if (!auth) return false;
112
113
  const pathname = new URL(req.url).pathname;
113
- if (pathname.startsWith("/api/integrations/") && req.method !== "GET") {
114
+ if ((pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) && req.method !== "GET") {
114
115
  return hasIntegrationScope(auth, "integrations:write") ||
115
116
  hasIntegrationScope(auth, "tasks:create") ||
116
117
  hasIntegrationScope(auth, "events:create");
package/src/types.ts CHANGED
@@ -174,6 +174,52 @@ export interface IntegrationEventInput {
174
174
  metadata?: Record<string, unknown>;
175
175
  }
176
176
 
177
+ export interface IntegrationTaskStats {
178
+ source: string;
179
+ tasks: number;
180
+ openTasks: number;
181
+ waitingTasks: number;
182
+ failedTasks: number;
183
+ lastSeenAt?: number;
184
+ lastUpdatedAt?: number;
185
+ }
186
+
187
+ export interface IntegrationSummary {
188
+ name: string;
189
+ configured: boolean;
190
+ observed: boolean;
191
+ scopes: string[];
192
+ targets: string[];
193
+ channels: string[];
194
+ callbackHost?: string;
195
+ callbackConfigured: boolean;
196
+ rateLimit: {
197
+ limitPerMinute: number;
198
+ currentWindowCount: number;
199
+ windowStartedAt?: number;
200
+ };
201
+ taskStats: IntegrationTaskStats;
202
+ }
203
+
204
+ export type ChannelDirection = "inbound" | "outbound" | "bidirectional";
205
+
206
+ export interface ChannelSummary {
207
+ id: string;
208
+ name: string;
209
+ type: string;
210
+ transport: string;
211
+ agentId: string;
212
+ status: AgentCard["status"];
213
+ ready: boolean;
214
+ direction: ChannelDirection;
215
+ target?: string;
216
+ topicChannels: string[];
217
+ capabilities: string[];
218
+ tags: string[];
219
+ lastSeen: number;
220
+ meta?: Record<string, unknown>;
221
+ }
222
+
177
223
  export interface TaskStatusInput {
178
224
  status: TaskStatus;
179
225
  agentId?: string;