Skip to content

Commit c4bd5ce

Browse files
authored
Merge pull request #61 from AgentWorkforce/feat-system-identity-first-class
Add first-class system identity support
2 parents 6c4d476 + 7ce5616 commit c4bd5ce

12 files changed

Lines changed: 68 additions & 7 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ Relaycast is the messaging backbone:
107107
- Workspace: isolated environment for one project/team
108108
- Workspace key (`rk_live_*`): admin token for managing workspace resources
109109
- Agent token (`at_live_*`): token an individual agent uses to participate
110+
- Identity types: `agent` (AI worker), `human` (person), `system` (automation/service actor)
110111
- Channel: shared room for team/agent communication
111112
- Message: post in channel/DM/thread, with optional files and reactions
112113

@@ -126,6 +127,9 @@ me.on.messageCreated((event) => {
126127
});
127128

128129
await me.send('#general', 'Hello from Relaycast');
130+
131+
// Convenience identity helpers
132+
const { token: systemToken } = await relay.system({ name: 'System' });
129133
```
130134

131135
Running locally:

openapi.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ components:
5959
type: string
6060
type:
6161
type: string
62+
enum: [agent, human, system]
6263
persona:
6364
type: string
6465
status:
@@ -378,6 +379,7 @@ paths:
378379
type: string
379380
type:
380381
type: string
382+
enum: [agent, human, system]
381383
persona:
382384
type: string
383385
metadata:

packages/observer-dashboard/src/components/AgentPanel.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ export function AgentPanel({ agent, onClose }: AgentPanelProps) {
5050
const cli = (agent.metadata?.cli as string) || (agent.metadata?.spawn as Record<string, unknown>)?.cli as string || 'unknown';
5151
const model = (agent.metadata?.model as string) || '';
5252
const currentTask = (agent.metadata?.current_task as string) || '';
53+
const identityLabel = agent.type === 'human' ? 'Human' : agent.type === 'system' ? 'System' : 'Agent';
54+
const identityIcon = agent.type === 'human'
55+
? <User className="h-3.5 w-3.5" />
56+
: agent.type === 'system'
57+
? <Sparkles className="h-3.5 w-3.5" />
58+
: <Bot className="h-3.5 w-3.5" />;
5359

5460
// Collect metadata entries to display (excluding known fields)
5561
const extraMeta = Object.entries(agent.metadata || {}).filter(
@@ -80,7 +86,7 @@ export function AgentPanel({ agent, onClose }: AgentPanelProps) {
8086
<span className={`text-xs ${status.color}`}>{status.text}</span>
8187
<span className="text-xs text-[var(--color-text-dim)] mx-1">&middot;</span>
8288
<span className="text-xs text-[var(--color-text-muted)]">
83-
{agent.type === 'human' ? 'Human' : 'Agent'}
89+
{identityLabel}
8490
</span>
8591
</div>
8692
</div>
@@ -123,7 +129,7 @@ export function AgentPanel({ agent, onClose }: AgentPanelProps) {
123129
value={relativeTime(agent.lastSeen)}
124130
/>
125131
<InfoRow
126-
icon={agent.type === 'human' ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
132+
icon={identityIcon}
127133
label="Created"
128134
value={formatDate(agent.createdAt ?? agent.lastSeen)}
129135
/>

packages/react/src/__tests__/reducer.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function makeChannel(overrides: Partial<{ id: string; workspaceId: string; name:
3636
};
3737
}
3838

39-
function makeAgent(overrides: Partial<{ id: string; workspaceId: string; name: string; type: 'agent' | 'human'; tokenHash: string; status: 'online' | 'offline' | 'away'; persona: string | null; metadata: Record<string, unknown>; createdAt: string; lastSeen: string }> = {}) {
39+
function makeAgent(overrides: Partial<{ id: string; workspaceId: string; name: string; type: 'agent' | 'human' | 'system'; tokenHash: string; status: 'online' | 'offline' | 'away'; persona: string | null; metadata: Record<string, unknown>; createdAt: string; lastSeen: string }> = {}) {
4040
return {
4141
id: 'ag1',
4242
workspaceId: 'ws1',

packages/sdk-python/src/relay_sdk/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
# ── Enums / Literals ──────────────────────────────────────────────
1111

12-
AgentType = Literal["agent", "human"]
12+
AgentType = Literal["agent", "human", "system"]
1313
AgentStatus = Literal["online", "offline", "away"]
1414
FileStatus = Literal["pending", "complete", "deleted"]
1515
DmType = Literal["1:1", "group"]

packages/sdk-rust/src/credentials.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ pub struct AgentSession {
6666
pub struct BootstrapConfig {
6767
/// Preferred agent name. If not set, the server assigns one.
6868
pub preferred_name: Option<String>,
69-
/// Agent type (e.g. "agent", "human"). Defaults to "agent".
69+
/// Agent type (e.g. "agent", "human", "system"). Defaults to "agent".
7070
pub agent_type: Option<String>,
7171
/// Custom base URL. Defaults to https://api.relaycast.dev.
7272
pub base_url: Option<String>,

packages/sdk-typescript/src/__tests__/relay.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,19 @@ describe('RelayCast', () => {
136136
expect(init.body).toBe(JSON.stringify({ name: 'Worker' }));
137137
});
138138

139+
it('system() registers a system identity', async () => {
140+
const { RelayCast } = await import('../relay.js');
141+
const relay = new RelayCast({ apiKey: 'rk_live_test123' });
142+
143+
mockFetch.mockImplementation(() => mockResponse({ ok: true }));
144+
await relay.system({ name: 'System' } as any);
145+
146+
const [url, init] = mockFetch.mock.calls[0]!;
147+
expect(url).toBe('https://api.relaycast.dev/v1/agents');
148+
expect(init.method).toBe('POST');
149+
expect(init.body).toBe(JSON.stringify({ name: 'System', type: 'system' }));
150+
});
151+
139152
it('list() calls GET /v1/agents', async () => {
140153
const { RelayCast } = await import('../relay.js');
141154
const relay = new RelayCast({ apiKey: 'rk_live_test123' });

packages/sdk-typescript/src/relay.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ interface WorkspaceDmMessage {
8181
createdAt: string;
8282
}
8383

84+
type RegisterTypedIdentityInput = Omit<CreateAgentRequest, 'type'>;
85+
type RegisterIdentityType = NonNullable<CreateAgentRequest['type']>;
86+
8487
export class RelayCast {
8588
private client: HttpClient;
8689
private identityHint: { agentId: string; name: string } | null = null;
@@ -198,6 +201,13 @@ export class RelayCast {
198201
throw new RelayError('transport_error', 'Failed to register agent identity after suffix retries');
199202
}
200203

204+
private registerTypedIdentity(
205+
type: RegisterIdentityType,
206+
data: RegisterTypedIdentityInput,
207+
): Promise<CreateAgentResponse> {
208+
return this.agents.register({ ...data, type });
209+
}
210+
201211
async registerAgent(data: RegisterAgentInput): Promise<CreateAgentResponse> {
202212
const { strict, ...request } = data;
203213
if (strict) {
@@ -235,6 +245,18 @@ export class RelayCast {
235245
return this.resolveIdentityInternal();
236246
}
237247

248+
agent(data: RegisterTypedIdentityInput): Promise<CreateAgentResponse> {
249+
return this.registerTypedIdentity('agent', data);
250+
}
251+
252+
human(data: RegisterTypedIdentityInput): Promise<CreateAgentResponse> {
253+
return this.registerTypedIdentity('human', data);
254+
}
255+
256+
system(data: RegisterTypedIdentityInput): Promise<CreateAgentResponse> {
257+
return this.registerTypedIdentity('system', data);
258+
}
259+
238260
workspace = {
239261
info: (): Promise<Workspace> => this.client.get('/v1/workspace'),
240262
update: (data: UpdateWorkspaceRequest): Promise<Workspace> =>

packages/server/src/routes/__tests__/agent.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ describe('POST /v1/agents', () => {
7777
expect(body.error.code).toBe('invalid_request');
7878
});
7979

80+
it('returns 400 when type is invalid', async () => {
81+
const res = await app.request('/v1/agents', {
82+
method: 'POST',
83+
headers: wsAuthHeaders(),
84+
body: JSON.stringify({ name: 'CodeReviewer', type: 'robot' }),
85+
}, bindings);
86+
87+
expect(res.status).toBe(400);
88+
const body = await res.json() as any;
89+
expect(body.error.code).toBe('invalid_request');
90+
});
91+
8092
it('returns 409 for duplicate agent name', async () => {
8193
vi.mocked(agentEngine.registerAgent).mockRejectedValue(
8294
Object.assign(new Error('Agent "CodeReviewer" already exists'), {

packages/server/src/routes/agent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Hono } from 'hono';
22
import { z } from 'zod';
3+
import { AgentTypeSchema } from '@relaycast/types';
34
import type { AppEnv } from '../env.js';
45
import { requireWorkspaceKey } from '../middleware/auth.js';
56
import { rateLimit } from '../middleware/rateLimit.js';
@@ -12,7 +13,7 @@ export const agentRoutes = new Hono<AppEnv>();
1213

1314
const registerAgentSchema = z.object({
1415
name: z.string().min(1),
15-
type: z.string().optional(),
16+
type: AgentTypeSchema.optional(),
1617
persona: z.string().optional(),
1718
metadata: z.record(z.string(), z.unknown()).optional(),
1819
});

0 commit comments

Comments
 (0)