Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased - Patch]
## [Unreleased - Minor]

### Added

- `agent-relay observer` mints a scoped, read-only observer token and prints the observer URL built from it, so sharing a live follow-along view no longer requires hand-rolling a `POST /v1/observer-tokens` call. Defaults to a 24-hour token with agent DMs excluded; `--channels`, `--include-dms`, and `--expires` widen it, and `observer list` / `observer revoke <id>` manage existing tokens.
- `get_observer_url` MCP tool does the same for an orchestrating agent, so a lead can hand the user a follow-along link without shelling out.
- `@agent-relay/sdk` exports `createObserverToken`, `listObserverTokens`, and `revokeObserverToken`.

### Fixed

Expand Down
64 changes: 64 additions & 0 deletions packages/cli/src/cli/agent-relay-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import {
AgentRelay,
RELAYCAST_SDK_VERSION,
createAgentClient,
createObserverToken,
createRealtimeClient,
createWorkspaceClient,
} from '@agent-relay/sdk';
import { z } from 'zod';
import { initTelemetry, shutdown as shutdownTelemetry } from './telemetry/index.js';
import { RealtimeResourceBridge, SubscriptionManager, registerResourceDefinitions } from './mcp/resources.js';
import { jsonContent, jsonResult, textContent } from './mcp/tool-results.js';
import { observerUrl, resolveObserverBaseUrl } from './lib/observer-url.js';
import {
createWorkspace,
extractWorkspaceKey,
Expand Down Expand Up @@ -577,6 +579,68 @@ function registerAgentRelayTools(
}
);

server.registerTool(
'get_observer_url',
{
title: 'Get Observer URL',
description:
'Mint a scoped, read-only observer link so a human can follow this workspace live. ' +
'Use this whenever the user asks to watch, follow along with, or see the agent conversation. ' +
'Returns a URL backed by a read-only observer token that expires — NEVER build an observer ' +
'URL from the workspace key, which is an administrative credential.',
inputSchema: {
channels: z
.array(z.string())
.optional()
.describe('Restrict the view to these channels. Omit to show every channel.'),
include_dms: z
.boolean()
.optional()
.describe('Include agent DM traffic. Defaults to false (channels only).'),
expires_in_hours: z
.number()
.int()
.min(1)
.max(2160)
.optional()
.describe('Token lifetime in hours. Defaults to 24.'),
},
outputSchema: jsonResult,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
async ({ channels, include_dms, expires_in_hours }: any) => {
const session = getSession();
requireWorkspaceKey(session);
const lifetimeHours = expires_in_hours ?? 24;
const token = await createObserverToken({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: An invalid RELAY_OBSERVER_URL creates a live 24-hour token and then fails before returning its URL or token material, leaving an unshareable credential active. Resolve/validate the observer base URL before minting so this configuration error has no token-creation side effect.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/cli/agent-relay-mcp.ts, line 615:

<comment>An invalid `RELAY_OBSERVER_URL` creates a live 24-hour token and then fails before returning its URL or token material, leaving an unshareable credential active. Resolve/validate the observer base URL before minting so this configuration error has no token-creation side effect.</comment>

<file context>
@@ -577,6 +579,63 @@ function registerAgentRelayTools(
+      const session = getSession();
+      requireWorkspaceKey(session);
+      const lifetimeHours = expires_in_hours ?? 24;
+      const token = await createObserverToken({
+        workspaceKey: session.workspaceKey as string,
+        name: `observer-mcp-${Math.random().toString(36).slice(2, 10)}`,
</file context>

workspaceKey: session.workspaceKey as string,
name: `observer-mcp-${Math.random().toString(36).slice(2, 10)}`,
description: 'Minted by the get_observer_url MCP tool for read-only follow-along',
filters: {
includeDms: include_dms === true,
...(channels?.length ? { channelNames: channels } : {}),
},
expiresAt: new Date(Date.now() + lifetimeHours * 3_600_000).toISOString(),
...(baseUrl ? { baseUrl } : {}),
});
if (!token.token) {
throw new Error('Observer token created, but the response did not include token material.');
}
return jsonContent({
url: observerUrl(resolveObserverBaseUrl(undefined), token.token),
tokenId: token.id,
expiresAt: token.expiresAt,
includesDms: include_dms === true,
...(channels?.length ? { channels } : {}),
});
}
);

server.registerTool(
'query_nodes',
{
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/cli/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ const expectedLeafCommands = [
'workspace join',
'workspace key',
'workspace switch',
// observer (the bare `observer` mint action is asserted separately — it is a
// group with a default action, so the leaf walk does not reach it)
'observer list',
'observer revoke',
// workspace agents
'agent register',
'agent list',
Expand Down Expand Up @@ -212,6 +216,7 @@ describe('bootstrap CLI', () => {
'fleet',
'reflex',
'status',
'observer',
'version',
'update',
'uninstall',
Expand Down Expand Up @@ -243,6 +248,24 @@ describe('bootstrap CLI', () => {
);
});

it('registers `observer` as a runnable command, not just a group', () => {
// `observer` carries both a default action (mint a link) and subcommands
// (list/revoke), so the leaf-path walk in the inventory test below skips
// it. Assert the action directly — otherwise the primary command could be
// dropped and every other assertion would still pass.
// The action's behaviour is covered in commands/observer.test.ts, which
// parses a bare `observer` and asserts a token is minted.
const program = createProgram();
const observer = program.commands.find((command) => command.name() === 'observer');
expect(observer).toBeDefined();
expect(observer?.commands.map((command) => command.name()).sort()).toEqual(['list', 'revoke']);
// The mint options live on the group itself, which is what makes a bare
// `agent-relay observer` runnable.
expect(observer?.options.map((option) => option.long)).toEqual(
expect.arrayContaining(['--channels', '--include-dms', '--expires'])
);
});

it('registers the expected executable commands', () => {
const program = createProgram();
const leafCommandPaths = collectLeafCommandPaths(program);
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { registerLocalWorkflowCommands } from './commands/local-workflow.js';
import { registerCloudCommands } from './commands/cloud.js';
import { registerReflexCommands } from './commands/reflex.js';
import { registerWorkspaceCommands } from './commands/workspace.js';
import { registerObserverCommands } from './commands/observer.js';
import { registerAgentCommands } from './commands/agent.js';
import { registerChannelCommands } from './commands/channel.js';
import { registerMessageCommands } from './commands/message.js';
Expand Down Expand Up @@ -413,6 +414,7 @@ export function createProgram(options: { name?: string } = {}): Command {
registerCloudCommands(program);
registerReflexCommands(program);
registerWorkspaceCommands(program);
registerObserverCommands(program);
registerAgentCommands(program);
registerChannelCommands(program);
registerMessageCommands(program);
Expand Down
155 changes: 155 additions & 0 deletions packages/cli/src/cli/commands/observer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { registerObserverCommands, type ObserverCommandDependencies } from './observer.js';
import { observerUrl, resolveObserverBaseUrl } from '../lib/observer-url.js';

class ExitSignal extends Error {
constructor(public readonly code: number) {
super(`exit:${code}`);
}
}

const WORKSPACE_KEY = 'rk_live_workspacekey000000';
const FIXED_NOW = Date.parse('2026-08-03T00:00:00.000Z');

function createdToken(overrides: Record<string, unknown> = {}) {
return {
id: 'ot_abc123',
name: 'observer-cli-deadbeef',
scopes: ['stream:read', 'messages:read'],
status: 'active',
expiresAt: '2026-08-04T00:00:00.000Z',
createdAt: '2026-08-03T00:00:00.000Z',
token: 'ot_live_secrettokenmaterial',
...overrides,
};
}

function setup(overrides: Partial<ObserverCommandDependencies> = {}) {
const logs: string[] = [];
const errors: string[] = [];
const createObserverToken = vi.fn(async () => createdToken());
const listObserverTokens = vi.fn(async () => []);
const revokeObserverToken = vi.fn(async () => {});

const program = new Command();
program.exitOverride();
registerObserverCommands(program, {
log: (...args: unknown[]) => logs.push(args.join(' ')),
error: (...args: unknown[]) => errors.push(args.join(' ')),
exit: (code: number) => {
throw new ExitSignal(code);
},
createObserverToken: createObserverToken as never,
listObserverTokens: listObserverTokens as never,
revokeObserverToken: revokeObserverToken as never,
now: () => FIXED_NOW,
randomSuffix: () => 'deadbeef',
...overrides,
});

return { program, logs, errors, createObserverToken, listObserverTokens, revokeObserverToken };
}

describe('agent-relay observer', () => {
beforeEach(() => {
process.env.RELAY_WORKSPACE_KEY = WORKSPACE_KEY;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This suite leaks environment mutations into other tests because process.env is changed directly without restoration. Using Vitest environment stubs and calling vi.unstubAllEnvs() in afterEach would preserve test isolation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/cli/commands/observer.test.ts, line 57:

<comment>This suite leaks environment mutations into other tests because `process.env` is changed directly without restoration. Using Vitest environment stubs and calling `vi.unstubAllEnvs()` in `afterEach` would preserve test isolation.</comment>

<file context>
@@ -0,0 +1,159 @@
+
+describe('agent-relay observer', () => {
+  beforeEach(() => {
+    process.env.RELAY_WORKSPACE_KEY = WORKSPACE_KEY;
+    delete process.env.RELAY_OBSERVER_URL;
+    delete process.env.RELAY_BASE_URL;
</file context>

delete process.env.RELAY_OBSERVER_URL;
delete process.env.RELAY_BASE_URL;
});

it('mints a scoped token and prints an observer URL carrying the token, not the workspace key', async () => {
const { program, logs, createObserverToken } = setup();

await program.parseAsync(['observer'], { from: 'user' });

const [call] = createObserverToken.mock.calls as unknown as [[Record<string, unknown>]];
expect(call[0].workspaceKey).toBe(WORKSPACE_KEY);
// Default posture: DMs excluded, no channel narrowing, 24h lifetime.
expect(call[0].filters).toEqual({ includeDms: false });
expect(call[0].expiresAt).toBe(new Date(FIXED_NOW + 24 * 3_600_000).toISOString());

const url = logs[0];
expect(url).toBe('https://agentrelay.com/observer?key=ot_live_secrettokenmaterial');
// The whole point: the administrative credential never reaches the output.
expect(logs.join('\n')).not.toContain(WORKSPACE_KEY);
});

it('narrows to channels and includes DMs when asked', async () => {
const { program, createObserverToken } = setup();

await program.parseAsync(
['observer', '--channels', '#general, build ,general', '--include-dms', '--expires', '7d'],
{ from: 'user' }
);

const [call] = createObserverToken.mock.calls as unknown as [[Record<string, unknown>]];
// Leading `#` stripped and duplicates collapsed.
expect(call[0].filters).toEqual({ includeDms: true, channelNames: ['general', 'build'] });
expect(call[0].expiresAt).toBe(new Date(FIXED_NOW + 7 * 86_400_000).toISOString());
});

it('rejects a bare-number expiry rather than guessing a unit', async () => {
const { program } = setup();

await expect(program.parseAsync(['observer', '--expires', '24'], { from: 'user' })).rejects.toThrow(
/30m, 24h, or 7d/
);
});

it('fails loudly when the engine returns no token material', async () => {
const { program, errors } = setup({
createObserverToken: vi.fn(async () => createdToken({ token: undefined })) as never,
});

await expect(program.parseAsync(['observer'], { from: 'user' })).rejects.toBeInstanceOf(ExitSignal);
expect(errors.join('\n')).toContain('did not include token material');
});

it('list never prints token material', async () => {
const { program, logs } = setup({
listObserverTokens: vi.fn(async () => [createdToken({ token: undefined, lastUsedAt: null })]) as never,
});

await program.parseAsync(['observer', 'list'], { from: 'user' });

expect(logs.join('\n')).toContain('ot_abc123');
expect(logs.join('\n')).not.toContain('ot_live_');
});

it('revokes by id', async () => {
const { program, revokeObserverToken, logs } = setup();

await program.parseAsync(['observer', 'revoke', 'ot_abc123'], { from: 'user' });

const [call] = revokeObserverToken.mock.calls as unknown as [[Record<string, unknown>]];
expect(call[0]).toMatchObject({ workspaceKey: WORKSPACE_KEY, id: 'ot_abc123' });
expect(logs.join('\n')).toContain('Revoked ot_abc123.');
});
});

describe('observer URL construction', () => {
it('refuses to build a URL from a workspace key', () => {
expect(() => observerUrl('https://agentrelay.com/observer', WORKSPACE_KEY)).toThrow(
/scoped observer token/
);
});

it('prefers an explicit URL, then RELAY_OBSERVER_URL, then the hosted default', () => {
const env = { RELAY_OBSERVER_URL: 'https://observer.relaycast.dev' } as NodeJS.ProcessEnv;
expect(resolveObserverBaseUrl('https://example.test/observer', env)).toBe(
'https://example.test/observer'
);
expect(resolveObserverBaseUrl(undefined, env)).toBe('https://observer.relaycast.dev');
expect(resolveObserverBaseUrl(undefined, {} as NodeJS.ProcessEnv)).toBe(
'https://agentrelay.com/observer'
);
});

it('rejects a malformed observer URL instead of emitting a broken link', () => {
expect(() => resolveObserverBaseUrl('not-a-url', {} as NodeJS.ProcessEnv)).toThrow(
/Invalid observer URL/
);
});
});
Loading