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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Packages without a separate changelog are covered by the cross-package notes below.

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

### Added

- Callers can attribute requests to a machine, user, and organization via `X-Agent-Relay-Machine-Id` / `-User-Id` / `-Org-Id` / `-Org-Slug` headers (or `agent_relay_*` query params on WebSocket upgrades), so hosted usage is reported per person and per customer rather than only per workspace. Analytics only — never affects authorization. See the README's Telemetry Attribution section.

### Fixed

- Corrected the canonical `deliver` wire fixture in `@relaycast/types` to the `{type, data}` payload the engine actually emits, so SDK authors are not coding against a stale flat shape.
- SDK telemetry identity now reaches WebSocket connections, both the workspace observer stream and agent sockets. `ws_session_started` was previously anonymous for identified callers.

## [6.2.0] - 2026-07-17

Expand Down
50 changes: 45 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,51 @@ API errors use `{ ok: false, error: { code, message } }`. Invalid or expired age

## Telemetry Attribution

SDK and wrapper clients may set a `harness` option, such as `codex` or
`claude-code/2.3 (model=opus-4.8)`, to attribute traffic in server telemetry.
The TypeScript SDK sends this as `X-Relaycast-Harness` for HTTP requests and as
the `harness` query parameter for WebSocket connections. Invalid values are
omitted.
Clients may declare who is driving a request so server-side product telemetry
can attribute it. Everything here is optional and analytics-only — it never
affects authentication, authorization, or routing.

```ts
const relay = new RelayCast({
apiKey: process.env.RELAY_API_KEY!,
originActor: 'agent-relay-cli/agent/claude-code',
agentRelayUserId: 'usr_abc123', // signed-in user, if your product has one
agentRelayMachineId: 'a1b2c3d4e5f6', // anonymous hashed machine id
agentRelayOrgId: 'org_xyz789',
agentRelayOrgSlug: 'acme',
});
```

| Option | HTTP header | WS query parameter |
| --- | --- | --- |
| `originActor` | `X-Relaycast-Origin-Actor` | `origin_actor` |
| `agentRelayDistinctId` | `X-Agent-Relay-Distinct-Id` | `agent_relay_distinct_id` |
| `agentRelayMachineId` | `X-Agent-Relay-Machine-Id` | `agent_relay_machine_id` |
| `agentRelayUserId` | `X-Agent-Relay-User-Id` | `agent_relay_user_id` |
| `agentRelayOrgId` | `X-Agent-Relay-Org-Id` | `agent_relay_org_id` |
| `agentRelayOrgSlug` | `X-Agent-Relay-Org-Slug` | `agent_relay_org_slug` |

`originActor` is a UA-style path, `{app}/{type}[/{name}]` — for example
`agent-relay-cli/agent/claude-code` or `pear/user/send-message-box`. (It
replaced the older `harness` option and its `X-Relaycast-Harness` header.)

Relaycast has no user table of its own — a workspace is an API-key row — so
these identity fields are the only way hosted usage can be reported per person
or per organization rather than only per workspace. `agentRelayUserId` doubles
as the analytics person key when `agentRelayDistinctId` is unset, so a host that
knows the user only has to set one field. `agentRelayMachineId` is sent
*alongside* the person key rather than instead of it, which is what makes
"how many machines share this workspace" and "are they one account or several"
answerable.

The query-parameter forms exist because browsers cannot set custom headers on a
WebSocket upgrade; the SDK applies them automatically to both the workspace
observer socket and agent sockets.

Values must match `[A-Za-z0-9._:-]+` and stay within 120 characters for the org
slug, 128 for the rest. Anything else is dropped — identity values are never
truncated to fit, since a shortened id would be a different id and could
attribute usage to the wrong person or organization.

## Core Concepts

Expand Down
33 changes: 30 additions & 3 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,36 @@ info:
- Success: `{ ok: true, data: ... }`
- Error: `{ ok: false, error: { code, message } }`

Clients may include `X-Relaycast-Harness` on HTTP requests to attribute
traffic in server telemetry. WebSocket clients that cannot send custom
headers may use the `harness` query parameter instead.
Telemetry attribution (optional, all requests):

Clients may declare who is driving a request so server-side product
telemetry can attribute it. These are analytics dimensions only — they
never affect authentication, authorization, or routing, and are safe to
omit entirely.

| Header | Query parameter | Meaning |
| --- | --- | --- |
| `X-Relaycast-Origin-Actor` | `origin_actor` | UA-style path identifying the caller, `{app}/{type}[/{name}]` (e.g. `agent-relay-cli/agent/claude-code`). Supersedes the former `X-Relaycast-Harness`. |
| `X-Agent-Relay-Distinct-Id` | `agent_relay_distinct_id` | Analytics person key for the caller. |
| `X-Agent-Relay-Machine-Id` | `agent_relay_machine_id` | Anonymous hashed id of the host machine. Sent alongside the distinct id, not instead of it. |
| `X-Agent-Relay-User-Id` | `agent_relay_user_id` | Signed-in user id from the calling product's own account system. Used as the person key when present. |
| `X-Agent-Relay-Org-Id` | `agent_relay_org_id` | Organization id, for per-customer rollups. |
| `X-Agent-Relay-Org-Slug` | `agent_relay_org_slug` | Human-readable organization slug. |

Relaycast has no user table of its own — a workspace is an API-key row — so
these are the only way hosted usage can be reported per person or per
organization rather than only per workspace.

The query-parameter forms exist for WebSocket upgrades (`/v1/ws`,
`/v1/node/ws`), where browsers cannot set custom headers; the header wins
when both are present.

Values must match `[A-Za-z0-9._:-]+` and stay within 120 characters for the
org slug, 128 for the rest. A value that fails either rule is ignored, and
only that dimension is dropped — the request itself is unaffected. Identity
values are never truncated to fit: a shortened id would be a different id
and could attribute usage to the wrong person or organization.
(`X-Agent-Relay-Distinct-Id` predates this rule and still truncates at 128.)
version: 1.0.0
contact:
name: Relaycast
Expand Down
6 changes: 5 additions & 1 deletion packages/engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

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

### Added
- `extractActorIdentity(request)` reads caller-declared identity from the `X-Agent-Relay-Machine-Id` / `-User-Id` / `-Org-Id` / `-Org-Slug` headers, falling back to the matching `agent_relay_*` query params for WebSocket upgrades. Malformed or oversized values are dropped rather than truncated.
- Server events carry `actor_machine_id` / `actor_user_id` / `actor_org_id` / `actor_org_slug` and `is_authenticated`, and key on the caller's user id when present (`actor_user_id ?? client_distinct_id ?? workspace_id`). Analytics dimensions only; they never affect authorization.

## [6.2.0] - 2026-07-17

Expand Down
129 changes: 129 additions & 0 deletions packages/engine/src/lib/__tests__/origin.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
extractActorIdentity,
extractAgentRelayDistinctId,
extractOriginActor,
UNKNOWN_ORIGIN_ACTOR,
Expand Down Expand Up @@ -139,3 +140,131 @@ describe("extractAgentRelayDistinctId", () => {
).toBe("a".repeat(128));
});
});

function identityReq(
init: {
headers?: Record<string, string>;
query?: Record<string, string>;
} = {},
): Request {
const url = new URL("https://cast.agentrelay.com/v1/activity");
for (const [key, value] of Object.entries(init.query ?? {})) {
url.searchParams.set(key, value);
}
return new Request(url, { headers: new Headers(init.headers ?? {}) });
}

describe("extractActorIdentity", () => {
it("reads user, org, and slug from headers", () => {
expect(
extractActorIdentity(
identityReq({
headers: {
"X-Agent-Relay-User-Id": "usr_abc123",
"X-Agent-Relay-Org-Id": "org_xyz789",
"X-Agent-Relay-Org-Slug": "agentworkforce",
},
}),
),
).toEqual({
actor_user_id: "usr_abc123",
actor_org_id: "org_xyz789",
actor_org_slug: "agentworkforce",
});
});

it("falls back to query params for WebSocket upgrades", () => {
expect(
extractActorIdentity(
identityReq({
query: {
agent_relay_machine_id: "abc123def4567890",
agent_relay_user_id: "usr_abc123",
agent_relay_org_id: "org_xyz789",
agent_relay_org_slug: "agentworkforce",
},
}),
),
).toEqual({
actor_machine_id: "abc123def4567890",
actor_user_id: "usr_abc123",
actor_org_id: "org_xyz789",
actor_org_slug: "agentworkforce",
});
});

it("prefers the header over the query param", () => {
expect(
extractActorIdentity(
identityReq({
headers: { "X-Agent-Relay-User-Id": "usr_header" },
query: { agent_relay_user_id: "usr_query" },
}),
).actor_user_id,
).toBe("usr_header");
});

it("omits fields entirely when absent, so no empty props are emitted", () => {
expect(extractActorIdentity(identityReq())).toEqual({});
});

it("drops malformed values rather than forwarding them", () => {
expect(
extractActorIdentity(
identityReq({
headers: {
"X-Agent-Relay-User-Id": "usr/abc",
"X-Agent-Relay-Org-Id": " ",
"X-Agent-Relay-Org-Slug": "fine-slug",
},
}),
),
).toEqual({ actor_org_slug: "fine-slug" });
});

// A truncated id is a *different* id — it can collide with a real one and
// attribute usage to the wrong person or company. Dropping it means the event
// is keyed by workspace, which is merely less specific rather than wrong.
it("drops an oversized id instead of truncating it into a different id", () => {
expect(
extractActorIdentity(
identityReq({ headers: { "X-Agent-Relay-User-Id": "u".repeat(129) } }),
).actor_user_id,
).toBeUndefined();
});

it("drops an oversized org slug", () => {
expect(
extractActorIdentity(
identityReq({ headers: { "X-Agent-Relay-Org-Slug": "s".repeat(121) } }),
).actor_org_slug,
).toBeUndefined();
});

it("keeps values exactly at the limit", () => {
const identity = extractActorIdentity(
identityReq({
headers: {
"X-Agent-Relay-User-Id": "u".repeat(128),
"X-Agent-Relay-Org-Slug": "s".repeat(120),
},
}),
);

expect(identity.actor_user_id).toHaveLength(128);
expect(identity.actor_org_slug).toHaveLength(120);
});

it("drops only the oversized dimension, keeping its siblings", () => {
expect(
extractActorIdentity(
identityReq({
headers: {
"X-Agent-Relay-User-Id": "u".repeat(200),
"X-Agent-Relay-Org-Id": "org_xyz789",
},
}),
),
).toEqual({ actor_org_id: "org_xyz789" });
});
});
Loading
Loading