agent-relay 8.7.2 → 8.8.1

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.
Files changed (47) hide show
  1. package/dist/auto/classifier.d.ts +29 -0
  2. package/dist/auto/classifier.d.ts.map +1 -0
  3. package/dist/auto/classifier.js +126 -0
  4. package/dist/auto/classifier.js.map +1 -0
  5. package/dist/auto/composer.d.ts +105 -0
  6. package/dist/auto/composer.d.ts.map +1 -0
  7. package/dist/auto/composer.js +454 -0
  8. package/dist/auto/composer.js.map +1 -0
  9. package/dist/auto/director-prompt.d.ts +25 -0
  10. package/dist/auto/director-prompt.d.ts.map +1 -0
  11. package/dist/auto/director-prompt.js +53 -0
  12. package/dist/auto/director-prompt.js.map +1 -0
  13. package/dist/auto/index.d.ts +15 -0
  14. package/dist/auto/index.d.ts.map +1 -0
  15. package/dist/auto/index.js +13 -0
  16. package/dist/auto/index.js.map +1 -0
  17. package/dist/cli/agent-relay-mcp.d.ts +1 -1
  18. package/dist/cli/agent-relay-mcp.d.ts.map +1 -1
  19. package/dist/cli/agent-relay-mcp.js +670 -610
  20. package/dist/cli/agent-relay-mcp.js.map +1 -1
  21. package/dist/cli/bootstrap.d.ts.map +1 -1
  22. package/dist/cli/bootstrap.js +2 -0
  23. package/dist/cli/bootstrap.js.map +1 -1
  24. package/dist/cli/commands/core.d.ts +1 -0
  25. package/dist/cli/commands/core.d.ts.map +1 -1
  26. package/dist/cli/commands/core.js +1 -1
  27. package/dist/cli/commands/core.js.map +1 -1
  28. package/dist/cli/commands/fleet.d.ts +16 -0
  29. package/dist/cli/commands/fleet.d.ts.map +1 -0
  30. package/dist/cli/commands/fleet.js +188 -0
  31. package/dist/cli/commands/fleet.js.map +1 -0
  32. package/dist/cli/commands/local-agent.d.ts.map +1 -1
  33. package/dist/cli/commands/local-agent.js +71 -13
  34. package/dist/cli/commands/local-agent.js.map +1 -1
  35. package/dist/cli/lib/broker-lifecycle.d.ts +5 -1
  36. package/dist/cli/lib/broker-lifecycle.d.ts.map +1 -1
  37. package/dist/cli/lib/broker-lifecycle.js +32 -2
  38. package/dist/cli/lib/broker-lifecycle.js.map +1 -1
  39. package/dist/cli/lib/client-factory.d.ts +2 -0
  40. package/dist/cli/lib/client-factory.d.ts.map +1 -1
  41. package/dist/cli/lib/client-factory.js.map +1 -1
  42. package/dist/cli/lib/fleet-sidecar.d.ts +53 -0
  43. package/dist/cli/lib/fleet-sidecar.d.ts.map +1 -0
  44. package/dist/cli/lib/fleet-sidecar.js +400 -0
  45. package/dist/cli/lib/fleet-sidecar.js.map +1 -0
  46. package/dist/index.cjs +2545 -16773
  47. package/package.json +10 -8
@@ -7,6 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { ListToolsRequestSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { RelayCast, SDK_VERSION, WsClient } from '@relaycast/sdk';
9
9
  import { INVALID_AGENT_TOKEN_CODE, agentTokenRecoveryMessage, isInvalidAgentTokenError, isInvalidAgentTokenToolResult, } from '@agent-relay/sdk';
10
+ import { AgentRelay } from '@agent-relay/sdk';
10
11
  import { z } from 'zod';
11
12
  import { initTelemetry, shutdown as shutdownTelemetry, track, } from './telemetry/index.js';
12
13
  import { relaycastWorkspaceTelemetryOptions, withRelaycastTelemetry } from './lib/relaycast-telemetry.js';
@@ -14,6 +15,11 @@ import { errorClassName } from './lib/telemetry-helpers.js';
14
15
  const DEFAULT_BASE_URL = 'https://gateway.relaycast.dev';
15
16
  export const AGENT_RELAY_MCP_VERSION = process.env.AGENT_RELAY_CLI_VERSION ?? SDK_VERSION ?? 'unknown';
16
17
  let mcpTelemetryExitHookInstalled = false;
18
+ const EXIT_AFTER_TASK_INSTRUCTION = '## Post-task exit\n' +
19
+ 'When the requested task is fully complete and you have reported the final outcome, output `/exit` on its own line so the Agent Relay harness exits cleanly. Do not output `/exit` before the task is complete.';
20
+ function withExitAfterTaskInstruction(task) {
21
+ return `${task}\n\n${EXIT_AFTER_TASK_INSTRUCTION}`;
22
+ }
17
23
  const DEFAULT_SYSTEM_PROMPT = `You are an AI agent in a collaborative workspace powered by Agent Relay. You can communicate with other agents using these MCP tools:
18
24
 
19
25
  ## Getting Started
@@ -30,6 +36,10 @@ const DEFAULT_SYSTEM_PROMPT = `You are an AI agent in a collaborative workspace
30
36
  - Reply to threads with "reply_to_thread"
31
37
  - React to messages with "add_reaction"
32
38
 
39
+ ## Fleet
40
+ - Use "query_nodes" to find fleet nodes by capability or name
41
+ - Use "spawn" to invoke the fleet spawn action on an eligible node
42
+
33
43
  ## Best Practices
34
44
  - Check your inbox regularly for new messages and mentions
35
45
  - Use channels for topic-based discussions
@@ -207,412 +217,95 @@ function createInitialSession(options) {
207
217
  wsInitAttempted: false,
208
218
  };
209
219
  }
210
- async function createWorkspace(name, baseUrl) {
211
- return (await RelayCast.createWorkspace(name, {
212
- baseUrl,
213
- ...relaycastWorkspaceTelemetryOptions(),
214
- }));
215
- }
216
- function extractWorkspaceKey(payload) {
217
- const data = payload.data && typeof payload.data === 'object' ? payload.data : {};
218
- const value = payload.workspaceKey ??
219
- payload.workspace_key ??
220
- payload.apiKey ??
221
- payload.api_key ??
222
- data.workspaceKey ??
223
- data.workspace_key ??
224
- data.apiKey ??
225
- data.api_key;
226
- return typeof value === 'string' && value.trim() ? value : undefined;
227
- }
228
- function extractWorkspaceName(payload, fallback) {
229
- const data = payload.data && typeof payload.data === 'object' ? payload.data : {};
230
- const value = payload.workspaceName ?? payload.workspace_name ?? payload.name ?? data.workspaceName;
231
- return typeof value === 'string' && value.trim() ? value : fallback;
232
- }
233
- function requireWorkspaceKey(session) {
234
- if (session.workspaceKey) {
235
- return;
236
- }
237
- throw new Error('Workspace key not configured. Call "create_workspace" first, or "set_workspace_key" if someone shared a workspace key.');
238
- }
239
- function jsonContent(value) {
240
- const structuredContent = typeof value === 'object' && value !== null && !Array.isArray(value)
241
- ? value
242
- : { value };
243
- return {
244
- content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
245
- structuredContent,
246
- };
247
- }
248
- function textContent(message, structuredContent = { message }) {
249
- return {
250
- content: [{ type: 'text', text: message }],
251
- structuredContent,
252
- };
253
- }
254
- function isSchemaObject(schema) {
255
- return Boolean(schema &&
256
- typeof schema === 'object' &&
257
- !Array.isArray(schema) &&
258
- typeof schema.safeParse !== 'function');
259
- }
260
- function getSchemaDescription(schema) {
261
- return isSchemaObject(schema) && typeof schema.description === 'string' ? schema.description : undefined;
262
- }
263
- function zodFromJsonSchema(schema) {
264
- if (schema === false) {
265
- return z.never();
266
- }
267
- if (!isSchemaObject(schema)) {
268
- return z.unknown();
269
- }
270
- let zodType;
271
- const schemaType = Array.isArray(schema.type) ? schema.type[0] : schema.type;
272
- switch (schemaType) {
273
- case 'array':
274
- zodType = z.array(zodFromJsonSchema(schema.items));
275
- break;
276
- case 'boolean':
277
- zodType = z.boolean();
278
- break;
279
- case 'integer':
280
- zodType = z.number().int();
281
- break;
282
- case 'number':
283
- zodType = z.number();
284
- break;
285
- case 'object':
286
- if (schema.properties) {
287
- const required = new Set(schema.required ?? []);
288
- const shape = {};
289
- for (const [key, childSchema] of Object.entries(schema.properties)) {
290
- const child = zodFromJsonSchema(childSchema);
291
- shape[key] = required.has(key) ? child : child.optional();
292
- }
293
- zodType = z.object(shape).passthrough();
294
- }
295
- else {
296
- zodType = z.record(z.string(), z.unknown());
297
- }
298
- break;
299
- case 'string':
300
- zodType = z.string();
301
- break;
302
- default:
303
- zodType = z.unknown();
304
- break;
305
- }
306
- const description = getSchemaDescription(schema);
307
- return description ? zodType.describe(description) : zodType;
308
- }
309
- function actionToolInputSchema(schema) {
310
- const zodShape = zodObjectShape(schema);
311
- if (zodShape) {
312
- return zodShape;
220
+ class SubscriptionManager {
221
+ subscriptions = new Set();
222
+ subscribe(uri) {
223
+ this.subscriptions.add(uri);
313
224
  }
314
- if (!isSchemaObject(schema) || schema.type !== 'object') {
315
- return {
316
- input: z.unknown().describe('Action input payload. The action registry performs final validation.'),
317
- };
225
+ unsubscribe(uri) {
226
+ this.subscriptions.delete(uri);
318
227
  }
319
- const required = new Set(schema.required ?? []);
320
- const shape = {};
321
- for (const [key, childSchema] of Object.entries(schema.properties ?? {})) {
322
- const child = zodFromJsonSchema(childSchema);
323
- shape[key] = required.has(key) ? child : child.optional();
228
+ getMatchingSubscriptions(uris) {
229
+ return uris.filter((uri) => this.subscriptions.has(uri));
324
230
  }
325
- return shape;
326
- }
327
- function actionInvocationInput(descriptor, args) {
328
- const schema = descriptor.inputSchema;
329
- if (zodObjectShape(schema)) {
330
- return args;
231
+ getAll() {
232
+ return [...this.subscriptions];
331
233
  }
332
- if (!isSchemaObject(schema) || schema.type !== 'object') {
333
- return typeof args === 'object' && args !== null && 'input' in args
334
- ? args.input
335
- : args;
234
+ clear() {
235
+ this.subscriptions.clear();
336
236
  }
337
- return args;
338
237
  }
339
- function zodObjectShape(schema) {
340
- if (schema instanceof z.ZodObject) {
341
- return schema.shape;
238
+ function getStringEventField(event, field) {
239
+ if (typeof event !== 'object' || event === null) {
240
+ return null;
342
241
  }
343
- return undefined;
344
- }
345
- function serializableActionDescriptor(descriptor) {
346
- return {
347
- name: descriptor.name,
348
- description: descriptor.description,
349
- visibility: descriptor.visibility,
350
- ...(descriptor.inputSchema ? { inputSchema: serializableActionSchema(descriptor.inputSchema) } : {}),
351
- ...(descriptor.outputSchema ? { outputSchema: serializableActionSchema(descriptor.outputSchema) } : {}),
352
- };
242
+ const candidate = event[field];
243
+ return typeof candidate === 'string' ? candidate : null;
353
244
  }
354
- function serializableActionSchema(schema) {
355
- if (isSchemaObject(schema)) {
356
- return schema;
357
- }
358
- if (isZodLikeSchema(schema)) {
359
- return {
360
- type: 'zod',
361
- ...(schema.description ? { description: schema.description } : {}),
362
- };
245
+ function eventToResourceUris(event) {
246
+ const type = getStringEventField(event, 'type');
247
+ switch (type) {
248
+ case 'message.created': {
249
+ const channel = getStringEventField(event, 'channel');
250
+ return channel ? ['relay://inbox', `relay://channels/${channel}/messages`] : ['relay://inbox'];
251
+ }
252
+ case 'message.updated': {
253
+ const channel = getStringEventField(event, 'channel');
254
+ return channel ? [`relay://channels/${channel}/messages`] : [];
255
+ }
256
+ case 'thread.reply': {
257
+ const parentId = getStringEventField(event, 'parentId');
258
+ return parentId ? ['relay://inbox', `relay://messages/${parentId}/thread`] : ['relay://inbox'];
259
+ }
260
+ case 'dm.received':
261
+ case 'group_dm.received': {
262
+ const conversationId = getStringEventField(event, 'conversationId');
263
+ return conversationId ? ['relay://inbox', `relay://dm/${conversationId}`] : ['relay://inbox'];
264
+ }
265
+ case 'agent.online':
266
+ case 'agent.offline':
267
+ return ['relay://agents'];
268
+ case 'channel.created':
269
+ case 'channel.updated':
270
+ case 'channel.archived':
271
+ case 'member.joined':
272
+ case 'member.left':
273
+ return ['relay://channels'];
274
+ case 'webhook.received':
275
+ case 'command.invoked': {
276
+ const channel = getStringEventField(event, 'channel');
277
+ return channel ? [`relay://channels/${channel}/messages`] : [];
278
+ }
279
+ case 'reaction.added':
280
+ case 'reaction.removed':
281
+ return ['relay://inbox'];
282
+ default:
283
+ return [];
363
284
  }
364
- return schema;
365
- }
366
- function isZodLikeSchema(schema) {
367
- return Boolean(schema &&
368
- typeof schema === 'object' &&
369
- !Array.isArray(schema) &&
370
- typeof schema.safeParse === 'function');
371
285
  }
372
- function registerAgentRelayActionTools(server, actions, getSession, onAuditEvent, getAgentClient, actionToolNames) {
373
- if (!actions) {
374
- return;
286
+ class RealtimeResourceBridge {
287
+ wsClient;
288
+ subscriptions;
289
+ notifyCallback;
290
+ unsubscribeFn = null;
291
+ constructor(wsClient, subscriptions, notifyCallback) {
292
+ this.wsClient = wsClient;
293
+ this.subscriptions = subscriptions;
294
+ this.notifyCallback = notifyCallback;
375
295
  }
376
- /**
377
- * Fire-and-forget invocation through the relay: returns an immediate ack
378
- * (with an `invocation_id`) and does NOT run the handler inline. Falls back to
379
- * the local in-process registry when the relay action surface is unavailable.
380
- */
381
- const invokeAction = async (name, input) => {
382
- const relayActions = getRelayAgentActions(getAgentClient);
383
- if (relayActions) {
384
- try {
385
- const ack = await relayActions.invoke(name, asInputRecord(input));
386
- return jsonContent({ ok: true, status: 'invoked', invocation: ack });
296
+ start() {
297
+ this.unsubscribeFn = this.wsClient.on('*', (event) => {
298
+ const type = getStringEventField(event, 'type');
299
+ if (type === 'open' ||
300
+ type === 'close' ||
301
+ type === 'error' ||
302
+ type === 'reconnecting' ||
303
+ type === 'permanently_disconnected') {
304
+ return;
387
305
  }
388
- catch (error) {
389
- return { ...jsonContent({ ok: false, error: errorMessage(error) }), isError: true };
390
- }
391
- }
392
- const session = getSession();
393
- const result = await actions.invoke({
394
- name,
395
- input,
396
- context: {
397
- caller: { name: session.agentName ?? 'mcp', type: 'agent' },
398
- emit: onAuditEvent,
399
- },
400
- });
401
- return result.ok ? jsonContent(result) : { ...jsonContent(result), isError: true };
402
- };
403
- server.registerTool('list_actions', {
404
- title: 'List Actions',
405
- description: 'List Agent Relay actions available to this agent.',
406
- inputSchema: {},
407
- outputSchema: jsonResult,
408
- annotations: {
409
- readOnlyHint: true,
410
- destructiveHint: false,
411
- idempotentHint: true,
412
- openWorldHint: false,
413
- },
414
- }, async () => jsonContent({
415
- actions: (await actions.list({ visibility: 'agent' })).map(serializableActionDescriptor),
416
- }));
417
- server.registerTool('invoke_action', {
418
- title: 'Invoke Action',
419
- description: 'Invoke a registered Agent Relay action by name. Fire-and-forget: returns an ack with an invocation id; the result arrives asynchronously to the action handler.',
420
- inputSchema: {
421
- name: z.string().describe('Registered action name'),
422
- input: z.unknown().describe('Action input payload'),
423
- },
424
- outputSchema: jsonResult,
425
- annotations: {
426
- readOnlyHint: false,
427
- destructiveHint: false,
428
- idempotentHint: false,
429
- openWorldHint: false,
430
- },
431
- }, async ({ name, input }) => invokeAction(name, input));
432
- void actions
433
- .list({ visibility: 'agent' })
434
- .then((descriptors) => {
435
- for (const descriptor of descriptors) {
436
- actionToolNames?.add(descriptor.name);
437
- server.registerTool(descriptor.name, {
438
- title: descriptor.name,
439
- description: descriptor.description,
440
- inputSchema: actionToolInputSchema(descriptor.inputSchema),
441
- outputSchema: jsonResult,
442
- annotations: {
443
- readOnlyHint: false,
444
- destructiveHint: false,
445
- idempotentHint: false,
446
- openWorldHint: false,
447
- },
448
- }, async (args) => invokeAction(descriptor.name, actionInvocationInput(descriptor, args)));
449
- }
450
- })
451
- .catch(() => undefined);
452
- }
453
- /** The relay-backed action surface on the live agent client, when available. */
454
- function getRelayAgentActions(getAgentClient) {
455
- if (!getAgentClient) {
456
- return undefined;
457
- }
458
- try {
459
- return getAgentClient().actions;
460
- }
461
- catch {
462
- return undefined;
463
- }
464
- }
465
- function asInputRecord(input) {
466
- if (input === undefined || input === null) {
467
- return undefined;
468
- }
469
- if (typeof input === 'object' && !Array.isArray(input)) {
470
- return input;
471
- }
472
- return { input };
473
- }
474
- function errorMessage(error) {
475
- return error instanceof Error ? error.message : String(error);
476
- }
477
- function createRegisteredAgent(agentName, agentToken) {
478
- return { agentName, agentToken };
479
- }
480
- export async function registerAgentWithRebind({ session, setSession, getRelay, name, type, persona, metadata, strictAgentName, preferredAgentName, forcedAgentType, }) {
481
- requireWorkspaceKey(session);
482
- const configuredName = session.agentName ?? preferredAgentName?.trim() ?? null;
483
- const warnings = [];
484
- const effectiveName = strictAgentName && configuredName ? configuredName : name;
485
- if (strictAgentName && configuredName && name.trim() !== configuredName) {
486
- warnings.push(`Strict worker identity is enabled; ignoring requested name "${name}" and using "${configuredName}".`);
487
- }
488
- const effectiveType = forcedAgentType ?? type;
489
- if (forcedAgentType && type && type !== forcedAgentType) {
490
- warnings.push(`Forced worker type is enabled; ignoring requested type "${type}" and using "${forcedAgentType}".`);
491
- }
492
- if (session.agentToken && effectiveName && strictAgentName) {
493
- // If the session tracks per-identity agents, only short-circuit when the
494
- // strict-named identity is still registered. After an `agent_token_invalid`
495
- // recovery the entry is dropped from the map, which lets this fall through
496
- // to a fresh registerOrRotate instead of handing back the dead token.
497
- const cachedAgent = session.agents?.get(effectiveName);
498
- const knowsIdentities = session.agents !== undefined;
499
- if (!knowsIdentities || cachedAgent) {
500
- return {
501
- name: effectiveName,
502
- token: cachedAgent?.agentToken ?? session.agentToken,
503
- registered_name: effectiveName,
504
- warnings,
505
- };
506
- }
507
- }
508
- const relay = getRelay();
509
- const result = await relay.agents.registerOrRotate({
510
- name: effectiveName,
511
- type: effectiveType,
512
- persona,
513
- metadata,
514
- });
515
- const reboundName = result.name?.trim() ? result.name : effectiveName;
516
- setSession({ agentToken: result.token, agentName: reboundName });
517
- return {
518
- ...result,
519
- registered_name: reboundName,
520
- warnings,
521
- };
522
- }
523
- class SubscriptionManager {
524
- subscriptions = new Set();
525
- subscribe(uri) {
526
- this.subscriptions.add(uri);
527
- }
528
- unsubscribe(uri) {
529
- this.subscriptions.delete(uri);
530
- }
531
- getMatchingSubscriptions(uris) {
532
- return uris.filter((uri) => this.subscriptions.has(uri));
533
- }
534
- getAll() {
535
- return [...this.subscriptions];
536
- }
537
- clear() {
538
- this.subscriptions.clear();
539
- }
540
- }
541
- function getStringEventField(event, field) {
542
- if (typeof event !== 'object' || event === null) {
543
- return null;
544
- }
545
- const candidate = event[field];
546
- return typeof candidate === 'string' ? candidate : null;
547
- }
548
- function eventToResourceUris(event) {
549
- const type = getStringEventField(event, 'type');
550
- switch (type) {
551
- case 'message.created': {
552
- const channel = getStringEventField(event, 'channel');
553
- return channel ? ['relay://inbox', `relay://channels/${channel}/messages`] : ['relay://inbox'];
554
- }
555
- case 'message.updated': {
556
- const channel = getStringEventField(event, 'channel');
557
- return channel ? [`relay://channels/${channel}/messages`] : [];
558
- }
559
- case 'thread.reply': {
560
- const parentId = getStringEventField(event, 'parentId');
561
- return parentId ? ['relay://inbox', `relay://messages/${parentId}/thread`] : ['relay://inbox'];
562
- }
563
- case 'dm.received':
564
- case 'group_dm.received': {
565
- const conversationId = getStringEventField(event, 'conversationId');
566
- return conversationId ? ['relay://inbox', `relay://dm/${conversationId}`] : ['relay://inbox'];
567
- }
568
- case 'agent.online':
569
- case 'agent.offline':
570
- return ['relay://agents'];
571
- case 'channel.created':
572
- case 'channel.updated':
573
- case 'channel.archived':
574
- case 'member.joined':
575
- case 'member.left':
576
- return ['relay://channels'];
577
- case 'webhook.received': {
578
- const channel = getStringEventField(event, 'channel');
579
- return channel ? [`relay://channels/${channel}/messages`] : [];
580
- }
581
- case 'action.invoked':
582
- case 'action.completed':
583
- case 'action.failed':
584
- // Actions are not channel-scoped; surface invocations via the inbox.
585
- return ['relay://inbox'];
586
- case 'reaction.added':
587
- case 'reaction.removed':
588
- return ['relay://inbox'];
589
- default:
590
- return [];
591
- }
592
- }
593
- class RealtimeResourceBridge {
594
- wsClient;
595
- subscriptions;
596
- notifyCallback;
597
- unsubscribeFn = null;
598
- constructor(wsClient, subscriptions, notifyCallback) {
599
- this.wsClient = wsClient;
600
- this.subscriptions = subscriptions;
601
- this.notifyCallback = notifyCallback;
602
- }
603
- start() {
604
- this.unsubscribeFn = this.wsClient.on('*', (event) => {
605
- const type = getStringEventField(event, 'type');
606
- if (type === 'open' ||
607
- type === 'close' ||
608
- type === 'error' ||
609
- type === 'reconnecting' ||
610
- type === 'permanently_disconnected') {
611
- return;
612
- }
613
- const matched = this.subscriptions.getMatchingSubscriptions(eventToResourceUris(event));
614
- for (const uri of matched) {
615
- this.notifyCallback(uri);
306
+ const matched = this.subscriptions.getMatchingSubscriptions(eventToResourceUris(event));
307
+ for (const uri of matched) {
308
+ this.notifyCallback(uri);
616
309
  }
617
310
  });
618
311
  this.wsClient.connect();
@@ -692,217 +385,506 @@ function formatInbox(inbox, selfName) {
692
385
  lines.push(` From ${dm.from}: ${dm.unreadCount} unread`);
693
386
  }
694
387
  }
695
- const reactions = selfNorm
696
- ? inbox.recentReactions?.filter((reaction) => !isSelf(reaction.agentName))
697
- : inbox.recentReactions;
698
- if (reactions?.length) {
699
- lines.push('Reactions (informational; no response required):');
700
- for (const reaction of reactions) {
701
- lines.push(` :${reaction.emoji}: on your message in #${reaction.channelName} by @${reaction.agentName}`);
702
- }
388
+ const reactions = selfNorm
389
+ ? inbox.recentReactions?.filter((reaction) => !isSelf(reaction.agentName))
390
+ : inbox.recentReactions;
391
+ if (reactions?.length) {
392
+ lines.push('Reactions (informational; no response required):');
393
+ for (const reaction of reactions) {
394
+ lines.push(` :${reaction.emoji}: on your message in #${reaction.channelName} by @${reaction.agentName}`);
395
+ }
396
+ }
397
+ return lines.length === 1 ? '' : lines.join('\n');
398
+ }
399
+ function readAsIdentity(args) {
400
+ const [input] = args;
401
+ if (typeof input !== 'object' || input === null)
402
+ return undefined;
403
+ const as = input.as;
404
+ return typeof as === 'string' ? as : undefined;
405
+ }
406
+ function invalidAgentTokenToolResult() {
407
+ const text = agentTokenRecoveryMessage();
408
+ return {
409
+ content: [{ type: 'text', text }],
410
+ structuredContent: {
411
+ error: { code: INVALID_AGENT_TOKEN_CODE, message: text },
412
+ },
413
+ isError: true,
414
+ };
415
+ }
416
+ function isErrorToolResult(value) {
417
+ return Boolean(value && typeof value === 'object' && value.isError === true);
418
+ }
419
+ /**
420
+ * Owned tools that delegate to the actions surface (`actions.invoke(...)`)
421
+ * rather than the agents/messaging APIs. Together with the dynamic per-action
422
+ * tools (tracked via `actionToolNames`), these intentionally skip per-tool
423
+ * telemetry so the same underlying action is not counted differently depending
424
+ * on which MCP surface the caller used (e.g. `spawn` vs `invoke_action`).
425
+ */
426
+ const ACTION_ROUTED_TOOL_NAMES = new Set(['invoke_action', 'spawn']);
427
+ /**
428
+ * Coarse type/category metadata for the statically-registered ("owned") MCP
429
+ * tools. Action-routed calls (see `ACTION_ROUTED_TOOL_NAMES`) and the dynamic
430
+ * per-action tools surfaced from the actions registry are intentionally
431
+ * excluded from per-tool telemetry (see the skip in `enableInboxPiggyback`),
432
+ * so they have no entry.
433
+ */
434
+ const AGENT_RELAY_TOOL_CALL_METADATA = {
435
+ add_agent: { toolType: 'agent.create', toolCategory: 'spawn' },
436
+ remove_agent: { toolType: 'agent.release', toolCategory: 'release' },
437
+ list_actions: { toolType: 'action.list', toolCategory: 'action' },
438
+ submit_result: { toolType: 'result.submit', toolCategory: 'result' },
439
+ create_workspace: { toolType: 'workspace.create', toolCategory: 'workspace' },
440
+ set_workspace_key: { toolType: 'workspace.set_key', toolCategory: 'workspace' },
441
+ register_agent: { toolType: 'agent.register', toolCategory: 'agent' },
442
+ list_agents: { toolType: 'agent.list', toolCategory: 'agent' },
443
+ post_message: { toolType: 'message.post', toolCategory: 'message' },
444
+ send_dm: { toolType: 'message.dm', toolCategory: 'message' },
445
+ send_group_dm: { toolType: 'message.group_dm', toolCategory: 'message' },
446
+ list_dms: { toolType: 'message.dm_list', toolCategory: 'message' },
447
+ list_messages: { toolType: 'message.list', toolCategory: 'message' },
448
+ reply_to_thread: { toolType: 'message.reply', toolCategory: 'message' },
449
+ get_message_thread: { toolType: 'message.thread', toolCategory: 'message' },
450
+ search_messages: { toolType: 'message.search', toolCategory: 'message' },
451
+ create_channel: { toolType: 'channel.create', toolCategory: 'channel' },
452
+ list_channels: { toolType: 'channel.list', toolCategory: 'channel' },
453
+ join_channel: { toolType: 'channel.join', toolCategory: 'channel' },
454
+ leave_channel: { toolType: 'channel.leave', toolCategory: 'channel' },
455
+ set_channel_topic: { toolType: 'channel.set_topic', toolCategory: 'channel' },
456
+ archive_channel: { toolType: 'channel.archive', toolCategory: 'channel' },
457
+ invite_to_channel: { toolType: 'channel.invite', toolCategory: 'channel' },
458
+ add_reaction: { toolType: 'reaction.add', toolCategory: 'reaction' },
459
+ remove_reaction: { toolType: 'reaction.remove', toolCategory: 'reaction' },
460
+ check_inbox: { toolType: 'inbox.check', toolCategory: 'inbox' },
461
+ mark_message_read: { toolType: 'inbox.mark_read', toolCategory: 'inbox' },
462
+ get_message_readers: { toolType: 'inbox.reader_list', toolCategory: 'inbox' },
463
+ };
464
+ function agentRelayToolCallMetadata(name) {
465
+ const known = AGENT_RELAY_TOOL_CALL_METADATA[name];
466
+ return known ?? { toolType: name, toolCategory: 'tool' };
467
+ }
468
+ function trackAgentRelayToolCall(input) {
469
+ track('agent_relay_tool_call', {
470
+ tool_name: input.toolName,
471
+ tool_type: input.toolType,
472
+ tool_category: input.toolCategory,
473
+ transport: input.transport ?? 'unknown',
474
+ success: input.success,
475
+ duration_ms: Date.now() - input.startedAt,
476
+ ...(input.errorClass ? { error_class: input.errorClass } : {}),
477
+ });
478
+ }
479
+ function enableInboxPiggyback(mcpServer, getSession, getAgentClient, invalidateAgentToken, telemetryTransport, actionToolNames = new Set()) {
480
+ const original = mcpServer.registerTool.bind(mcpServer);
481
+ const mutableServer = mcpServer;
482
+ mutableServer.registerTool = (name, config, handler) => {
483
+ if (!handler) {
484
+ return original(name, config, handler);
485
+ }
486
+ const wrapped = async (...args) => {
487
+ const asIdentity = readAsIdentity(args);
488
+ const startedAt = Date.now();
489
+ // Action-routed calls (`invoke_action`, `spawn`, and the dynamic
490
+ // per-action tools) run through the actions surface and deliberately skip
491
+ // per-tool telemetry; only the owned tools emit `agent_relay_tool_call`.
492
+ const toolMetadata = !ACTION_ROUTED_TOOL_NAMES.has(name) && !actionToolNames.has(name)
493
+ ? agentRelayToolCallMetadata(name)
494
+ : undefined;
495
+ let result;
496
+ try {
497
+ result = await handler(...args);
498
+ }
499
+ catch (err) {
500
+ if (name !== 'register_agent' && isInvalidAgentTokenError(err)) {
501
+ invalidateAgentToken(asIdentity);
502
+ if (toolMetadata) {
503
+ trackAgentRelayToolCall({
504
+ toolName: name,
505
+ toolType: toolMetadata.toolType,
506
+ toolCategory: toolMetadata.toolCategory,
507
+ transport: telemetryTransport,
508
+ startedAt,
509
+ success: false,
510
+ errorClass: errorClassName(err) ?? 'InvalidAgentToken',
511
+ });
512
+ }
513
+ return invalidAgentTokenToolResult();
514
+ }
515
+ if (toolMetadata) {
516
+ trackAgentRelayToolCall({
517
+ toolName: name,
518
+ toolType: toolMetadata.toolType,
519
+ toolCategory: toolMetadata.toolCategory,
520
+ transport: telemetryTransport,
521
+ startedAt,
522
+ success: false,
523
+ errorClass: errorClassName(err),
524
+ });
525
+ }
526
+ throw err;
527
+ }
528
+ if (name !== 'register_agent' && isInvalidAgentTokenToolResult(result)) {
529
+ invalidateAgentToken(asIdentity);
530
+ if (toolMetadata) {
531
+ trackAgentRelayToolCall({
532
+ toolName: name,
533
+ toolType: toolMetadata.toolType,
534
+ toolCategory: toolMetadata.toolCategory,
535
+ transport: telemetryTransport,
536
+ startedAt,
537
+ success: false,
538
+ errorClass: 'InvalidAgentToken',
539
+ });
540
+ }
541
+ if (hasContentArray(result)) {
542
+ result.content.push({ type: 'text', text: agentTokenRecoveryMessage() });
543
+ }
544
+ return result;
545
+ }
546
+ if (!SKIP_PIGGYBACK.has(name) && getSession().agentToken && hasContentArray(result)) {
547
+ try {
548
+ const inbox = await getAgentClient(asIdentity).inbox();
549
+ const inboxText = formatInbox(inbox, asIdentity ?? getSession().agentName);
550
+ if (inboxText) {
551
+ result.content.push({ type: 'text', text: inboxText });
552
+ }
553
+ }
554
+ catch (err) {
555
+ if (isInvalidAgentTokenError(err)) {
556
+ invalidateAgentToken(asIdentity);
557
+ }
558
+ }
559
+ }
560
+ if (toolMetadata) {
561
+ const resultIsError = isErrorToolResult(result);
562
+ trackAgentRelayToolCall({
563
+ toolName: name,
564
+ toolType: toolMetadata.toolType,
565
+ toolCategory: toolMetadata.toolCategory,
566
+ transport: telemetryTransport,
567
+ startedAt,
568
+ success: !resultIsError,
569
+ ...(resultIsError ? { errorClass: 'ToolResultError' } : {}),
570
+ });
571
+ }
572
+ return result;
573
+ };
574
+ return original(name, config, wrapped);
575
+ };
576
+ }
577
+ async function createWorkspace(name, baseUrl) {
578
+ return (await RelayCast.createWorkspace(name, {
579
+ baseUrl,
580
+ ...relaycastWorkspaceTelemetryOptions(),
581
+ }));
582
+ }
583
+ function extractWorkspaceKey(payload) {
584
+ const data = payload.data && typeof payload.data === 'object' ? payload.data : {};
585
+ const value = payload.workspaceKey ??
586
+ payload.workspace_key ??
587
+ payload.apiKey ??
588
+ payload.api_key ??
589
+ data.workspaceKey ??
590
+ data.workspace_key ??
591
+ data.apiKey ??
592
+ data.api_key;
593
+ return typeof value === 'string' && value.trim() ? value : undefined;
594
+ }
595
+ function extractWorkspaceName(payload, fallback) {
596
+ const data = payload.data && typeof payload.data === 'object' ? payload.data : {};
597
+ const value = payload.workspaceName ?? payload.workspace_name ?? payload.name ?? data.workspaceName;
598
+ return typeof value === 'string' && value.trim() ? value : fallback;
599
+ }
600
+ function requireWorkspaceKey(session) {
601
+ if (session.workspaceKey) {
602
+ return;
603
+ }
604
+ throw new Error('Workspace key not configured. Call "create_workspace" first, or "set_workspace_key" if someone shared a workspace key.');
605
+ }
606
+ function jsonContent(value) {
607
+ const structuredContent = typeof value === 'object' && value !== null && !Array.isArray(value)
608
+ ? value
609
+ : { value };
610
+ return {
611
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
612
+ structuredContent,
613
+ };
614
+ }
615
+ function textContent(message, structuredContent = { message }) {
616
+ return {
617
+ content: [{ type: 'text', text: message }],
618
+ structuredContent,
619
+ };
620
+ }
621
+ function isSchemaObject(schema) {
622
+ return Boolean(schema &&
623
+ typeof schema === 'object' &&
624
+ !Array.isArray(schema) &&
625
+ typeof schema.safeParse !== 'function');
626
+ }
627
+ function getSchemaDescription(schema) {
628
+ return isSchemaObject(schema) && typeof schema.description === 'string' ? schema.description : undefined;
629
+ }
630
+ function zodFromJsonSchema(schema) {
631
+ if (schema === false) {
632
+ return z.never();
633
+ }
634
+ if (!isSchemaObject(schema)) {
635
+ return z.unknown();
636
+ }
637
+ let zodType;
638
+ const schemaType = Array.isArray(schema.type) ? schema.type[0] : schema.type;
639
+ switch (schemaType) {
640
+ case 'array':
641
+ zodType = z.array(zodFromJsonSchema(schema.items));
642
+ break;
643
+ case 'boolean':
644
+ zodType = z.boolean();
645
+ break;
646
+ case 'integer':
647
+ zodType = z.number().int();
648
+ break;
649
+ case 'number':
650
+ zodType = z.number();
651
+ break;
652
+ case 'object':
653
+ if (schema.properties) {
654
+ const required = new Set(schema.required ?? []);
655
+ const shape = {};
656
+ for (const [key, childSchema] of Object.entries(schema.properties)) {
657
+ const child = zodFromJsonSchema(childSchema);
658
+ shape[key] = required.has(key) ? child : child.optional();
659
+ }
660
+ zodType = z.object(shape).passthrough();
661
+ }
662
+ else {
663
+ zodType = z.record(z.string(), z.unknown());
664
+ }
665
+ break;
666
+ case 'string':
667
+ zodType = z.string();
668
+ break;
669
+ default:
670
+ zodType = z.unknown();
671
+ break;
672
+ }
673
+ const description = getSchemaDescription(schema);
674
+ return description ? zodType.describe(description) : zodType;
675
+ }
676
+ function actionToolInputSchema(schema) {
677
+ const zodShape = zodObjectShape(schema);
678
+ if (zodShape) {
679
+ return zodShape;
680
+ }
681
+ if (!isSchemaObject(schema) || schema.type !== 'object') {
682
+ return {
683
+ input: z.unknown().describe('Action input payload. The action registry performs final validation.'),
684
+ };
685
+ }
686
+ const required = new Set(schema.required ?? []);
687
+ const shape = {};
688
+ for (const [key, childSchema] of Object.entries(schema.properties ?? {})) {
689
+ const child = zodFromJsonSchema(childSchema);
690
+ shape[key] = required.has(key) ? child : child.optional();
691
+ }
692
+ return shape;
693
+ }
694
+ function actionInvocationInput(descriptor, args) {
695
+ const schema = descriptor.inputSchema;
696
+ if (zodObjectShape(schema)) {
697
+ return args;
698
+ }
699
+ if (!isSchemaObject(schema) || schema.type !== 'object') {
700
+ return typeof args === 'object' && args !== null && 'input' in args
701
+ ? args.input
702
+ : args;
703
+ }
704
+ return args;
705
+ }
706
+ function zodObjectShape(schema) {
707
+ if (schema instanceof z.ZodObject) {
708
+ return schema.shape;
709
+ }
710
+ return undefined;
711
+ }
712
+ function serializableActionDescriptor(descriptor) {
713
+ return {
714
+ name: descriptor.name,
715
+ description: descriptor.description,
716
+ visibility: descriptor.visibility,
717
+ ...(descriptor.inputSchema ? { inputSchema: serializableActionSchema(descriptor.inputSchema) } : {}),
718
+ ...(descriptor.outputSchema ? { outputSchema: serializableActionSchema(descriptor.outputSchema) } : {}),
719
+ };
720
+ }
721
+ function serializableActionSchema(schema) {
722
+ if (isSchemaObject(schema)) {
723
+ return schema;
724
+ }
725
+ if (isZodLikeSchema(schema)) {
726
+ return {
727
+ type: 'zod',
728
+ ...(schema.description ? { description: schema.description } : {}),
729
+ };
730
+ }
731
+ return schema;
732
+ }
733
+ function isZodLikeSchema(schema) {
734
+ return Boolean(schema &&
735
+ typeof schema === 'object' &&
736
+ !Array.isArray(schema) &&
737
+ typeof schema.safeParse === 'function');
738
+ }
739
+ function registerAgentRelayActionTools(server, actions, getSession, onAuditEvent, getAgentClient, actionToolNames) {
740
+ if (!actions) {
741
+ return;
742
+ }
743
+ /**
744
+ * Fire-and-forget invocation through the relay: returns an immediate ack
745
+ * (with an `invocation_id`) and does NOT run the handler inline. Falls back to
746
+ * the local in-process registry when the relay action surface is unavailable.
747
+ */
748
+ const invokeAction = async (name, input) => {
749
+ const relayActions = getRelayAgentActions(getAgentClient);
750
+ if (relayActions) {
751
+ try {
752
+ const ack = await relayActions.invoke(name, asInputRecord(input));
753
+ return jsonContent({ ok: true, status: 'invoked', invocation: ack });
754
+ }
755
+ catch (error) {
756
+ return { ...jsonContent({ ok: false, error: errorMessage(error) }), isError: true };
757
+ }
758
+ }
759
+ const session = getSession();
760
+ const result = await actions.invoke({
761
+ name,
762
+ input,
763
+ context: {
764
+ caller: { name: session.agentName ?? 'mcp', type: 'agent' },
765
+ emit: onAuditEvent,
766
+ },
767
+ });
768
+ return result.ok ? jsonContent(result) : { ...jsonContent(result), isError: true };
769
+ };
770
+ server.registerTool('list_actions', {
771
+ title: 'List Actions',
772
+ description: 'List Agent Relay actions available to this agent.',
773
+ inputSchema: {},
774
+ outputSchema: jsonResult,
775
+ annotations: {
776
+ readOnlyHint: true,
777
+ destructiveHint: false,
778
+ idempotentHint: true,
779
+ openWorldHint: false,
780
+ },
781
+ }, async () => jsonContent({
782
+ actions: (await actions.list({ visibility: 'agent' })).map(serializableActionDescriptor),
783
+ }));
784
+ server.registerTool('invoke_action', {
785
+ title: 'Invoke Action',
786
+ description: 'Invoke a registered Agent Relay action by name. Fire-and-forget: returns an ack with an invocation id; the result arrives asynchronously to the action handler.',
787
+ inputSchema: {
788
+ name: z.string().describe('Registered action name'),
789
+ input: z.unknown().describe('Action input payload'),
790
+ },
791
+ outputSchema: jsonResult,
792
+ annotations: {
793
+ readOnlyHint: false,
794
+ destructiveHint: false,
795
+ idempotentHint: false,
796
+ openWorldHint: false,
797
+ },
798
+ }, async ({ name, input }) => invokeAction(name, input));
799
+ void actions
800
+ .list({ visibility: 'agent' })
801
+ .then((descriptors) => {
802
+ for (const descriptor of descriptors) {
803
+ actionToolNames?.add(descriptor.name);
804
+ server.registerTool(descriptor.name, {
805
+ title: descriptor.name,
806
+ description: descriptor.description,
807
+ inputSchema: actionToolInputSchema(descriptor.inputSchema),
808
+ outputSchema: jsonResult,
809
+ annotations: {
810
+ readOnlyHint: false,
811
+ destructiveHint: false,
812
+ idempotentHint: false,
813
+ openWorldHint: false,
814
+ },
815
+ }, async (args) => invokeAction(descriptor.name, actionInvocationInput(descriptor, args)));
816
+ }
817
+ })
818
+ .catch(() => undefined);
819
+ }
820
+ /** The relay-backed action surface on the live agent client, when available. */
821
+ function getRelayAgentActions(getAgentClient) {
822
+ if (!getAgentClient) {
823
+ return undefined;
824
+ }
825
+ try {
826
+ return getAgentClient().actions;
703
827
  }
704
- return lines.length === 1 ? '' : lines.join('\n');
705
- }
706
- function readAsIdentity(args) {
707
- const [input] = args;
708
- if (typeof input !== 'object' || input === null)
828
+ catch {
709
829
  return undefined;
710
- const as = input.as;
711
- return typeof as === 'string' ? as : undefined;
712
- }
713
- function invalidAgentTokenToolResult() {
714
- const text = agentTokenRecoveryMessage();
715
- return {
716
- content: [{ type: 'text', text }],
717
- structuredContent: {
718
- error: { code: INVALID_AGENT_TOKEN_CODE, message: text },
719
- },
720
- isError: true,
721
- };
830
+ }
722
831
  }
723
- const AGENT_RELAY_TOOL_CALL_METADATA = {
724
- add_agent: { toolType: 'agent.create', toolCategory: 'spawn' },
725
- remove_agent: { toolType: 'agent.release', toolCategory: 'release' },
726
- invoke_action: { toolType: 'action.invoke', toolCategory: 'action' },
727
- list_actions: { toolType: 'action.list', toolCategory: 'action' },
728
- submit_result: { toolType: 'result.submit', toolCategory: 'result' },
729
- create_workspace: { toolType: 'workspace.create', toolCategory: 'workspace' },
730
- set_workspace_key: { toolType: 'workspace.set_key', toolCategory: 'workspace' },
731
- register_agent: { toolType: 'agent.register', toolCategory: 'agent' },
732
- list_agents: { toolType: 'agent.list', toolCategory: 'agent' },
733
- post_message: { toolType: 'message.post', toolCategory: 'message' },
734
- send_dm: { toolType: 'message.dm', toolCategory: 'message' },
735
- send_group_dm: { toolType: 'message.group_dm', toolCategory: 'message' },
736
- list_dms: { toolType: 'message.dm_list', toolCategory: 'message' },
737
- list_messages: { toolType: 'message.list', toolCategory: 'message' },
738
- get_message: { toolType: 'message.get', toolCategory: 'message' },
739
- reply_to_thread: { toolType: 'message.reply', toolCategory: 'message' },
740
- get_message_thread: { toolType: 'message.thread', toolCategory: 'message' },
741
- get_thread: { toolType: 'message.thread', toolCategory: 'message' },
742
- search_messages: { toolType: 'message.search', toolCategory: 'message' },
743
- create_channel: { toolType: 'channel.create', toolCategory: 'channel' },
744
- list_channels: { toolType: 'channel.list', toolCategory: 'channel' },
745
- join_channel: { toolType: 'channel.join', toolCategory: 'channel' },
746
- leave_channel: { toolType: 'channel.leave', toolCategory: 'channel' },
747
- set_channel_topic: { toolType: 'channel.set_topic', toolCategory: 'channel' },
748
- archive_channel: { toolType: 'channel.archive', toolCategory: 'channel' },
749
- invite_to_channel: { toolType: 'channel.invite', toolCategory: 'channel' },
750
- list_channel_members: { toolType: 'channel.member_list', toolCategory: 'channel' },
751
- add_reaction: { toolType: 'reaction.add', toolCategory: 'reaction' },
752
- remove_reaction: { toolType: 'reaction.remove', toolCategory: 'reaction' },
753
- check_inbox: { toolType: 'inbox.check', toolCategory: 'inbox' },
754
- mark_message_read: { toolType: 'inbox.mark_read', toolCategory: 'inbox' },
755
- get_message_readers: { toolType: 'inbox.reader_list', toolCategory: 'inbox' },
756
- };
757
- function readInvokedActionName(name, args) {
758
- if (name !== 'invoke_action') {
832
+ function asInputRecord(input) {
833
+ if (input === undefined || input === null) {
759
834
  return undefined;
760
835
  }
761
- const [input] = args;
762
- if (!input || typeof input !== 'object') {
763
- return undefined;
836
+ if (typeof input === 'object' && !Array.isArray(input)) {
837
+ return input;
764
838
  }
765
- const actionName = input.name;
766
- return typeof actionName === 'string' && actionName.trim() ? actionName : undefined;
839
+ return { input };
767
840
  }
768
- function agentRelayActionNameCategory(name) {
769
- const leaf = name.split(/[._-]/).filter(Boolean).at(-1)?.toLowerCase();
770
- switch (leaf) {
771
- case 'create':
772
- case 'spawn':
773
- case 'attach':
774
- return 'spawn';
775
- case 'release':
776
- return 'release';
777
- case 'status':
778
- return 'agent';
779
- default:
780
- return 'action';
781
- }
841
+ function errorMessage(error) {
842
+ return error instanceof Error ? error.message : String(error);
782
843
  }
783
- function agentRelayToolCallMetadata(name, args, actionToolNames) {
784
- const invokedActionName = readInvokedActionName(name, args);
785
- if (invokedActionName) {
786
- return {
787
- toolType: invokedActionName,
788
- toolCategory: agentRelayActionNameCategory(invokedActionName),
789
- };
844
+ function createRegisteredAgent(agentName, agentToken) {
845
+ return { agentName, agentToken };
846
+ }
847
+ export async function registerAgentWithRebind({ session, setSession, getRelay, name, type, persona, metadata, strictAgentName, preferredAgentName, forcedAgentType, }) {
848
+ requireWorkspaceKey(session);
849
+ const configuredName = session.agentName ?? preferredAgentName?.trim() ?? null;
850
+ const warnings = [];
851
+ const effectiveName = strictAgentName && configuredName ? configuredName : name;
852
+ if (strictAgentName && configuredName && name.trim() !== configuredName) {
853
+ warnings.push(`Strict worker identity is enabled; ignoring requested name "${name}" and using "${configuredName}".`);
790
854
  }
791
- const known = AGENT_RELAY_TOOL_CALL_METADATA[name];
792
- if (known) {
793
- return known;
855
+ const effectiveType = forcedAgentType ?? type;
856
+ if (forcedAgentType && type && type !== forcedAgentType) {
857
+ warnings.push(`Forced worker type is enabled; ignoring requested type "${type}" and using "${forcedAgentType}".`);
794
858
  }
795
- if (actionToolNames.has(name)) {
796
- return {
797
- toolType: name,
798
- toolCategory: agentRelayActionNameCategory(name),
799
- };
859
+ if (session.agentToken && effectiveName && strictAgentName) {
860
+ // If the session tracks per-identity agents, only short-circuit when the
861
+ // strict-named identity is still registered. After an `agent_token_invalid`
862
+ // recovery the entry is dropped from the map, which lets this fall through
863
+ // to a fresh registerOrRotate instead of handing back the dead token.
864
+ const cachedAgent = session.agents?.get(effectiveName);
865
+ const knowsIdentities = session.agents !== undefined;
866
+ if (!knowsIdentities || cachedAgent) {
867
+ return {
868
+ name: effectiveName,
869
+ token: cachedAgent?.agentToken ?? session.agentToken,
870
+ registered_name: effectiveName,
871
+ warnings,
872
+ };
873
+ }
800
874
  }
801
- return { toolType: name, toolCategory: 'tool' };
802
- }
803
- function isErrorToolResult(value) {
804
- return Boolean(value && typeof value === 'object' && value.isError === true);
805
- }
806
- function trackAgentRelayToolCall(input) {
807
- track('agent_relay_tool_call', {
808
- tool_name: input.toolName,
809
- tool_type: input.toolType,
810
- tool_category: input.toolCategory,
811
- transport: input.transport ?? 'unknown',
812
- success: input.success,
813
- duration_ms: Date.now() - input.startedAt,
814
- ...(input.errorClass ? { error_class: input.errorClass } : {}),
875
+ const relay = getRelay();
876
+ const result = await relay.agents.registerOrRotate({
877
+ name: effectiveName,
878
+ type: effectiveType,
879
+ persona,
880
+ metadata,
815
881
  });
816
- }
817
- function enableInboxPiggyback(mcpServer, getSession, getAgentClient, invalidateAgentToken, telemetryTransport, actionToolNames = new Set()) {
818
- const original = mcpServer.registerTool.bind(mcpServer);
819
- const mutableServer = mcpServer;
820
- mutableServer.registerTool = (name, config, handler) => {
821
- if (!handler) {
822
- return original(name, config, handler);
823
- }
824
- const wrapped = async (...args) => {
825
- const asIdentity = readAsIdentity(args);
826
- const startedAt = Date.now();
827
- const toolMetadata = agentRelayToolCallMetadata(name, args, actionToolNames);
828
- let result;
829
- try {
830
- result = await handler(...args);
831
- }
832
- catch (err) {
833
- // `register_agent` is the recovery path itself — never invalidate a
834
- // freshly-issued token, and let registration errors bubble normally.
835
- if (name !== 'register_agent' && isInvalidAgentTokenError(err)) {
836
- invalidateAgentToken(asIdentity);
837
- trackAgentRelayToolCall({
838
- toolName: name,
839
- toolType: toolMetadata.toolType,
840
- toolCategory: toolMetadata.toolCategory,
841
- transport: telemetryTransport,
842
- startedAt,
843
- success: false,
844
- errorClass: errorClassName(err) ?? 'InvalidAgentToken',
845
- });
846
- return invalidAgentTokenToolResult();
847
- }
848
- trackAgentRelayToolCall({
849
- toolName: name,
850
- toolType: toolMetadata.toolType,
851
- toolCategory: toolMetadata.toolCategory,
852
- transport: telemetryTransport,
853
- startedAt,
854
- success: false,
855
- errorClass: errorClassName(err),
856
- });
857
- throw err;
858
- }
859
- // Successful response that still carries an "Invalid agent token" body.
860
- if (name !== 'register_agent' && isInvalidAgentTokenToolResult(result)) {
861
- invalidateAgentToken(asIdentity);
862
- trackAgentRelayToolCall({
863
- toolName: name,
864
- toolType: toolMetadata.toolType,
865
- toolCategory: toolMetadata.toolCategory,
866
- transport: telemetryTransport,
867
- startedAt,
868
- success: false,
869
- errorClass: 'InvalidAgentToken',
870
- });
871
- if (hasContentArray(result)) {
872
- result.content.push({ type: 'text', text: agentTokenRecoveryMessage() });
873
- }
874
- return result;
875
- }
876
- if (!SKIP_PIGGYBACK.has(name) && getSession().agentToken && hasContentArray(result)) {
877
- try {
878
- const inbox = await getAgentClient(asIdentity).inbox();
879
- const inboxText = formatInbox(inbox, asIdentity ?? getSession().agentName);
880
- if (inboxText) {
881
- result.content.push({ type: 'text', text: inboxText });
882
- }
883
- }
884
- catch (err) {
885
- // Inbox piggyback is opportunistic; the original tool result should
886
- // still win. But if the inbox fetch itself reveals an invalid token,
887
- // clear the stale identity so the next call doesn't reuse it.
888
- if (isInvalidAgentTokenError(err)) {
889
- invalidateAgentToken(asIdentity);
890
- }
891
- }
892
- }
893
- const resultIsError = isErrorToolResult(result);
894
- trackAgentRelayToolCall({
895
- toolName: name,
896
- toolType: toolMetadata.toolType,
897
- toolCategory: toolMetadata.toolCategory,
898
- transport: telemetryTransport,
899
- startedAt,
900
- success: !resultIsError,
901
- ...(resultIsError ? { errorClass: 'ToolResultError' } : {}),
902
- });
903
- return result;
904
- };
905
- return original(name, config, wrapped);
882
+ const reboundName = result.name?.trim() ? result.name : effectiveName;
883
+ setSession({ agentToken: result.token, agentName: reboundName });
884
+ return {
885
+ ...result,
886
+ registered_name: reboundName,
887
+ warnings,
906
888
  };
907
889
  }
908
890
  function resolveEmoji(input) {
@@ -1047,6 +1029,23 @@ function registerAgentRelayTools(server, getRelay, getAgentClient, getSession, s
1047
1029
  const agents = await getRelay().agents.list(status ? { status } : undefined);
1048
1030
  return jsonContent({ agents });
1049
1031
  });
1032
+ server.registerTool('query_nodes', {
1033
+ title: 'Query Fleet Nodes',
1034
+ description: 'Query registered fleet nodes by capability or name.',
1035
+ inputSchema: {
1036
+ capability: z.string().optional().describe('Optional capability name filter'),
1037
+ name: z.string().optional().describe('Optional node name filter'),
1038
+ },
1039
+ outputSchema: {
1040
+ nodes: z.array(z.object({}).passthrough()).describe('Fleet nodes'),
1041
+ },
1042
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1043
+ }, async ({ capability, name }) => {
1044
+ const session = getSession();
1045
+ requireWorkspaceKey(session);
1046
+ const relay = new AgentRelay({ workspaceKey: session.workspaceKey ?? undefined, baseUrl });
1047
+ return jsonContent({ nodes: await relay.nodes.list({ capability, name }) });
1048
+ });
1050
1049
  server.registerTool('create_channel', {
1051
1050
  title: 'Create Channel',
1052
1051
  description: 'Create a new workspace channel.',
@@ -1338,27 +1337,94 @@ function registerAgentRelayTools(server, getRelay, getAgentClient, getSession, s
1338
1337
  }, async ({ message_id, as }) => jsonContent({ readers: await getAgentClient(as).readers(message_id) }));
1339
1338
  server.registerTool('add_agent', {
1340
1339
  title: 'Add Agent',
1341
- description: 'Ask Relaycast to spawn a worker agent for a task.',
1340
+ description: 'Spawn another AI agent (relay worker) to delegate a task to. This is how you ' +
1341
+ 'create workers — including non-Claude ones. Use it for any "spawn a <tool> agent" request. ' +
1342
+ 'Examples: "spawn a codex agent" → cli:"codex"; ' +
1343
+ '"spawn an opus claude agent" → cli:"claude", model:"claude-opus-4-8"; ' +
1344
+ '"spawn a sonnet claude agent" → cli:"claude", model:"claude-sonnet-4-6". ' +
1345
+ 'Do NOT use the built-in Agent/Task tool for relay workers.',
1342
1346
  inputSchema: {
1343
1347
  name: z.string().describe('Worker agent name'),
1344
- cli: z.enum(['claude', 'codex', 'gemini', 'aider', 'goose']).describe('AI CLI to launch'),
1348
+ cli: z
1349
+ .enum(['claude', 'codex', 'gemini', 'aider', 'goose', 'grok', 'opencode'])
1350
+ .describe('Which AI CLI runs the worker: "codex agent" → codex, "gemini agent" → gemini, ' +
1351
+ '"claude/opus claude/sonnet claude agent" → claude (default).'),
1345
1352
  task: z.string().describe('Task instructions'),
1346
1353
  channel: z.string().optional().describe('Channel to join'),
1347
1354
  persona: z.string().optional().describe('Worker persona'),
1348
- model: z.string().optional().describe('Model powering the worker'),
1355
+ model: z
1356
+ .string()
1357
+ .optional()
1358
+ .describe('Model to pin (Claude only). Required when a tier is specified: ' +
1359
+ '"opus claude" → claude-opus-4-8, "sonnet claude" → claude-sonnet-4-6, ' +
1360
+ '"haiku" → claude-haiku-4-5-20251001.'),
1361
+ spawn_mode: z
1362
+ .enum(['interactive', 'task_exit', 'task-exit', 'single_shot', 'single-shot'])
1363
+ .optional()
1364
+ .describe('Spawn lifecycle. Use task_exit to exit after the injected task completes.'),
1365
+ exit_after_task: z
1366
+ .boolean()
1367
+ .optional()
1368
+ .describe('Exit the worker after it completes the injected task.'),
1349
1369
  },
1350
1370
  outputSchema: jsonResult,
1351
1371
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1352
- }, async ({ name, cli, task, channel, persona, model }) => jsonContent(await getRelay().agents.spawn({
1372
+ }, async ({ name, cli, task, channel, persona, model, spawn_mode, exit_after_task }) => jsonContent(await getRelay().agents.spawn({
1353
1373
  name,
1354
- cli,
1355
- task,
1374
+ // The broker/gateway support grok and opencode at runtime, but the
1375
+ // @relaycast/sdk SpawnAgentRequest type narrows cli to the core five.
1376
+ // Cast to keep grok/opencode selectable from the MCP tool enum.
1377
+ cli: cli,
1378
+ task: exit_after_task ||
1379
+ spawn_mode === 'task_exit' ||
1380
+ spawn_mode === 'task-exit' ||
1381
+ spawn_mode === 'single_shot' ||
1382
+ spawn_mode === 'single-shot'
1383
+ ? withExitAfterTaskInstruction(task)
1384
+ : task,
1356
1385
  channel,
1357
1386
  persona,
1358
- // Pass model as a first-class spawn field so it reaches the broker and
1359
- // the launched CLI (--model), not just the agent's display metadata.
1360
- model: model ?? undefined,
1387
+ // SpawnAgentRequest has no top-level model field; pass via metadata
1388
+ // so the broker can extract it and forward --model to the launched CLI.
1389
+ metadata: model ? { model } : undefined,
1361
1390
  })));
1391
+ server.registerTool('spawn', {
1392
+ title: 'Spawn Agent',
1393
+ description: 'Invoke the fleet spawn action. Optionally target a specific node.',
1394
+ inputSchema: {
1395
+ name: z.string().describe('Agent name'),
1396
+ cli: z.enum(['claude', 'codex', 'gemini', 'aider', 'goose']).describe('AI CLI to launch'),
1397
+ task: z.string().optional().describe('Initial task instructions'),
1398
+ channel: z.string().optional().describe('Channel to join'),
1399
+ channels: z.array(z.string()).optional().describe('Channels to join'),
1400
+ model: z.string().optional().describe('Model powering the worker'),
1401
+ session_ref: z.string().optional().describe('Session reference for resumable spawns'),
1402
+ target_node: z.string().optional().describe('Optional target fleet node name'),
1403
+ ...identityOverrideInputShape,
1404
+ },
1405
+ outputSchema: jsonResult,
1406
+ annotations: {
1407
+ readOnlyHint: false,
1408
+ destructiveHint: false,
1409
+ idempotentHint: false,
1410
+ openWorldHint: true,
1411
+ },
1412
+ }, async ({ name, cli, task, channel, channels, model, session_ref, target_node, as }) => {
1413
+ const actions = getAgentClient(as).actions;
1414
+ if (!actions) {
1415
+ throw new Error('spawn requires an agent-scoped Relaycast actions client.');
1416
+ }
1417
+ const actionInput = {
1418
+ name,
1419
+ cli,
1420
+ ...(task ? { task } : {}),
1421
+ ...(model ? { model } : {}),
1422
+ ...(session_ref ? { session_ref } : {}),
1423
+ ...(target_node ? { target_node } : {}),
1424
+ ...((channels ?? (channel ? [channel] : undefined)) ? { channels: channels ?? [channel] } : {}),
1425
+ };
1426
+ return jsonContent({ invocation: await actions.invoke('spawn', actionInput) });
1427
+ });
1362
1428
  server.registerTool('remove_agent', {
1363
1429
  title: 'Remove Agent',
1364
1430
  description: 'Release a worker agent from active duty.',
@@ -1430,10 +1496,10 @@ export function createAgentRelayMcpServer(options) {
1430
1496
  if (session.agentToken && !session.wsBridge && !session.wsInitAttempted) {
1431
1497
  try {
1432
1498
  const subscriptions = new SubscriptionManager();
1433
- const wsClient = new WsClient(withRelaycastTelemetry({
1499
+ const wsClient = new WsClient({
1434
1500
  token: session.agentToken,
1435
1501
  baseUrl: options.baseUrl,
1436
- }));
1502
+ });
1437
1503
  const wsBridge = new RealtimeResourceBridge(wsClient, subscriptions, (uri) => {
1438
1504
  mcpServer.server.sendResourceUpdated({ uri }).catch(() => undefined);
1439
1505
  });
@@ -1457,9 +1523,6 @@ export function createAgentRelayMcpServer(options) {
1457
1523
  nextAgents.delete(targetName);
1458
1524
  partial.agents = nextAgents;
1459
1525
  }
1460
- // Clear the active-session token when the invalidated identity is the
1461
- // active one (or the caller didn't pin to a particular identity). The
1462
- // active workspaceKey stays intact so `register_agent` can recover.
1463
1526
  if (!asIdentity || asIdentity === session.agentName) {
1464
1527
  if (session.agentToken !== null) {
1465
1528
  partial.agentToken = null;
@@ -1536,9 +1599,6 @@ export function createAgentRelayMcpServer(options) {
1536
1599
  return result;
1537
1600
  });
1538
1601
  }
1539
- if (session.agentToken && !session.wsBridge) {
1540
- setSession({ agentToken: session.agentToken, agentName: session.agentName });
1541
- }
1542
1602
  return mcpServer;
1543
1603
  }
1544
1604
  /** Relaycast agent tokens are opaque `at_live_<hex>` literals. Anything else