Skip to content
Merged
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 numberDiff line numberDiff 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]
## [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`.

## [11.8.0] - 2026-08-19

Expand Down
67 changes: 67 additions & 0 deletions packages/cli/src/cli/agent-relay-mcp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import {
AgentRelay,
RELAYCAST_SDK_VERSION,
createAgentClient,
createObserverToken,
createRealtimeClient,
createWorkspaceClient,
isInvalidAgentTokenError,
Expand All@@ -31,6 +32,7 @@ import { attributableReleaseReason } from './lib/release-reason.js';
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 DownExpand Up@@ -968,6 +970,71 @@ 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;
// Resolve the dashboard URL BEFORE minting: an invalid RELAY_OBSERVER_URL
// would otherwise leave a live token behind that this call never returns.
const observerBase = resolveObserverBaseUrl(undefined);
const token = await createObserverToken({
Comment thread
willwashburn marked this conversation as resolved.
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(observerBase, 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 numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,10 @@ const expectedLeafCommands = [
'workspace switch',
'workspace restore',
'workspace rebind',
// 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 rotate',
Expand DownExpand Up@@ -218,6 +222,7 @@ describe('bootstrap CLI', () => {
'reflex',
'session',
'status',
'observer',
'version',
'update',
'uninstall',
Expand DownExpand Up@@ -249,6 +254,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 numberDiff line numberDiff 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 DownExpand Up@@ -414,6 +415,7 @@ export function createProgram(options: { name?: string } = {}): Command {
registerCloudCommands(program);
registerReflexCommands(program);
registerWorkspaceCommands(program);
registerObserverCommands(program);
registerAgentCommands(program);
registerChannelCommands(program);
registerMessageCommands(program);
Expand Down
221 changes: 221 additions & 0 deletions packages/cli/src/cli/commands/observer.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
import { Command } from 'commander';
import { afterEach, 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 () => {});

// Build the dep set once and hand back exactly what was registered, so a test
// that overrides a mock inspects the same instance the command called rather
// than the untouched default.
const deps = {
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,
};

const program = new Command();
program.exitOverride();
registerObserverCommands(program, deps);

return {
program,
logs,
errors,
createObserverToken: deps.createObserverToken as unknown as ReturnType<typeof vi.fn>,
listObserverTokens: deps.listObserverTokens as unknown as ReturnType<typeof vi.fn>,
revokeObserverToken: deps.revokeObserverToken as unknown as ReturnType<typeof vi.fn>,
};
}

describe('agent-relay observer', () => {
beforeEach(() => {
// Stub rather than assign: a direct `process.env` mutation outlives this
// suite and leaks a workspace key into every test file that runs after it.
vi.stubEnv('RELAY_WORKSPACE_KEY', WORKSPACE_KEY);
vi.stubEnv('RELAY_OBSERVER_URL', '');
vi.stubEnv('RELAY_BASE_URL', '');
});

afterEach(() => {
vi.unstubAllEnvs();
});

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('honours flags on subcommands even though the parent declares the same names', async () => {
// Regression: Commander binds a repeated option to the ancestor that
// declared it first, so reading the subcommand's local opts returned {} and
// silently dropped both --json and --workspace-key.
const { program, logs, listObserverTokens } = setup({
listObserverTokens: vi.fn(async () => [createdToken({ token: undefined })]) as never,
});

await program.parseAsync(['observer', 'list', '--json', '--workspace-key', 'rk_live_explicitkey00000'], {
from: 'user',
});

const [call] = listObserverTokens.mock.calls as unknown as [[Record<string, unknown>]];
expect(call[0].workspaceKey).toBe('rk_live_explicitkey00000');
expect(() => JSON.parse(logs.join('\n'))).not.toThrow();
});

it('validates the observer URL before minting, so a bad config leaves no live token', async () => {
const { program, errors, createObserverToken } = setup();

await expect(
program.parseAsync(['observer', '--observer-url', 'data:text/html,x'], { from: 'user' })
).rejects.toBeInstanceOf(ExitSignal);

expect(errors.join('\n')).toContain('must be http or https');
// The important half: no token was created before the failure.
expect(createObserverToken).not.toHaveBeenCalled();
});

it('collapses duplicate channels before applying the cap', async () => {
const { program, createObserverToken } = setup();
const many = Array.from({ length: 60 }, () => 'general').join(',');

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

const [call] = createObserverToken.mock.calls as unknown as [[Record<string, unknown>]];
expect(call[0].filters).toEqual({ includeDms: false, channelNames: ['general'] });
});

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/
);
});

it('rejects non-http(s) schemes, which would carry the token somewhere unintended', () => {
for (const bad of ['data:text/html,x', 'javascript:alert(1)', 'file:///etc/passwd', 'ftp://h/p']) {
expect(() => resolveObserverBaseUrl(bad, {} as NodeJS.ProcessEnv)).toThrow(/must be http or https/);
}
expect(resolveObserverBaseUrl('http://localhost:3000/observer', {} as NodeJS.ProcessEnv)).toBe(
'http://localhost:3000/observer'
);
});
});
Loading
Loading