agent-relay-server 0.5.0 → 0.6.0

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/src/db.ts CHANGED
@@ -5,13 +5,17 @@ import type {
5
5
  AgentCard,
6
6
  ActivityEvent,
7
7
  ActivityEventInput,
8
+ AgentKind,
8
9
  AgentSessionGuard,
10
+ ChannelBinding,
11
+ ChannelBindingMode,
12
+ ChannelRouteTarget,
13
+ ChannelSummary,
9
14
  CreatePairInput,
10
15
  HealthCheck,
11
16
  HealthReport,
12
17
  ManagedAgent,
13
18
  Message,
14
- MessageType,
15
19
  Orchestrator,
16
20
  OrchestratorStatus,
17
21
  PairActionInput,
@@ -48,6 +52,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
48
52
  CREATE TABLE IF NOT EXISTS agents (
49
53
  id TEXT PRIMARY KEY,
50
54
  name TEXT NOT NULL,
55
+ kind TEXT NOT NULL DEFAULT 'provider',
51
56
  tags TEXT NOT NULL DEFAULT '[]',
52
57
  machine TEXT,
53
58
  rig TEXT,
@@ -64,7 +69,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
64
69
  id INTEGER PRIMARY KEY AUTOINCREMENT,
65
70
  from_agent TEXT NOT NULL,
66
71
  to_target TEXT NOT NULL,
67
- type TEXT NOT NULL DEFAULT 'message',
72
+ kind TEXT NOT NULL DEFAULT 'chat',
68
73
  channel TEXT,
69
74
  subject TEXT,
70
75
  body TEXT NOT NULL,
@@ -75,6 +80,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
75
80
  claimed_at INTEGER,
76
81
  claim_expires_at INTEGER,
77
82
  idempotency_key TEXT,
83
+ payload TEXT NOT NULL DEFAULT '{}',
78
84
  meta TEXT NOT NULL DEFAULT '{}',
79
85
  read_by TEXT NOT NULL DEFAULT '[]',
80
86
  created_at INTEGER NOT NULL
@@ -199,6 +205,38 @@ export function initDb(path: string = "agent-relay.db"): Database {
199
205
  CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
200
206
  CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
201
207
 
208
+ CREATE TABLE IF NOT EXISTS channels (
209
+ id TEXT PRIMARY KEY,
210
+ provider TEXT NOT NULL,
211
+ account_id TEXT NOT NULL,
212
+ display_name TEXT NOT NULL,
213
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
214
+ transport TEXT NOT NULL,
215
+ direction TEXT NOT NULL DEFAULT 'bidirectional',
216
+ topic_channels TEXT NOT NULL DEFAULT '[]',
217
+ capabilities TEXT NOT NULL DEFAULT '[]',
218
+ meta TEXT NOT NULL DEFAULT '{}',
219
+ created_at INTEGER NOT NULL,
220
+ updated_at INTEGER NOT NULL
221
+ );
222
+ CREATE INDEX IF NOT EXISTS idx_channels_agent ON channels(agent_id);
223
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_provider_account ON channels(provider, account_id);
224
+
225
+ CREATE TABLE IF NOT EXISTS channel_bindings (
226
+ id TEXT PRIMARY KEY,
227
+ channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
228
+ conversation_key TEXT NOT NULL DEFAULT '',
229
+ conversation_id TEXT,
230
+ target_type TEXT NOT NULL,
231
+ target_id TEXT NOT NULL,
232
+ mode TEXT NOT NULL DEFAULT 'exclusive',
233
+ priority INTEGER NOT NULL DEFAULT 0,
234
+ created_at INTEGER NOT NULL,
235
+ updated_at INTEGER NOT NULL
236
+ );
237
+ CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority);
238
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id);
239
+
202
240
  CREATE TABLE IF NOT EXISTS orchestrators (
203
241
  id TEXT PRIMARY KEY,
204
242
  hostname TEXT NOT NULL,
@@ -238,9 +276,42 @@ export function initDb(path: string = "agent-relay.db"): Database {
238
276
  db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
239
277
  db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
240
278
 
241
- if (!colNames.includes("type")) {
242
- db.run("ALTER TABLE messages ADD COLUMN type TEXT NOT NULL DEFAULT 'message'");
279
+ const channelBindingsSql = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
280
+ if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
281
+ db.transaction(() => {
282
+ db.run("ALTER TABLE channel_bindings RENAME TO channel_bindings_old");
283
+ db.run(`
284
+ CREATE TABLE channel_bindings (
285
+ id TEXT PRIMARY KEY,
286
+ channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
287
+ conversation_key TEXT NOT NULL DEFAULT '',
288
+ conversation_id TEXT,
289
+ target_type TEXT NOT NULL,
290
+ target_id TEXT NOT NULL,
291
+ mode TEXT NOT NULL DEFAULT 'exclusive',
292
+ priority INTEGER NOT NULL DEFAULT 0,
293
+ created_at INTEGER NOT NULL,
294
+ updated_at INTEGER NOT NULL
295
+ )
296
+ `);
297
+ db.run(`
298
+ INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
299
+ SELECT id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at
300
+ FROM channel_bindings_old
301
+ `);
302
+ db.run("DROP TABLE channel_bindings_old");
303
+ })();
304
+ }
305
+ db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
306
+ db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
307
+
308
+ if (!colNames.includes("kind")) {
309
+ db.run("ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'chat'");
310
+ }
311
+ if (!colNames.includes("payload")) {
312
+ db.run("ALTER TABLE messages ADD COLUMN payload TEXT NOT NULL DEFAULT '{}'");
243
313
  }
314
+ db.run("CREATE INDEX IF NOT EXISTS idx_msg_kind ON messages(kind)");
244
315
 
245
316
  // Backfill thread_id for pre-migration rows (self-threaded).
246
317
  db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
@@ -280,19 +351,33 @@ export function initDb(path: string = "agent-relay.db"): Database {
280
351
  if (!agentColNames.includes("epoch")) {
281
352
  db.run("ALTER TABLE agents ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
282
353
  }
354
+ if (!agentColNames.includes("kind")) {
355
+ db.run("ALTER TABLE agents ADD COLUMN kind TEXT NOT NULL DEFAULT 'provider'");
356
+ db.run(`
357
+ UPDATE agents
358
+ SET kind = CASE
359
+ WHEN id = 'user' THEN 'user'
360
+ WHEN id = 'system' THEN 'system'
361
+ WHEN json_extract(meta, '$.kind') = 'channel' THEN 'channel'
362
+ WHEN EXISTS (SELECT 1 FROM json_each(tags) WHERE value = 'channel') THEN 'channel'
363
+ ELSE 'provider'
364
+ END
365
+ `);
366
+ }
283
367
  db.run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
368
+ db.run("CREATE INDEX IF NOT EXISTS idx_agents_kind ON agents(kind)");
284
369
 
285
370
  // Built-in agents — registered unconditionally so sends from these ids
286
371
  // pass the sendMessage validation. The reaper exempts these by checking
287
372
  // meta.builtin (or by id for "user").
288
373
  const now = Date.now();
289
374
  const builtinStmt = db.prepare(`
290
- INSERT INTO agents (id, name, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
291
- VALUES (?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
375
+ INSERT INTO agents (id, name, kind, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
376
+ VALUES (?, ?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
292
377
  ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
293
378
  `);
294
- builtinStmt.run("user", "User", '["human"]', now, now);
295
- builtinStmt.run("system", "System", '["system"]', now, now);
379
+ builtinStmt.run("user", "User", "user", '["human"]', now, now);
380
+ builtinStmt.run("system", "System", "system", '["system"]', now, now);
296
381
 
297
382
  // One-shot migration: backfill message_reads from legacy read_by JSON
298
383
  // if that column still carries data. Safe to run repeatedly (INSERT OR IGNORE).
@@ -333,7 +418,19 @@ function stringValue(value: unknown): string | undefined {
333
418
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
334
419
  }
335
420
 
421
+ function inferAgentKind(input: Pick<RegisterAgentInput, "id" | "kind" | "tags" | "capabilities" | "meta">): AgentKind {
422
+ if (input.kind) return input.kind;
423
+ if (input.id === "user") return "user";
424
+ if (input.id === "system") return "system";
425
+ const metaKind = stringValue(input.meta?.kind);
426
+ if (metaKind === "channel" || metaKind === "communication-channel") return "channel";
427
+ if (metaKind === "orchestrator") return "orchestrator";
428
+ if ((input.tags ?? []).includes("channel") || (input.capabilities ?? []).includes("channel")) return "channel";
429
+ return "provider";
430
+ }
431
+
336
432
  function inferProviderTag(input: RegisterAgentInput): "claude" | "codex" | undefined {
433
+ if (inferAgentKind(input) !== "provider") return undefined;
337
434
  const meta = input.meta ?? {};
338
435
  const values = [
339
436
  input.id,
@@ -367,6 +464,7 @@ function rowToAgent(row: any): AgentCard {
367
464
  return {
368
465
  id: row.id,
369
466
  name: row.name,
467
+ kind: row.kind ?? "provider",
370
468
  label: row.label ?? undefined,
371
469
  tags: parseStringArray(row.tags),
372
470
  machine: row.machine ?? undefined,
@@ -387,7 +485,7 @@ function rowToMessage(row: any): Message {
387
485
  id: row.id,
388
486
  from: row.from_agent,
389
487
  to: row.to_target,
390
- type: (row.type ?? "message") as MessageType,
488
+ kind: row.kind ?? "chat",
391
489
  channel: row.channel ?? undefined,
392
490
  subject: row.subject ?? undefined,
393
491
  body: row.body,
@@ -398,6 +496,7 @@ function rowToMessage(row: any): Message {
398
496
  claimedAt: row.claimed_at ?? undefined,
399
497
  claimExpiresAt: row.claim_expires_at ?? undefined,
400
498
  idempotencyKey: row.idempotency_key ?? undefined,
499
+ payload: parseJson(row.payload ?? "{}", {}),
401
500
  meta: parseJson(row.meta, {}),
402
501
  readBy: parseJson(row.read_by_agents ?? "[]", []),
403
502
  createdAt: row.created_at,
@@ -503,6 +602,65 @@ function rowToActivityEvent(row: any): ActivityEvent {
503
602
  };
504
603
  }
505
604
 
605
+ function rowToChannelBinding(row: any): ChannelBinding {
606
+ const target = row.target_type === "broadcast"
607
+ ? { type: "broadcast" } as ChannelRouteTarget
608
+ : { type: row.target_type, id: row.target_id } as ChannelRouteTarget;
609
+ return {
610
+ id: row.id,
611
+ channelId: row.channel_id,
612
+ conversationId: row.conversation_id ?? undefined,
613
+ target,
614
+ mode: row.mode as ChannelBindingMode,
615
+ priority: row.priority,
616
+ createdAt: row.created_at,
617
+ updatedAt: row.updated_at,
618
+ };
619
+ }
620
+
621
+ function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
622
+ if (target.type === "agent") return target.id;
623
+ if (target.type === "label") return `label:${target.id}`;
624
+ if (target.type === "tag") return `tag:${target.id}`;
625
+ if (target.type === "capability") return `cap:${target.id}`;
626
+ if (target.type === "broadcast") return "broadcast";
627
+ if (target.type === "orchestrator") return `orchestrator:${target.id}`;
628
+ return "";
629
+ }
630
+
631
+ function rowToChannelSummary(row: any): ChannelSummary {
632
+ const binding = row.binding_id ? rowToChannelBinding({
633
+ id: row.binding_id,
634
+ channel_id: row.id,
635
+ conversation_id: row.binding_conversation_id,
636
+ target_type: row.binding_target_type,
637
+ target_id: row.binding_target_id,
638
+ mode: row.binding_mode,
639
+ priority: row.binding_priority,
640
+ created_at: row.binding_created_at,
641
+ updated_at: row.binding_updated_at,
642
+ }) : undefined;
643
+
644
+ return {
645
+ id: row.id,
646
+ name: row.display_name,
647
+ type: row.provider,
648
+ transport: row.transport,
649
+ agentId: row.agent_id,
650
+ accountId: row.account_id,
651
+ status: row.agent_status,
652
+ ready: row.agent_ready === 1,
653
+ direction: row.direction,
654
+ target: binding ? bindingTargetToLegacyTarget(binding.target) : undefined,
655
+ binding,
656
+ topicChannels: parseStringArray(row.topic_channels),
657
+ capabilities: parseStringArray(row.channel_capabilities),
658
+ tags: parseStringArray(row.agent_tags),
659
+ lastSeen: row.agent_last_seen,
660
+ meta: parseJson(row.channel_meta, {}),
661
+ };
662
+ }
663
+
506
664
  const MSG_SELECT = `SELECT m.*, (
507
665
  SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
508
666
  ) AS read_by_agents FROM messages m`;
@@ -511,6 +669,13 @@ const MSG_SELECT = `SELECT m.*, (
511
669
 
512
670
  export function upsertAgent(input: RegisterAgentInput): AgentCard {
513
671
  const now = Date.now();
672
+ const kind = inferAgentKind(input);
673
+ if (kind === "channel") {
674
+ const expectedId = expectedChannelAgentId(input);
675
+ if (input.id !== expectedId) {
676
+ throw new ValidationError(`channel agent id must be canonical: ${expectedId}`);
677
+ }
678
+ }
514
679
  const tags = tagsWithProvider(input);
515
680
  // Preserve the existing label across re-registrations unless the caller
516
681
  // explicitly sends one (including null to clear).
@@ -518,10 +683,11 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
518
683
  const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
519
684
  const instanceProvided = Boolean(input.instanceId);
520
685
  const stmt = db.prepare(`
521
- INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
522
- VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
686
+ INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
687
+ VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
523
688
  ON CONFLICT(id) DO UPDATE SET
524
689
  name = $name,
690
+ kind = $kind,
525
691
  label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
526
692
  tags = $tags,
527
693
  machine = coalesce($machine, agents.machine),
@@ -541,6 +707,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
541
707
  stmt.run({
542
708
  $id: input.id,
543
709
  $name: input.name,
710
+ $kind: kind,
544
711
  $label: input.label ?? null,
545
712
  $labelProvided: labelProvided ? 1 : 0,
546
713
  $tags: JSON.stringify(tags),
@@ -557,7 +724,9 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
557
724
  $now: now,
558
725
  });
559
726
 
560
- return getAgent(input.id)!;
727
+ const agent = getAgent(input.id)!;
728
+ if (agent.kind === "channel") upsertChannelForAgent(agent);
729
+ return agent;
561
730
  }
562
731
 
563
732
  export function validateAgentSession(id: string, guard?: AgentSessionGuard): { ok: boolean; error?: string } {
@@ -646,6 +815,201 @@ export function heartbeat(id: string, guard?: AgentSessionGuard): boolean {
646
815
  return result.changes > 0;
647
816
  }
648
817
 
818
+ function channelProviderForAgent(agent: AgentCard): string {
819
+ return stringValue(agent.meta?.provider) ??
820
+ stringValue(agent.meta?.channelType) ??
821
+ stringValue(agent.meta?.transport) ??
822
+ agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
823
+ "custom";
824
+ }
825
+
826
+ function channelProviderForInput(input: Pick<RegisterAgentInput, "tags" | "meta">): string {
827
+ return stringValue(input.meta?.provider) ??
828
+ stringValue(input.meta?.channelType) ??
829
+ stringValue(input.meta?.transport) ??
830
+ (input.tags ?? []).find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
831
+ "custom";
832
+ }
833
+
834
+ function channelAccountIdForAgent(agent: AgentCard, provider: string): string {
835
+ const accountId = stringValue(agent.meta?.accountId);
836
+ if (accountId) return accountId;
837
+ const prefix = `${provider}:`;
838
+ return agent.id.startsWith(prefix) ? agent.id.slice(prefix.length) : agent.id;
839
+ }
840
+
841
+ function channelAccountIdForInput(input: Pick<RegisterAgentInput, "id" | "meta">, provider: string): string {
842
+ const accountId = stringValue(input.meta?.accountId);
843
+ if (accountId) return accountId;
844
+ const prefix = `${provider}:`;
845
+ return input.id.startsWith(prefix) ? input.id.slice(prefix.length) : input.id;
846
+ }
847
+
848
+ function expectedChannelAgentId(input: Pick<RegisterAgentInput, "id" | "tags" | "meta">): string {
849
+ const provider = channelProviderForInput(input);
850
+ const accountId = channelAccountIdForInput(input, provider);
851
+ return `${provider}:${accountId}`;
852
+ }
853
+
854
+ function channelDirectionForAgent(agent: AgentCard): ChannelSummary["direction"] {
855
+ const direction = stringValue(agent.meta?.direction);
856
+ return direction === "inbound" || direction === "outbound" || direction === "bidirectional" ? direction : "bidirectional";
857
+ }
858
+
859
+ function routeTargetFromLegacyTarget(target: string | undefined): ChannelRouteTarget | undefined {
860
+ if (!target) return undefined;
861
+ if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
862
+ if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
863
+ if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
864
+ if (target === "broadcast") return { type: "broadcast" };
865
+ if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
866
+ return { type: "agent", id: target };
867
+ }
868
+
869
+ function upsertChannelForAgent(agent: AgentCard): void {
870
+ const now = Date.now();
871
+ const provider = channelProviderForAgent(agent);
872
+ const accountId = channelAccountIdForAgent(agent, provider);
873
+ const channelId = `${provider}:${accountId}`;
874
+ const transport = stringValue(agent.meta?.transport) ?? provider;
875
+ const displayName = stringValue(agent.meta?.displayName) ?? agent.name;
876
+ const topicChannels = Array.isArray(agent.meta?.topicChannels)
877
+ ? (agent.meta.topicChannels as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)
878
+ : [];
879
+
880
+ db.prepare(`
881
+ INSERT INTO channels (id, provider, account_id, display_name, agent_id, transport, direction, topic_channels, capabilities, meta, created_at, updated_at)
882
+ VALUES ($id, $provider, $accountId, $displayName, $agentId, $transport, $direction, $topicChannels, $capabilities, $meta, $now, $now)
883
+ ON CONFLICT(provider, account_id) DO UPDATE SET
884
+ id = $id,
885
+ provider = $provider,
886
+ account_id = $accountId,
887
+ display_name = $displayName,
888
+ agent_id = $agentId,
889
+ transport = $transport,
890
+ direction = $direction,
891
+ topic_channels = $topicChannels,
892
+ capabilities = $capabilities,
893
+ meta = $meta,
894
+ updated_at = $now
895
+ `).run({
896
+ $id: channelId,
897
+ $provider: provider,
898
+ $accountId: accountId,
899
+ $displayName: displayName,
900
+ $agentId: agent.id,
901
+ $transport: transport,
902
+ $direction: channelDirectionForAgent(agent),
903
+ $topicChannels: JSON.stringify(topicChannels),
904
+ $capabilities: JSON.stringify(agent.capabilities),
905
+ $meta: JSON.stringify(agent.meta ?? {}),
906
+ $now: now,
907
+ });
908
+
909
+ const defaultTarget = routeTargetFromLegacyTarget(stringValue(agent.meta?.target));
910
+ if (defaultTarget) {
911
+ upsertChannelBinding({
912
+ channelId,
913
+ target: defaultTarget,
914
+ mode: defaultTarget.type === "agent" ? "exclusive" : defaultTarget.type === "broadcast" ? "broadcast" : "claimable",
915
+ });
916
+ }
917
+ }
918
+
919
+ export function listChannels(): ChannelSummary[] {
920
+ const rows = db.prepare(`
921
+ SELECT
922
+ c.*,
923
+ c.capabilities AS channel_capabilities,
924
+ c.meta AS channel_meta,
925
+ a.status AS agent_status,
926
+ a.ready AS agent_ready,
927
+ a.tags AS agent_tags,
928
+ a.last_seen AS agent_last_seen,
929
+ b.id AS binding_id,
930
+ b.conversation_id AS binding_conversation_id,
931
+ b.target_type AS binding_target_type,
932
+ b.target_id AS binding_target_id,
933
+ b.mode AS binding_mode,
934
+ b.priority AS binding_priority,
935
+ b.created_at AS binding_created_at,
936
+ b.updated_at AS binding_updated_at
937
+ FROM channels c
938
+ JOIN agents a ON a.id = c.agent_id
939
+ LEFT JOIN channel_bindings b ON b.channel_id = c.id AND b.conversation_key = ''
940
+ ORDER BY a.ready DESC, c.display_name COLLATE NOCASE
941
+ `).all() as any[];
942
+ return rows.map(rowToChannelSummary);
943
+ }
944
+
945
+ export function getChannel(channelId: string): ChannelSummary | null {
946
+ return listChannels().find((channel) => channel.id === channelId) ?? null;
947
+ }
948
+
949
+ export function listChannelBindings(channelId?: string): ChannelBinding[] {
950
+ const rows = channelId
951
+ ? db.prepare("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
952
+ : db.prepare("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
953
+ return rows.map(rowToChannelBinding);
954
+ }
955
+
956
+ export function upsertChannelBinding(input: {
957
+ channelId: string;
958
+ conversationId?: string;
959
+ target: ChannelRouteTarget;
960
+ mode?: ChannelBindingMode;
961
+ priority?: number;
962
+ }): ChannelBinding {
963
+ if (!getChannel(input.channelId)) throw new ValidationError(`channel ${input.channelId} not found`);
964
+ const conversationKey = input.conversationId ?? "";
965
+ const targetId = input.target.type === "broadcast" ? "" : input.target.id;
966
+ const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
967
+ const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
968
+ const now = Date.now();
969
+ db.prepare(`
970
+ INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
971
+ VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
972
+ ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
973
+ mode = $mode,
974
+ priority = $priority,
975
+ updated_at = $now
976
+ `).run({
977
+ $id: id,
978
+ $channelId: input.channelId,
979
+ $conversationKey: conversationKey,
980
+ $conversationId: input.conversationId ?? null,
981
+ $targetType: input.target.type,
982
+ $targetId: targetId,
983
+ $mode: input.mode ?? "exclusive",
984
+ $priority: input.priority ?? 0,
985
+ $now: now,
986
+ });
987
+
988
+ const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
989
+ return rowToChannelBinding(row);
990
+ }
991
+
992
+ export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
993
+ const rows = conversationId
994
+ ? db.prepare(`
995
+ SELECT * FROM channel_bindings
996
+ WHERE channel_id = ? AND conversation_key IN (?, '')
997
+ ORDER BY CASE WHEN conversation_key = ? THEN 1 ELSE 0 END DESC, priority DESC, updated_at DESC
998
+ `).all(channelId, conversationId, conversationId) as any[]
999
+ : db.prepare(`
1000
+ SELECT * FROM channel_bindings
1001
+ WHERE channel_id = ? AND conversation_key = ''
1002
+ ORDER BY priority DESC, updated_at DESC
1003
+ `).all(channelId) as any[];
1004
+ if (!conversationId) return rows.map(rowToChannelBinding);
1005
+ const exact = rows.filter((row) => row.conversation_key === conversationId);
1006
+ return (exact.length ? exact : rows.filter((row) => row.conversation_key === "")).map(rowToChannelBinding);
1007
+ }
1008
+
1009
+ export function resolveChannelRoute(channelId: string, conversationId?: string): ChannelBinding | null {
1010
+ return resolveChannelRoutes(channelId, conversationId)[0] ?? null;
1011
+ }
1012
+
649
1013
  export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
650
1014
  const now = Date.now();
651
1015
  const cutoff = now - ttlMs;
@@ -833,14 +1197,16 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
833
1197
  message = sendMessage({
834
1198
  from: "system",
835
1199
  to: task.target,
836
- type: "system",
1200
+ kind: "task",
837
1201
  channel: task.channel,
838
1202
  subject: `[${task.severity}] ${task.title}`,
839
1203
  body: taskMessageBody(task),
840
1204
  claimable: true,
841
- meta: {
1205
+ payload: {
842
1206
  taskId: task.id,
843
1207
  source: task.source,
1208
+ title: task.title,
1209
+ status: task.status,
844
1210
  severity: task.severity,
845
1211
  dedupeKey: task.dedupeKey ?? null,
846
1212
  externalUrl: task.externalUrl ?? null,
@@ -1175,10 +1541,10 @@ function pairSystemMessage(pair: PairSession, to: string, event: string, subject
1175
1541
  return sendMessage({
1176
1542
  from: "system",
1177
1543
  to,
1178
- type: "system",
1544
+ kind: "pair",
1179
1545
  subject,
1180
1546
  body,
1181
- meta: {
1547
+ payload: {
1182
1548
  pairId: pair.id,
1183
1549
  pairEvent: event,
1184
1550
  requesterId: pair.requesterId,
@@ -1326,9 +1692,10 @@ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true
1326
1692
  const message = sendMessage({
1327
1693
  from: input.from,
1328
1694
  to,
1695
+ kind: "pair",
1329
1696
  subject: input.subject ?? `Pair ${pair.id}`,
1330
1697
  body: input.body,
1331
- meta: {
1698
+ payload: {
1332
1699
  pairId: pair.id,
1333
1700
  pairEvent: "message",
1334
1701
  requesterId: pair.requesterId,
@@ -1352,9 +1719,20 @@ function isDeliveryAgent(agent: AgentCard): boolean {
1352
1719
  return agent.status !== "offline" &&
1353
1720
  agent.id !== "user" &&
1354
1721
  agent.id !== "system" &&
1722
+ agent.kind !== "channel" &&
1355
1723
  agent.meta?.kind !== "channel";
1356
1724
  }
1357
1725
 
1726
+ function isChannelAgentId(agentId: string): boolean {
1727
+ const agent = getAgent(agentId);
1728
+ return Boolean(agent && (
1729
+ agent.kind === "channel" ||
1730
+ agent.meta?.kind === "channel" ||
1731
+ agent.tags.includes("channel") ||
1732
+ agent.capabilities.includes("channel")
1733
+ ));
1734
+ }
1735
+
1358
1736
  function matchingDeliveryAgents(target: string): AgentCard[] {
1359
1737
  if (!target) return [];
1360
1738
  const candidates = listAgents().filter(isDeliveryAgent);
@@ -1382,10 +1760,18 @@ function claimableAllowedForTarget(target: string): boolean {
1382
1760
 
1383
1761
  function shouldStoreClaimable(input: SendMessageInput): boolean {
1384
1762
  if (!input.claimable) return false;
1385
- if (input.type === "system" || typeof input.meta?.taskId === "number") return true;
1763
+ if (input.kind === "task" || input.kind === "system") return true;
1764
+ if (input.kind === "channel.event") return true;
1386
1765
  return claimableAllowedForTarget(input.to);
1387
1766
  }
1388
1767
 
1768
+ function inferMessageKind(input: SendMessageInput): Message["kind"] {
1769
+ if (input.kind) return input.kind;
1770
+ if (input.claimable) return "task";
1771
+ if (isChannelAgentId(input.from) || isChannelAgentId(input.to)) return "channel.event";
1772
+ return "chat";
1773
+ }
1774
+
1389
1775
  export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
1390
1776
  const now = Date.now();
1391
1777
 
@@ -1408,17 +1794,18 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
1408
1794
  }
1409
1795
 
1410
1796
  const insert = db.prepare(`
1411
- INSERT INTO messages (from_agent, to_target, type, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, meta, created_at)
1412
- VALUES ($from, $to, $type, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $meta, $now)
1797
+ INSERT INTO messages (from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, payload, meta, created_at)
1798
+ VALUES ($from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $payload, $meta, $now)
1413
1799
  `);
1414
1800
  const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
1415
1801
  const claimable = shouldStoreClaimable(input);
1802
+ const kind = inferMessageKind(input);
1416
1803
 
1417
1804
  const id = db.transaction(() => {
1418
1805
  const result = insert.run({
1419
1806
  $from: input.from,
1420
1807
  $to: input.to,
1421
- $type: input.type ?? "message",
1808
+ $kind: kind,
1422
1809
  $channel: input.channel ?? null,
1423
1810
  $subject: input.subject ?? null,
1424
1811
  $body: input.body,
@@ -1426,6 +1813,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
1426
1813
  $replyTo: input.replyTo ?? null,
1427
1814
  $claimable: claimable ? 1 : 0,
1428
1815
  $idempotencyKey: input.idempotencyKey ?? null,
1816
+ $payload: JSON.stringify(input.payload ?? {}),
1429
1817
  $meta: JSON.stringify(input.meta ?? {}),
1430
1818
  $now: now,
1431
1819
  });
@@ -1483,8 +1871,8 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
1483
1871
  const messageClaim = claimMessageRow(messageId, agentId, now);
1484
1872
  if (!messageClaim.ok) return messageClaim;
1485
1873
 
1486
- const taskId = typeof msg.meta?.taskId === "number" && Number.isSafeInteger(msg.meta.taskId)
1487
- ? msg.meta.taskId
1874
+ const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
1875
+ ? msg.payload.taskId
1488
1876
  : null;
1489
1877
  if (!taskId) return { ok: true };
1490
1878
 
@@ -1534,8 +1922,8 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
1534
1922
  let task: Task | undefined;
1535
1923
  db.transaction(() => {
1536
1924
  db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
1537
- const taskId = typeof msg.meta?.taskId === "number" && Number.isSafeInteger(msg.meta.taskId)
1538
- ? msg.meta.taskId
1925
+ const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
1926
+ ? msg.payload.taskId
1539
1927
  : null;
1540
1928
  if (taskId) {
1541
1929
  db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);