Skip to content

Commit b36831e

Browse files
willwashburnclaude
andauthored
SDKs: node provider clients (TS, Python, Swift) (#243)
* SDKs: node provider clients (TS, Python, Swift) + node.spawn/node.message frames Add a node provider client to each relaycast SDK: connect to /v1/node/ws with a node token, declare provider identity, register capabilities with per-capability acceptance, run action.invoke handlers, heartbeat, reconnect with a fresh instance id, and deregister. Handler context exposes sendMessage and spawnAgent. Wire the ctx helpers to two node-WS request frames: node.spawn (capacity-direct, routing dispatchCapacitySpawn) and node.message (post + observer fanout). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Test node.message attribution is scoped to the node workspace Assert a node token cannot post as an agent from another workspace: the `from` lookup is workspace-scoped, so a cross-workspace name is rejected with agent_not_found and no message leaks into the other workspace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Scope node.spawn to the connection's own node Drop target_node_id from the node.spawn frame: ctx.spawnAgent addresses the provider's own node capacity executor, and the engine always dispatches to the connection's node. A node credential cannot direct a spawn at another node — cross-node placement is workspace-level with agent/workspace credentials. Conformance asserts the spawn lands on the connection's own node and never reaches a second node's capacity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: reconnect on handshake drop; drop unsupported thread_id - TS and Python clients now treat a transport drop during the register handshake as a reconnect (only a protocol rejection hard-fails), matching Swift. Fixes a Python hang and a TS non-reconnect when the socket closes before the node.register reply. - Remove thread_id from node.message: the field was accepted by the wire schema and all three SDKs but silently dropped by the engine (postMessage posts top-level only). node.message is a top-level-post surface; thread replies are not part of it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: validate acceptance, sync handlers, spawn input type - All three clients hard-fail registration when the reply omits a well-formed accepted_capabilities list (a missing/corrupt list means acceptance can't be confirmed); a malformed acceptance entry counts as rejected (fail-safe). Swift previously dropped malformed entries and could register on a corrupt reply. - Python handlers may be sync or async (awaited only when awaitable), matching the TypeScript client. - Swift spawnAgent takes [String: JSONValue] so callers can't build a node.spawn frame the engine's object-typed input schema rejects. - Strengthen the reconnect test to complete re-registration after the drop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ctx.sendMessage posts through the canonical message route, not a parallel frame Remove the node.message WS frame and its engine handler. Node-attributed posts now go through the canonical POST /v1/channels/:name/messages route, which accepts a node token (new `sender` auth requirement) with a required `from` — an agent resolved strictly within the node token's workspace. Delivery routing, observer events, webhooks, triggers, and idempotency come free because it is the one posting path (spec §5.3: only the hosting connection differs). ctx.sendMessage in all three SDKs becomes an HTTP call to that route with the node token. node.spawn stays a WS frame (dispatchCapacitySpawn duplicates no existing surface). Conformance covers node-token posting: delivery to node-hosted recipients, required `from`, unknown-agent and cross-workspace rejection, and `from` rejected on an agent token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: settle registration on reconnect exhaustion; null-safe reply - All three clients reject the registration waiter when reconnects are exhausted before registering, so serve()/whenRegistered() no longer hang forever. - TS onRegistered tolerates a null/undefined reply data (treats it as an invalid reply hard-fail) instead of throwing a TypeError that misroutes to reconnect. - Guard the Python reconnect tests against a race reading the re-register frame. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: registration-state guard, HTTP base/encoding hardening - Python: reconnect exhaustion no longer overwrites a completed registration, so wait_registered() keeps returning success after a later drop. - ctx.sendMessage normalizes a ws(s):// base to http(s):// (all three SDKs) and encodes the channel as a single path segment (Swift no longer leaves `/` unescaped). Swift treats a non-HTTP or non-envelope 2xx response as a failure instead of returning null. - Poll for the node-token delivery fanout in conformance instead of a fixed sleep, matching the file's other delivery assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: verify full acceptance, drain on stop, stop rejects register - Registration hard-fails unless every requested capability is acknowledged in the reply (an empty/partial accepted_capabilities can no longer pass) — all three SDKs. - stop() drains in-flight invoke handlers (bounded) before closing the socket so their action.result frames aren't dropped, and rejects a still-pending registration waiter so whenRegistered() can't hang when stopped pre-register. - Swift clamps the reconnect max delay against the sanitized base delay (no negative backoff) and its HTTP tests clear the mock URL handler via defer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: drain gate, snapshot validation, drain self-safety - stop() stops accepting new action.invoke frames once draining, so a late invoke can't start a handler whose result would be dropped after close (all three SDKs); the engine reschedules the undispatched invocation. - Registration acceptance is validated against the capability snapshot sent in the register frame, not the live map, so a capability added mid-handshake can't trigger a false missing-acknowledgement failure (all three SDKs). - Python stop() excludes the caller's own task from the drain, so a handler that calls stop() no longer deadlocks (and gets cancelled, skipping deregister). - TS clears the drain timeout when tasks finish early; Swift stops heartbeats before the drain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f01a04b commit b36831e

15 files changed

Lines changed: 4135 additions & 18 deletions

File tree

packages/engine/src/__tests__/conformance/nodeProviders.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,118 @@ describe('node providers', () => {
423423
expect(actionRows.map((a) => a.name).sort()).toEqual(['run-etl']);
424424
});
425425

426+
it('routes a node.spawn frame to the connection\'s own node capacity, bypassing any spawn shadow', async () => {
427+
const ws = await createWorkspace(stack.app, 'np-node-spawn');
428+
await enrollNode(ws, 'node_a', 'alpha');
429+
// node_a: broker provider offers native spawn:claude capacity.
430+
const broker = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'default', [{ name: 'spawn:claude', kind: 'capacity' }]);
431+
// A policy provider shadows it with a spawn:claude action, and is where the
432+
// handler runs: its ctx.spawnAgent must delegate to broker capacity, never
433+
// re-enter its own shadow.
434+
const policy = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'policy', [{ name: 'spawn:claude', kind: 'action' }]);
435+
// A second node with its own spawn capacity: a node.spawn from node_a must
436+
// never reach it — a node credential cannot direct a spawn at another node.
437+
await enrollNode(ws, 'node_b', 'beta');
438+
const otherBroker = await attachProvider(ws.workspaceId, 'node_b', 'beta', 'default', [{ name: 'spawn:claude', kind: 'capacity' }]);
439+
440+
await policy.handle.handleMessage(JSON.stringify({
441+
v: 1,
442+
id: 'spawn-1',
443+
type: 'node.spawn',
444+
input: { cli: 'claude', name: 'worker-1' },
445+
}));
446+
447+
// Delegated to node_a's broker capacity...
448+
expect(broker.sock.ofType('action.invoke').at(-1)).toMatchObject({ action: 'spawn:claude' });
449+
// ...not back into the policy provider's shadow action...
450+
expect(policy.sock.ofType('action.invoke')).toHaveLength(0);
451+
// ...and never crossing to the other node.
452+
expect(otherBroker.sock.ofType('action.invoke')).toHaveLength(0);
453+
const reply = policy.sock.ofType('reply').at(-1) as { ok?: boolean; id?: string; data?: { invocation_id?: string; handler_node_id?: string } };
454+
expect(reply).toMatchObject({ ok: true, id: 'spawn-1' });
455+
expect(typeof reply.data?.invocation_id).toBe('string');
456+
expect(reply.data?.handler_node_id).toBe('node_a');
457+
});
458+
459+
// ctx.sendMessage posts through the canonical message route with a node token
460+
// and a required `from`, so node-attributed posts get delivery routing,
461+
// observer events, and triggers by construction (no parallel posting path).
462+
async function enrollNodeWithToken(ws: { workspaceKey: string }, nodeId: string, name: string): Promise<string> {
463+
const res = await stack.app.request('/v1/nodes', {
464+
method: 'POST',
465+
headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` },
466+
body: JSON.stringify({ node_id: nodeId, name, role: 'broker', capabilities: [], max_agents: 4, tags: ['test'], version: 'v0' }),
467+
});
468+
expect(res.status).toBe(201);
469+
return (await res.json() as { data: { token: string } }).data.token;
470+
}
471+
472+
function postMessage(token: string, channel: string, body: Record<string, unknown>) {
473+
return stack.app.request(`/v1/channels/${channel}/messages`, {
474+
method: 'POST',
475+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
476+
body: JSON.stringify(body),
477+
});
478+
}
479+
480+
it('posts a node-token message attributed to `from`, delivered to node-hosted recipients', async () => {
481+
const ws = await createWorkspace(stack.app, 'nt-msg');
482+
await registerAgent(stack.app, ws.workspaceKey, 'poster');
483+
const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha');
484+
const py = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', [{ name: 'run-etl', kind: 'action' }]);
485+
// A worker agent hosted by py joins #general, so it is a delivery recipient.
486+
await py.handle.handleMessage(JSON.stringify({ v: 1, id: 'agent-1', type: 'agent.register', name: 'worker', session_ref: 'pty://py/worker' }));
487+
py.sock.received.length = 0;
488+
489+
const res = await postMessage(nodeToken, 'general', { text: 'etl finished', from: 'poster' });
490+
expect(res.status).toBe(201);
491+
const body = await res.json() as { data: { agent_name: string; text: string } };
492+
expect(body.data).toMatchObject({ agent_name: 'poster', text: 'etl finished' });
493+
494+
// Poll for the async fanout instead of a fixed sleep, to avoid CI flakiness.
495+
for (let i = 0; i < 50 && deliverFramesOfType(py.sock, 'message.created').length === 0; i++) {
496+
await new Promise((r) => setTimeout(r, 10));
497+
}
498+
// Delivery routing came for free from the canonical route.
499+
expect(deliverFramesOfType(py.sock, 'message.created').length).toBeGreaterThanOrEqual(1);
500+
});
501+
502+
it('requires `from` on a node-token message', async () => {
503+
const ws = await createWorkspace(stack.app, 'nt-msg-from-required');
504+
const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha');
505+
const res = await postMessage(nodeToken, 'general', { text: 'hi' });
506+
expect(res.status).toBe(400);
507+
expect((await res.json() as { error: { code: string } }).error.code).toBe('from_required');
508+
});
509+
510+
it('rejects a node-token message whose `from` agent does not exist', async () => {
511+
const ws = await createWorkspace(stack.app, 'nt-msg-unknown');
512+
const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha');
513+
const res = await postMessage(nodeToken, 'general', { text: 'hi', from: 'ghost' });
514+
expect(res.status).toBe(404);
515+
expect((await res.json() as { error: { code: string } }).error.code).toBe('agent_not_found');
516+
});
517+
518+
it('scopes node-token `from` to the node workspace, rejecting a cross-workspace agent', async () => {
519+
const other = await createWorkspace(stack.app, 'nt-msg-other-ws');
520+
await registerAgent(stack.app, other.workspaceKey, 'outsider');
521+
522+
const ws = await createWorkspace(stack.app, 'nt-msg-scope');
523+
const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha');
524+
const res = await postMessage(nodeToken, 'general', { text: 'hi', from: 'outsider' });
525+
expect(res.status).toBe(404);
526+
expect((await res.json() as { error: { code: string } }).error.code).toBe('agent_not_found');
527+
});
528+
529+
it('rejects `from` on an agent-token message', async () => {
530+
const ws = await createWorkspace(stack.app, 'nt-msg-agent-from');
531+
const agent = await registerAgent(stack.app, ws.workspaceKey, 'poster');
532+
const other = await registerAgent(stack.app, ws.workspaceKey, 'other');
533+
const res = await postMessage(agent.token, 'general', { text: 'hi', from: other.name });
534+
expect(res.status).toBe(400);
535+
expect((await res.json() as { error: { code: string } }).error.code).toBe('from_not_allowed');
536+
});
537+
426538
it('removes a provider through DELETE /v1/nodes/:node/providers/:name', async () => {
427539
const ws = await createWorkspace(stack.app, 'np-delete');
428540
await enrollNode(ws, 'node_a', 'alpha');

packages/engine/src/auth/tokenKind.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function getAuthTokenKind(token: string): AuthTokenKind | undefined {
6565
return parseAuthToken(token)?.kind;
6666
}
6767

68-
export function requiredTokenMessage(kind: Exclude<AuthRequire, 'any'>): string {
68+
export function requiredTokenMessage(kind: Exclude<AuthRequire, 'any' | 'sender'>): string {
6969
return tokenKindConfig[kind].requiredMessage;
7070
}
7171

@@ -77,6 +77,9 @@ const allowedKindsByRequire = {
7777
// `any` intentionally means user/workspace API credentials, not transport-only
7878
// node credentials or scoped observer credentials.
7979
any: ['workspace', 'agent'],
80+
// `sender` is a principal that can post a message: a workspace key, an agent
81+
// (posts as itself), or a node token (posts as an explicit `from` agent).
82+
sender: ['workspace', 'agent', 'node'],
8083
} as const satisfies Record<AuthRequire, readonly AuthTokenKind[]>;
8184

8285
export type AuthRequirementValidation =
@@ -108,7 +111,7 @@ export function validateTokenRequirement(
108111
};
109112
}
110113

111-
if (require === 'any') {
114+
if (require === 'any' || require === 'sender') {
112115
return { ok: false, code: 'unauthorized', message: 'Invalid token format' };
113116
}
114117

packages/engine/src/engine/node.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ import {
3030
removeProvider,
3131
upsertProvider,
3232
} from './nodeProvider.js';
33-
import { completeNodeInvocation, rescheduleInvocationsForLostNode, rescheduleNodeInvocation } from './action.js';
33+
import {
34+
completeNodeInvocation,
35+
dispatchCapacitySpawn,
36+
rescheduleInvocationsForLostNode,
37+
rescheduleNodeInvocation,
38+
} from './action.js';
3439
import { emitInvocationCompletionEffects } from './invocationCompletion.js';
3540
import type { InvocationCompletionDeps } from './invocationCompletion.js';
3641
import { ackDeliveriesUpToSeq, deliverPendingToNode } from './delivery.js';
@@ -1425,6 +1430,30 @@ export async function handleNodeControlMessage(args: HandleNodeControlMessageArg
14251430
case 'node.deregister':
14261431
await deregisterProvider(args.db, args.registry, args.workspaceId, args.nodeId, frameProviderName);
14271432
return;
1433+
case 'node.spawn': {
1434+
// Handler-context `ctx.spawnAgent`: capacity-direct delegation to this
1435+
// connection's own node capacity executor (the broker provider).
1436+
// Bypasses action dispatch so a `spawn:<harness>` shadow handler cannot
1437+
// re-enter itself. The target is always the connection's node — a node
1438+
// credential cannot direct a spawn at another node.
1439+
const result = await dispatchCapacitySpawn(
1440+
args.db,
1441+
args.workspaceId,
1442+
{
1443+
input: message.input,
1444+
target_node_id: args.nodeId,
1445+
},
1446+
{ nodeConnections: args.registry },
1447+
);
1448+
sendControl(args.socket, {
1449+
v: 1,
1450+
id: requestId(message),
1451+
type: 'reply',
1452+
ok: true,
1453+
data: result as Record<string, unknown>,
1454+
});
1455+
return;
1456+
}
14281457
case 'agent.register': {
14291458
const registered = await registerAgentViaNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message);
14301459
// The relay broker's node_control client awaits a `reply` frame keyed by

packages/engine/src/middleware/auth.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ export const requireWorkspaceKey = makeAuthMiddleware('workspace');
127127
/** Accepts either a workspace key or an agent token. */
128128
export const requireAuth = makeAuthMiddleware('any');
129129

130+
/**
131+
* Accepts a workspace key, an agent token, or a node token — the principals that
132+
* can post a message. An agent posts as itself; a node posts as an explicit
133+
* `from` agent resolved within the node's workspace.
134+
*/
135+
export const requireSender = makeAuthMiddleware('sender');
136+
130137
/** Requires an agent token (`at_live_...`). */
131138
export const requireAgentToken = makeAuthMiddleware('agent');
132139

packages/engine/src/ports/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export type Agent = typeof agents.$inferSelect;
66
export type Node = typeof nodes.$inferSelect;
77
export type ObserverToken = typeof observerTokens.$inferSelect;
88

9-
export type AuthRequire = 'workspace' | 'agent' | 'node' | 'observer' | 'any';
9+
export type AuthRequire = 'workspace' | 'agent' | 'node' | 'observer' | 'any' | 'sender';
1010

1111
export type AuthResult =
1212
| { ok: true; workspace: Workspace; agent?: Agent; node?: Node; observerToken?: ObserverToken }

packages/engine/src/routes/message.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Hono } from 'hono';
22
import { z } from 'zod';
3+
import { and, eq } from 'drizzle-orm';
4+
import { agents } from '../db/schema.js';
35
import type { AppEnv } from '../env.js';
4-
import { requireAuth, requireWorkspaceRead } from '../middleware/auth.js';
6+
import { requireSender, requireWorkspaceRead } from '../middleware/auth.js';
57
import { rateLimit } from '../middleware/rateLimit.js';
68
import { jsonIdempotentOk, parseIdempotencyKey, runIdempotent } from '../middleware/idempotency.js';
79
import * as messageEngine from '../engine/message.js';
@@ -40,6 +42,9 @@ const postMessageSchema = z.object({
4042
data: z.record(z.string(), z.unknown()).nullable().optional(),
4143
content_type: z.string().optional(),
4244
mode: z.enum(['wait', 'steer']).default('wait'),
45+
// Node-token posts attribute the message to `from` — an agent name resolved
46+
// strictly within the node token's workspace. Agent-token posts must omit it.
47+
from: z.string().min(1).optional(),
4348
});
4449

4550
type ValidationFailure = { error: { issues: Array<{ path: PropertyKey[] }> } };
@@ -58,18 +63,19 @@ function postMessageInvalidMessage(failure: ValidationFailure) {
5863
// POST /v1/channels/:name/messages - post a message
5964
messageRoutes.post(
6065
'/channels/:name/messages',
61-
requireAuth,
66+
requireSender,
6267
rateLimit,
6368
async (c) => {
6469
try {
6570
const db = c.get('db');
6671
const workspace = c.get('workspace');
6772
const agent = c.get('agent');
73+
const node = c.get('node');
6874
const parsed = await parseJsonBody(c, postMessageSchema, postMessageInvalidMessage);
6975
if (!parsed.ok) {
7076
return parsed.response;
7177
}
72-
const { text, blocks, attachments, data, content_type, mode } = parsed.data;
78+
const { text, blocks, attachments, data, content_type, mode, from } = parsed.data;
7379

7480
const { key: idempotencyKey, error: idempotencyError } = parseIdempotencyKey(c.req.header('Idempotency-Key'));
7581
if (idempotencyError) {
@@ -84,33 +90,55 @@ messageRoutes.post(
8490
return jsonNotFound(c, 'channel_not_found', `Channel "${channelName}" not found`);
8591
}
8692

87-
// Determine agent ID (from agent token or body)
88-
const agentId = agent?.id;
89-
if (!agentId) {
90-
return jsonError(c, 'agent_token_required', 'Agent token required to post messages', 403);
93+
// Resolve the sender. An agent token posts as itself; a node token posts
94+
// as `from`, an agent resolved strictly within the node's workspace (so a
95+
// node credential can never attribute a message to another workspace).
96+
let senderAgentId: string;
97+
let senderAgentName: string;
98+
if (agent) {
99+
if (from !== undefined) {
100+
return jsonError(c, 'from_not_allowed', '"from" is only valid with a node token', 400);
101+
}
102+
senderAgentId = agent.id;
103+
senderAgentName = agent.name;
104+
} else if (node) {
105+
if (!from) {
106+
return jsonError(c, 'from_required', 'A node token must set "from" (an agent name in the node workspace)', 400);
107+
}
108+
const [fromAgent] = await db
109+
.select({ id: agents.id, name: agents.name })
110+
.from(agents)
111+
.where(and(eq(agents.workspaceId, workspace.id), eq(agents.name, from)));
112+
if (!fromAgent) {
113+
return jsonNotFound(c, 'agent_not_found', `Agent "${from}" not found in this workspace`);
114+
}
115+
senderAgentId = fromAgent.id;
116+
senderAgentName = fromAgent.name;
117+
} else {
118+
return jsonError(c, 'agent_token_required', 'Agent or node token required to post messages', 403);
91119
}
92120

93121
const mailbox = resolveMailboxConfig(c.get('engine').config, workspace.id);
94122
const toMessageCreatedEventData = (data: Awaited<ReturnType<typeof messageEngine.postMessage>>) => buildMessageCreatedEventData(data, {
95123
channelName,
96-
fromName: agent?.name ?? 'unknown',
124+
fromName: senderAgentName,
97125
mode,
98126
});
99127

100128
const idempotent = await runIdempotent({
101129
workspaceId: workspace.id,
102-
actorId: agentId,
130+
actorId: senderAgentId,
103131
scope: `channel-message:${channel.id}`,
104132
key: idempotencyKey,
105133
status: 201,
106-
fingerprint: JSON.stringify({ channelId: channel.id, text, blocks, attachments, data, content_type, mode }),
134+
fingerprint: JSON.stringify({ channelId: channel.id, text, blocks, attachments, data, content_type, mode, from }),
107135
kv: c.get('engine').kv,
108136
operation: () =>
109137
messageEngine.postMessage(
110138
db,
111139
workspace.id,
112140
channel.id,
113-
agentId,
141+
senderAgentId,
114142
{ text, blocks, attachments, data, content_type, mode },
115143
{ mailbox },
116144
),
@@ -146,7 +174,7 @@ messageRoutes.post(
146174
if (_delivery_rejections && _delivery_rejections.length > 0) {
147175
runInBackground(
148176
c,
149-
notifyDeliveryRejections(c, agentId, _delivery_rejections),
177+
notifyDeliveryRejections(c, senderAgentId, _delivery_rejections),
150178
'fanout delivery rejected',
151179
);
152180
}
@@ -170,8 +198,8 @@ messageRoutes.post(
170198
id: String(publicData.id),
171199
channel_id: channel.id,
172200
channel_name: channelName,
173-
agent_id: agentId,
174-
agent_name: agent?.name,
201+
agent_id: senderAgentId,
202+
agent_name: senderAgentName,
175203
text: String(publicData.text ?? text),
176204
mentions: Array.isArray(publicData.mentions) ? publicData.mentions as string[] : [],
177205
metadata: (publicData.metadata ?? null) as Record<string, unknown> | null,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
sanitize_agent_relay_distinct_id,
1111
)
1212
from .errors import RelayError
13+
from .node import NodeProvider, NodeRegistrationError
1314
from .relay import AsyncRelay, Relay
1415
from .ws import WsClient
1516

@@ -23,6 +24,8 @@
2324
"AsyncHttpClient",
2425
"AsyncRelay",
2526
"HttpClient",
27+
"NodeProvider",
28+
"NodeRegistrationError",
2629
"Relay",
2730
"RelayError",
2831
"SDK_VERSION",

0 commit comments

Comments
 (0)