SDKs: node provider clients (TS, Python, Swift)#243
Conversation
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughAdds sender-scoped channel posting, node.spawn routing, and Node Provider client SDKs in Python, Swift, and TypeScript with websocket registration, invoke dispatch, reconnect, heartbeat, and message/spawn helpers. ChangesEngine Auth and Node Routing
Node Provider client SDKs
Estimated code review effort: 5 (Critical) | ~150 minutes Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e0a8f86f2a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Code Review
This pull request introduces language-neutral NodeProvider clients across the Python, Swift, and TypeScript SDKs, allowing processes to attach to a node's capabilities, register them, handle heartbeats, and execute invokes directly via /v1/node/ws without a local broker dependency. It also updates the engine to handle node.spawn and node.message control frames, routing them directly to bypass action dispatch shadows and posting channel messages respectively. Feedback on the TypeScript client points out that onRegisterFailed incorrectly treats temporary transport/connection failures during registration as fatal instead of retrying with backoff like the Python and Swift implementations.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
Review completed against the latest diff
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 7 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 13 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…sage 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>
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>
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>
- 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>
- 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>
…llel 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>
…e 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>
2abb77f to
df0d7cd
Compare
- 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>
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (1)
packages/sdk-python/tests/test_node.py (1)
259-271: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd coverage for partial acceptance replies.
This covers a missing
accepted_capabilitiesfield, but not a present list that omits one of the registered capabilities. Add that case to lock in fail-closed registration validation.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/sdk-python/tests/test_node.py` around lines 259 - 271, Add a test in test_registration_missing_accepted_capabilities_raises to also cover a reply where accepted_capabilities is present but only includes a subset of the capabilities sent in the NodeProvider registration. Use the existing NodeProvider, FakeNodeServer, conn.push, and node.wait_registered/serve flow to simulate the partial-acceptance reply, and assert that registration still fails by raising NodeRegistrationError for both the wait_registered and serve task paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/sdk-python/src/relay_sdk/node.py`:
- Around line 340-353: Quiesce in-flight invoke tasks during shutdown by
handling outstanding work in NodeProvider.stop(). Before closing the
websocket/HTTP client, drain or cancel any active _invoke_tasks so
_dispatch_invoke does not keep running after shutdown. Update the stop() flow in
NodeProvider to wait for or cancel those tasks, then proceed with
deregistration, _safe_close(conn), and _http.close() only after invoke
processing is complete.
- Around line 488-504: The registration handling in Node._handle_register_reply
only checks the returned accepted_capabilities entries, so an empty list can
incorrectly pass. Update the logic in the Node registration path to compare the
reply against the requested capabilities list, verify every requested capability
name is present in accepted_capabilities before setting self._registered and
calling self._resolve_registered, and raise a NodeRegistrationError if any
requested capability is missing. Use the existing accepted/rejected handling in
Node._handle_register_reply as the place to add this validation.
In `@packages/sdk-swift/Sources/Relaycast/NodeProvider.swift`:
- Around line 324-326: The reconnect delay sanitization in NodeProvider’s
initializer is using the raw reconnectBaseDelayMilliseconds when setting
reconnectMaxDelayMilliseconds, so a negative max can still slip through even
after the base delay is clamped. Update the clamp logic to compute the max delay
from the already-sanitized base value, and apply the same fix anywhere the
reconnect settings are initialized or updated in NodeProvider so
computeBackoffDelayMilliseconds always receives non-negative bounds.
- Around line 628-665: The action result is currently sent through the actor’s
mutable transport, so a delayed handler can reply on a later reconnect instead
of the socket that received the invoke. Update the invoke flow in NodeProvider
by capturing the current transport from the read loop and passing it into
dispatchInvoke, then have sendActionResult/sendFrame use that specific transport
instance for both success and error replies. Keep the behavior localized around
dispatchInvoke, sendActionResult, and sendFrame so replies stay bound to the
original connection epoch.
- Around line 725-728: The channel URL builder is encoding the channel with a
character set that still allows slashes, so
`channelMessageURL(baseURL:channel:)` can treat one channel value as multiple
path segments. Update the `encoded` handling in `channelMessageURL` to
percent-encode `channel` as a single path component so values like `ops/team`
become one segment in the final URL, preserving the existing base URL trimming
and URL construction.
In `@packages/sdk-swift/Tests/RelaycastTests/NodeProviderTests.swift`:
- Around line 530-540: The NodeMockURLProtocol handler in
testSendMessagePostsToTheCanonicalChannelRoute can leak into later tests if the
test exits early, so make sure it is always cleared with defer immediately after
setting it. Apply the same cleanup pattern in the related NodeProviderTests case
referenced by the comment so the static handler does not persist across tests
and create order-dependent failures.
In `@packages/sdk-typescript/src/node-provider.ts`:
- Around line 251-262: The stop flow in NodeProvider.stop only rejects pending
request/reply promises and leaves the registration promise unresolved, so
whenRegistered() can hang forever if stop() happens before registration
completes. Update stop() to also reject the register promise via the same
register-promise handling used by scheduleReconnect/exhaustion (for example,
through rejectRegister or the equivalent helper), and ensure the NodeProvider
state is cleaned up after that path. Add or adjust a test around stop() before
registration completes to verify whenRegistered() is rejected instead of waiting
indefinitely.
---
Nitpick comments:
In `@packages/sdk-python/tests/test_node.py`:
- Around line 259-271: Add a test in
test_registration_missing_accepted_capabilities_raises to also cover a reply
where accepted_capabilities is present but only includes a subset of the
capabilities sent in the NodeProvider registration. Use the existing
NodeProvider, FakeNodeServer, conn.push, and node.wait_registered/serve flow to
simulate the partial-acceptance reply, and assert that registration still fails
by raising NodeRegistrationError for both the wait_registered and serve task
paths.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 20cd8fac-361b-4cb0-9afb-93a4aafb6a9a
📒 Files selected for processing (15)
packages/engine/src/__tests__/conformance/nodeProviders.test.tspackages/engine/src/auth/tokenKind.tspackages/engine/src/engine/node.tspackages/engine/src/middleware/auth.tspackages/engine/src/ports/auth.tspackages/engine/src/routes/message.tspackages/sdk-python/src/relay_sdk/__init__.pypackages/sdk-python/src/relay_sdk/node.pypackages/sdk-python/tests/test_node.pypackages/sdk-swift/Sources/Relaycast/NodeProvider.swiftpackages/sdk-swift/Tests/RelaycastTests/NodeProviderTests.swiftpackages/sdk-typescript/src/__tests__/node-provider.test.tspackages/sdk-typescript/src/index.tspackages/sdk-typescript/src/node-provider.tspackages/types/src/fleet-wire.ts
There was a problem hiding this comment.
All reported issues were addressed across 7 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…egister - 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>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/sdk-python/src/relay_sdk/node.py (2)
448-453: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winPropagate external cancellation from backoff sleep
stop()intentionally cancels_reconnect_sleep_task, but thisexcept asyncio.CancelledError: passalso swallows a direct cancellation ofserve()while it is backing off. Re-raise when_stoppedis still false.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/sdk-python/src/relay_sdk/node.py` around lines 448 - 453, The backoff wait in `serve()` is swallowing all `asyncio.CancelledError` from `self._reconnect_sleep_task`, which also hides external cancellation. Update the `try/except/finally` around `await self._reconnect_sleep_task` in `Node` so that `stop()`-initiated cancellation is still ignored, but if `_stopped` is still false the `CancelledError` is re-raised to let `serve()` terminate correctly.
620-627: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick winRedact the token from connect errors. Some
websocketsexceptions include the full URI in their message, which can exposenode_tokenfrom the query string when a connect attempt fails.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/sdk-python/src/relay_sdk/node.py` around lines 620 - 627, The websocket URL built by _build_url() includes node_token in the query string, which can leak into connect-time exception messages from websockets. Update the connection/error handling around the Node websocket client in relay_sdk.node to redact or sanitize the URI before logging or rethrowing connect errors, ensuring any message that references the URL does not include the token. Use the _build_url() output as the source to locate the connection path and apply redaction centrally so all websocket connect failures are covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@packages/sdk-python/src/relay_sdk/node.py`:
- Around line 448-453: The backoff wait in `serve()` is swallowing all
`asyncio.CancelledError` from `self._reconnect_sleep_task`, which also hides
external cancellation. Update the `try/except/finally` around `await
self._reconnect_sleep_task` in `Node` so that `stop()`-initiated cancellation is
still ignored, but if `_stopped` is still false the `CancelledError` is
re-raised to let `serve()` terminate correctly.
- Around line 620-627: The websocket URL built by _build_url() includes
node_token in the query string, which can leak into connect-time exception
messages from websockets. Update the connection/error handling around the Node
websocket client in relay_sdk.node to redact or sanitize the URI before logging
or rethrowing connect errors, ensuring any message that references the URL does
not include the token. Use the _build_url() output as the source to locate the
connection path and apply redaction centrally so all websocket connect failures
are covered.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 2c7d1774-f1e6-4724-b2b0-af84f84b5821
📒 Files selected for processing (7)
packages/engine/src/__tests__/conformance/nodeProviders.test.tspackages/sdk-python/src/relay_sdk/node.pypackages/sdk-python/tests/test_node.pypackages/sdk-swift/Sources/Relaycast/NodeProvider.swiftpackages/sdk-swift/Tests/RelaycastTests/NodeProviderTests.swiftpackages/sdk-typescript/src/__tests__/node-provider.test.tspackages/sdk-typescript/src/node-provider.ts
🚧 Files skipped from review as they are similar to previous changes (6)
- packages/sdk-python/tests/test_node.py
- packages/engine/src/tests/conformance/nodeProviders.test.ts
- packages/sdk-swift/Tests/RelaycastTests/NodeProviderTests.swift
- packages/sdk-typescript/src/tests/node-provider.test.ts
- packages/sdk-typescript/src/node-provider.ts
- packages/sdk-swift/Sources/Relaycast/NodeProvider.swift
There was a problem hiding this comment.
6 issues found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/sdk-python/src/relay_sdk/node.py">
<violation number="1" location="packages/sdk-python/src/relay_sdk/node.py:350">
P1: In `NodeProvider.stop()`, the shutdown drain awaits all tasks in `self._invoke_tasks` via `asyncio.gather`. If a capability handler calls `await provider.stop()`, its own task is still in that set, so `gather` deadlocks waiting for itself until the 5-second timeout expires. The timeout can then cancel the current task and prevent `node.deregister` from being sent. Exclude `asyncio.current_task()` from the gathered tasks so a handler-initiated shutdown can complete gracefully.</violation>
</file>
<file name="packages/sdk-swift/Sources/Relaycast/NodeProvider.swift">
<violation number="1" location="packages/sdk-swift/Sources/Relaycast/NodeProvider.swift:413">
P2: Shutdown drains only a snapshot of current invoke tasks while the socket and heartbeat remain active, so new `action.invoke` frames arriving during the drain are dispatched into `invokeTasks` but are not waited on before `node.deregister` closes the transport. This can cause handler results for late-arriving invocations to fail to send.
Additionally, `sendHeartbeat` unconditionally advertises `"handlers_live": true`, and `stopHeartbeat()` is only called after the drain completes, so the engine may route more work to a provider that is already shutting down.
**Recommendation**: Close the transport (or at least stop the heartbeat) immediately after setting `stopped = true`, so no new frames or heartbeats occur during the drain window. Alternatively, guard `handleFrame` so it drops or rejects new `action.invoke` frames once `stopped` is true.</violation>
<violation number="2" location="packages/sdk-swift/Sources/Relaycast/NodeProvider.swift:557">
P2: `sendRegister` validates the registration reply against the live `capabilityOrder` property after an `await`, but Swift actor reentrancy allows concurrent `capability(...)` calls to mutate that array while the register request is in flight. A capability added during that window won't appear in the engine's reply, so the missing-acknowledgement check throws a false `invalid_register_reply` and hard-fails registration. Capture the snapshot of capabilities that were actually sent (e.g., `let sentCapabilities = capabilityOrder` before the `await request(...)`) and validate the reply against `sentCapabilities` instead.</violation>
</file>
<file name="packages/sdk-typescript/src/node-provider.ts">
<violation number="1" location="packages/sdk-typescript/src/node-provider.ts:258">
P1: `stop()` drains only a one-time snapshot of `invokeTasks`, but `handleFrame` continues to accept new `action.invoke` frames while the WebSocket is still open during the drain window. Any handler started during that window but completing after `closeSocket()` gets its `action.result` silently dropped by `sendFrame`, violating the never-drop guarantee. A looping drain that re-snapshots until the set stays empty (or a timeout expires) would close the race.</violation>
<violation number="2" location="packages/sdk-typescript/src/node-provider.ts:261">
P2: The 5-second drain timeout inside this `Promise.race` is never cancelled when invoke tasks finish early, so the timer keeps the Node.js event loop alive unnecessarily for up to 5 seconds after `stop()` resolves. This can delay test process exit and background worker shutdown. Store the timer handle and `clearTimeout` it after the race settles, matching the pattern already used for `reconnectTimer` elsewhere in the class.</violation>
<violation number="3" location="packages/sdk-typescript/src/node-provider.ts:384">
P1: The `onRegistered()` check for missing capabilities reads `this.capabilities.keys()` at reply time, but `sendRegister()` sent a snapshot of the map earlier. Because `capability()` is public and unguarded after `serve()` starts, a caller can add a capability between sending the register frame and receiving its reply. The engine then omits that late capability from the acceptance list, and the reply-time validation treats it as `invalid_register_reply`, permanently stopping the provider. Consider capturing the list of capability names that were actually sent (e.g., into a field at `sendRegister()` time) and validating the reply against that snapshot instead of the live Map.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
- 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>
Stacked on
node-providers-engine(#242) — step 2 of the node-providers spec. Its diff is only the SDK client layer plus the two node-WS frames those clients need.What this adds
A node provider client in each relaycast SDK — TypeScript (
@relaycast/sdk), Python (relaycast-sdk), and Swift (Relaycast). Given an explicit{ baseUrl, nodeToken, node identity, provider, capabilities, handlers }, it:/v1/node/ws, sendsnode.registerwith the provider identity and capability set, and surfaces per-capability acceptance — hard-failing on a rejected capability or an engineerrorframe (provider_instance_conflict,action_name_conflict) with a clear error.load,active_agents,handlers_live).action.invoke, runs the registered handler, and repliesaction.result— a handler throw becomes an error result and an unknown action name becomes an error result; an invocation is never dropped.instance_idper connection (the engine's replace semantic).node.deregisteron graceful shutdown.sendMessageandspawnAgent(spec §4).The constructor takes an explicit token and base URL. Enrollment-file logic (
from_enrollment,fleet-enrollments.json) is deliberately out of scope — it layers on top of this client in the relay repo (step 3).Handler-context surface (
ctx.spawnAgent/ctx.sendMessage)ctx.spawnAgentis a node-WSnode.spawnrequest frame answered by the engine, routing the already-implementeddispatchCapacitySpawn— capacity-direct,bypassShadow, always targeting the connection's own node (the frame carries no node target), so aspawn:<harness>shadow handler that delegates cannot re-enter itself and a node credential cannot direct a spawn at another node. This is the only engine frame added here (dispatchCapacitySpawnduplicates no existing surface, so it has no parity problem), with a conformance test.ctx.sendMessageposts through the canonical message route —POST /v1/channels/:name/messages— which now accepts a node token (a newsenderauth requirement alongside agent/workspace) with a requiredfrom: an agent name resolved strictly within the node token's workspace, so a node credential can never attribute a message to another workspace. Delivery routing, observer events, webhooks, triggers, and idempotency come free because it is the one posting path (spec §5.3: registration, invocation, and events are shared; only the hosting connection differs). There is no parallel node-message posting path. In all three SDKsctx.sendMessageis an HTTP call to that route with the node token.Engine changes on this branch (for the engine-branch owner): the
node.spawnframe, and the message route'ssenderauth +fromattribution. Conformance covers node-token posting end to end: delivery to node-hosted recipients, requiredfrom, unknown-agent and cross-workspace rejection, andfromrejected on an agent token.Parity
The three clients expose the same semantics, named per language (TS camelCase, Python snake_case, Swift Swift-style). Divergences, all intentional:
send_message(from_=...)andcapability(global_=...)avoid thefrom/globalkeyword clash; the wire fields stayfrom/global.NodeProvideris anactor(the rest of the SDK uses@unchecked Sendableclass +NSLock) — chosen for correctness across the manyawaitpoints in register/heartbeat/reconnect/invoke bookkeeping. Constructorcapabilitiesis handler-only; per-capabilitykind/global/queue/metadatago through thecapability(...)builder. Addssession/transportparams for test injection.?token=(server-side clients could useAuthorization: Bearer, but query-param matches the existing SDK WS clients and the engine accepts both).Tests
nodeProviders.test.ts):node.spawnroutes to the connection's own node capacity (never crossing to another node); node-token posting via the canonical route — delivery to a node-hosted recipient, requiredfrom, unknown-agent and cross-workspace rejection, andfromrejected on an agent token. Full engine suite green (426).instance_id, heartbeat shape, deregister,spawnAgent,sendMessage.ctx.sendMessageposting to a channel.Follow-ups
?token=(matching the existing SDK WS clients and keeping the TypeScript client isomorphic with the browserWebSocket, which cannot set headers). Server-side transports (Python/Swift, and Node via a header-capable WS) could prefer anAuthorization: Bearerheader to avoid token-in-URL leakage into intermediary logs; the engine already accepts both. Deferred to keep the clients consistent and the TS build isomorphic.🤖 Generated with Claude Code