Skip to content

Comments

feat: add Rust SDK matching TypeScript SDK API#26

Merged
willwashburn merged 6 commits intomainfrom
feature/rust-sdk
Feb 21, 2026
Merged

feat: add Rust SDK matching TypeScript SDK API#26
willwashburn merged 6 commits intomainfrom
feature/rust-sdk

Conversation

@agent-relay-staging
Copy link
Contributor

@agent-relay-staging agent-relay-staging bot commented Feb 16, 2026

Summary

  • Adds a complete Rust SDK for RelayCast with feature parity to the TypeScript SDK
  • Matches the API surface of @relaycast/sdk (v0.2.3)

What's Included

Core Modules

  • HttpClient - HTTP client with retry logic, error handling, and SDK version headers
  • RelayCast - Workspace-level operations (agents, webhooks, subscriptions, commands, stats)
  • AgentClient - Agent-level operations (messages, DMs, channels, reactions, search, files)
  • WsClient - WebSocket client for real-time events with auto-reconnect
  • BillingClient - Billing and subscription management

Type Definitions

  • Complete type definitions for all API requests/responses
  • WebSocket event types matching TypeScript SDK

Features

  • Async/await API using Tokio
  • Automatic retry with exponential backoff for 5xx errors
  • WebSocket with ping/pong keepalive
  • Idempotency key support for safe retries
  • Comprehensive error types

Example Usage

use relaycast::{RelayCast, RelayCastOptions, CreateAgentRequest};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let relay = RelayCast::new(RelayCastOptions::new("rk_live_xxx"))?;
    
    let agent = relay.register_agent(CreateAgentRequest {
        name: "my-agent".to_string(),
        description: Some("My agent".to_string()),
    }).await?;
    
    let mut client = relay.as_agent(&agent.token)?;
    client.send("#general", "Hello from Rust!", None, None, None).await?;
    
    Ok(())
}

Test Plan

  • cargo check passes
  • cargo test passes
  • cargo clippy passes
  • Example runs successfully against staging

🤖 Generated with Claude Code


Open with Devin

Adds a complete Rust SDK for RelayCast with feature parity to the TypeScript SDK:

- HttpClient: HTTP client with retry logic and error handling
- RelayCast: Workspace-level operations (agents, webhooks, subscriptions, commands)
- AgentClient: Agent-level operations (messages, DMs, channels, reactions, files)
- WsClient: WebSocket client for real-time events
- BillingClient: Billing and subscription management

Includes comprehensive type definitions for all API responses and WebSocket events.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Align Rust SDK behavior/endpoints with TypeScript SDK: set default base URL to relaycast.dev, add origin metadata (surface/client/version) to HTTP headers and WebSocket handshake, and preserve transport/client defaults. Implement workspace stream APIs, channel & message listing (including stripping leading '#' for channels), message/thread/reaction/read endpoints, DM message listing, spawn/release agent endpoints, heartbeat & best-effort REST disconnect, and various agent/channel/message helpers. Add related types (workspace stream config, spawn/release request/response, workspace DM message), wire up urlencoding usage, update exports, update README URL, clean up example imports, and add integration parity tests (tests/parity.rs).
@github-actions
Copy link

Preview deployed!

Environment URL
API https://pr26-api.relaycast.dev
Health https://pr26-api.relaycast.dev/health
Observer https://pr26-observer.relaycast.dev

This preview shares the staging database and will be cleaned up when the PR is merged or closed.

Run E2E tests

npm run e2e -- https://pr26-api.relaycast.dev --ci

Open observer dashboard

https://pr26-observer.relaycast.dev


## API Reference

See the [documentation](https://docs.rs/relaycast) for complete API reference.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be removed. And maybe we should have relaycast mintlify docs but don't see an immediate need for that now.

Copy link
Contributor

@khaliqgant khaliqgant left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs nit

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

let status = response.status().as_u16();

// Retry on 5xx errors
if status >= 500 && status <= 599 && attempt < RETRY_BACKOFFS_MS.len() - 1 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Retry loop uses attempt < len - 1 instead of attempt < len, skipping the last backoff and making one fewer retry than TypeScript SDK

The retry condition attempt < RETRY_BACKOFFS_MS.len() - 1 (i.e., attempt < 2) means the third backoff value (800ms) is never used, and the SDK makes only 3 total attempts instead of the 4 that the TypeScript SDK makes.

Root Cause

The for loop iterates over RETRY_BACKOFFS_MS (3 elements) using enumerate(), giving attempt values 0, 1, 2. The retry condition attempt < RETRY_BACKOFFS_MS.len() - 1 evaluates to attempt < 2, so only attempts 0 and 1 trigger a retry. On attempt 2, a 5xx response falls through to error handling without retrying.

In contrast, the TypeScript SDK (packages/sdk/src/client.ts:153) uses attempt < retryBackoffsMs.length (i.e., attempt < 3) with a separate attempt counter that increments after each retry. This allows all 3 backoff values to be used, resulting in 4 total attempts (1 initial + 3 retries).

Impact: The Rust SDK is less resilient to transient 5xx errors — it retries only twice (sleeping 200ms and 400ms) while the TypeScript SDK retries three times (sleeping 200ms, 400ms, and 800ms). The 800ms backoff entry in RETRY_BACKOFFS_MS is effectively dead code.

Prompt for agents
In packages/rust-sdk/src/client.rs, the retry loop at line 166-206 needs to be restructured to match the TypeScript SDK's retry behavior. The TypeScript SDK uses a while(true) loop with a separate attempt counter, making 4 total attempts (1 initial + 3 retries using all backoff values). The simplest fix is to change the for loop to a while loop pattern:

1. Replace the for loop with a loop that tracks attempt count separately
2. Use RETRY_BACKOFFS_MS as backoff values indexed by attempt number
3. The retry condition should be `attempt < RETRY_BACKOFFS_MS.len()` (not `len() - 1`)
4. Increment attempt after sleeping
5. On the final iteration (when attempt >= len), fall through to error handling

Alternatively, keep the for loop but add one extra iteration. The key insight is that with N backoff values, you want N+1 total attempts (the initial attempt plus N retries).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a comprehensive Rust SDK for RelayCast with the goal of achieving feature parity with the TypeScript SDK (v0.2.3). The implementation includes complete client modules for workspace operations, agent operations, WebSocket events, billing, and extensive type definitions.

Changes:

  • Complete Rust SDK implementation with async/await API using Tokio
  • HTTP client with retry logic, error handling, and SDK version headers
  • WebSocket client for real-time events with auto-reconnect capabilities
  • Comprehensive type definitions matching the TypeScript SDK API surface
  • Test suite validating endpoint parity and basic.rs example demonstrating usage

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/rust-sdk/Cargo.toml Package manifest with dependencies (reqwest, tokio, serde, etc.)
packages/rust-sdk/src/lib.rs Main library entry point with re-exports of all public types
packages/rust-sdk/src/error.rs Error type definitions using thiserror
packages/rust-sdk/src/types.rs Complete type definitions for API requests/responses and WebSocket events
packages/rust-sdk/src/client.rs HTTP client with retry logic and origin metadata headers
packages/rust-sdk/src/relay.rs Workspace-level operations (agents, webhooks, subscriptions, etc.)
packages/rust-sdk/src/agent.rs Agent-level operations (messages, channels, DMs, reactions)
packages/rust-sdk/src/ws.rs WebSocket client with ping/pong keepalive and channel subscriptions
packages/rust-sdk/src/billing.rs Billing and subscription management operations
packages/rust-sdk/tests/parity.rs Integration tests validating API endpoint compatibility
packages/rust-sdk/examples/basic.rs Example demonstrating SDK usage
packages/rust-sdk/README.md Documentation with examples and API overview
.trajectories/* Trajectory tracking files for development process
Comments suppressed due to low confidence (7)

packages/rust-sdk/src/ws.rs:167

  • The ping interval starts ticking immediately when created on line 167, which means the first ping will be sent 30 seconds after connection. However, the interval's first tick fires immediately by default in Tokio. This could cause an immediate ping to be sent right after connecting, before any other setup is complete.

While this might not cause issues in practice, it's worth verifying this behavior matches the TypeScript SDK's ping timing. Consider using ping_interval.tick().await; before entering the loop to skip the first immediate tick, or adjust the timing to match the expected behavior.

            let mut ping_interval =
                tokio::time::interval(tokio::time::Duration::from_secs(PING_INTERVAL_SECS));

packages/rust-sdk/src/agent.rs:81

  • The disconnect method sends a REST API call to /v1/agents/disconnect before closing the WebSocket, but it ignores any errors from this call (line 73's let _). If the REST call fails (e.g., due to network issues, authentication expiry, or server errors), the WebSocket will still be closed, but the server might not be notified properly.

This could lead to the server thinking the agent is still online when it's actually disconnected. Consider:

  1. Logging the error for debugging purposes
  2. Adding a comment explaining why errors are intentionally ignored
  3. Potentially documenting this behavior so users understand the disconnect semantics
    pub async fn disconnect(&mut self) {
        if self.ws.is_some() {
            // Keep parity with TypeScript SDK: best-effort REST disconnect before socket close.
            let _ = self
                .client
                .post::<serde_json::Value>(
                    "/v1/agents/disconnect",
                    Some(serde_json::json!({})),
                    None,
                )
                .await;
        }

packages/rust-sdk/src/relay.rs:91

  • The create_workspace static method creates its own reqwest::Client instead of reusing the HttpClient infrastructure. This means it:
  1. Bypasses the retry logic that exists in HttpClient (lines 166-184 in client.rs)
  2. Doesn't benefit from the configured timeout (30 seconds in HttpClient)
  3. May have different SSL/TLS settings than other SDK calls
  4. Doesn't use the consistent error handling pattern

This inconsistency could lead to different behavior for workspace creation compared to other API calls. Consider either:

  1. Making this a method that takes an HttpClient parameter
  2. Extracting the common request logic into a shared function
  3. Documenting why workspace creation needs special handling
    /// Create a new workspace.
    pub async fn create_workspace(
        name: &str,
        base_url: Option<&str>,
    ) -> Result<CreateWorkspaceResponse> {
        let url = format!("{}/v1/workspaces", base_url.unwrap_or(DEFAULT_BASE_URL));

        let client = reqwest::Client::new();
        let response = client
            .post(&url)
            .header("Content-Type", "application/json")
            .header("X-SDK-Version", SDK_VERSION)
            .header("X-Relaycast-Origin-Surface", DEFAULT_ORIGIN_SURFACE)
            .header("X-Relaycast-Origin-Client", DEFAULT_ORIGIN_CLIENT)
            .header("X-Relaycast-Origin-Version", SDK_VERSION)
            .json(&serde_json::json!({ "name": name }))
            .send()
            .await?;

        let status = response.status().as_u16();
        let json: ApiResponse<CreateWorkspaceResponse> = response.json().await?;

        if !json.ok {
            let error = json.error.unwrap_or_else(|| ApiErrorInfo {
                code: "unknown_error".to_string(),
                message: "Unknown error".to_string(),
            });
            return Err(RelayError::api(error.code, error.message, status));
        }

        json.data
            .ok_or_else(|| RelayError::InvalidResponse("Response missing data field".to_string()))
    }

packages/rust-sdk/src/ws.rs:248

  • There's a race condition in the disconnect logic. On line 248, is_connected is set to false, but this happens after sending the Disconnect command on line 246. If the spawned task (line 165) hasn't processed the command yet and checks is_connected elsewhere, there could be inconsistent state.

The spawned task also sets is_connected to false on line 237 when it exits. This means the flag can be set to false in two places, which could lead to race conditions.

Consider either: (1) only setting the flag in one place (the spawned task), or (2) using proper synchronization to ensure the flag is only updated once per disconnect operation.

    pub async fn disconnect(&mut self) {
        if let Some(tx) = self.command_tx.take() {
            let _ = tx.send(WsCommand::Disconnect).await;
        }
        *self.is_connected.lock().await = false;

packages/rust-sdk/Cargo.toml:3

  • The PR description states this SDK matches TypeScript SDK v0.2.3, but the TypeScript SDK in the repository is currently at version 0.3.1 (as seen in packages/sdk/package.json). This version mismatch could indicate that the Rust SDK is missing features from the newer TypeScript SDK version, or that the PR description needs to be updated to clarify which version it's actually matching.

Please clarify whether this is intentional (matching an older version) or if the Rust SDK should be updated to match v0.3.1.

version = "0.2.3"

packages/rust-sdk/src/ws.rs:280

  • The Drop implementation has a comment indicating it can't call async disconnect, which could lead to resource leaks. When a WsClient is dropped without explicitly calling disconnect(), the spawned task (from line 165) will continue running until the channels are dropped, but there's no guarantee this happens immediately.

This means:

  1. The WebSocket connection remains open until the task detects channel closure
  2. The background task continues consuming resources
  3. No explicit cleanup signal is sent to the server

Consider using a synchronous channel or a different mechanism to ensure cleanup happens promptly when the client is dropped, or document this behavior clearly so users know they must call disconnect() before dropping.

impl Drop for WsClient {
    fn drop(&mut self) {
        // Note: We can't call async disconnect here, but the task will
        // eventually clean up when the channels are dropped
    }

packages/rust-sdk/src/ws.rs:177

  • The broadcast channel is created with a capacity of 1024 messages. If no receivers are subscribed (or if all receivers are slow), messages will be dropped silently after the buffer fills. Line 177 uses let _ = event_tx.send(event); which ignores send errors, meaning events can be lost without any indication to the caller.

While this might be intentional for a broadcast pattern, it could lead to subtle bugs where:

  1. Events are silently dropped when the buffer is full
  2. No backpressure mechanism exists to slow down message production
  3. Users might not realize they're missing events

Consider either documenting this behavior clearly in the WsClient documentation, or providing a way for users to detect when events are being dropped (e.g., exposing buffer fill metrics or logging warnings).

        let (event_tx, _) = broadcast::channel(1024);

        Self {
            token: options.token,
            base_url: base_url.trim_end_matches('/').to_string(),
            debug: options.debug,
            origin_surface: options
                .origin_surface
                .unwrap_or_else(|| DEFAULT_ORIGIN_SURFACE.to_string()),
            origin_client: options
                .origin_client
                .unwrap_or_else(|| DEFAULT_ORIGIN_CLIENT.to_string()),
            origin_version: options
                .origin_version
                .unwrap_or_else(|| SDK_VERSION.to_string()),
            event_tx,
            command_tx: None,
            is_connected: Arc::new(Mutex::new(false)),
        }
    }

    /// Check if the WebSocket is connected.
    pub async fn is_connected(&self) -> bool {
        *self.is_connected.lock().await
    }

    /// Subscribe to receive events.
    pub fn subscribe_events(&self) -> EventReceiver {
        self.event_tx.subscribe()
    }

    /// Connect to the WebSocket server.
    pub async fn connect(&mut self) -> Result<()> {
        if *self.is_connected.lock().await {
            return Ok(());
        }

        let mut url = Url::parse(&format!("{}/v1/ws", self.base_url))?;
        {
            let mut query = url.query_pairs_mut();
            query.append_pair("token", &self.token);
            query.append_pair("origin_surface", &self.origin_surface);
            query.append_pair("origin_client", &self.origin_client);
            query.append_pair("origin_version", &self.origin_version);
        }

        let (ws_stream, _) = connect_async(url.as_str()).await?;
        let (mut write, mut read) = ws_stream.split();

        let (command_tx, mut command_rx) = mpsc::channel::<WsCommand>(32);
        self.command_tx = Some(command_tx);

        let event_tx = self.event_tx.clone();
        let is_connected = self.is_connected.clone();
        let debug = self.debug;

        *is_connected.lock().await = true;

        // Spawn the WebSocket handler task
        tokio::spawn(async move {
            let mut ping_interval =
                tokio::time::interval(tokio::time::Duration::from_secs(PING_INTERVAL_SECS));

            loop {
                tokio::select! {
                    // Handle incoming messages
                    msg = read.next() => {
                        match msg {
                            Some(Ok(Message::Text(text))) => {
                                match serde_json::from_str::<WsEvent>(&text) {
                                    Ok(event) => {
                                        let _ = event_tx.send(event);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 177 to 184
let response = request.send().await?;
let status = response.status().as_u16();

// Retry on 5xx errors
if status >= 500 && status <= 599 && attempt < RETRY_BACKOFFS_MS.len() - 1 {
tokio::time::sleep(Duration::from_millis(*backoff)).await;
continue;
}
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry logic has a critical bug. On line 177, response.send().await? is called, which consumes the response. Then on line 193, response.json().await? is called again. However, if the first request gets a 5xx error and needs to be retried (lines 181-183), the response has already been consumed by line 177's send, making the retry attempt impossible.

The issue is that the error check happens after consuming the response body. On 5xx errors, the code tries to continue to the next iteration, but there's no new request being built - the same consumed response would be reused.

The fix is to check the status before consuming the response body, or to rebuild the request in the retry loop. The response should only be consumed (via .json()) after confirming it won't be retried.

Copilot uses AI. Check for mistakes.
Rename package path from packages/rust-sdk to packages/sdk-rust and update SDK origin identifier headers. Remove the billing surface (deleted billing.rs and related exports) and perform a broad types/API parity pass to match TypeScript contracts (renamed fields like workspace_id, agent_type/persona, payload/message shapes, attachments/reactions/read receipt fields, subscription/webhook/command shapes, etc.). Update client/ws defaults (DEFAULT_ORIGIN_CLIENT), fix retry backoff off-by-one logic, update examples and tests to the new shapes/headers, and add trajectory metadata/notes. These changes implement PR feedback and enforce parity with the TypeScript SDK.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (8)

packages/sdk-rust/src/ws.rs:167

  • The ping_interval.tick() will fire immediately on the first call, sending a ping right after connection. This is because tokio::time::interval ticks immediately for the first time. While not critical, this could be unexpected behavior. Consider using ping_interval.tick().await; before the loop to consume the first immediate tick, ensuring the first ping happens after PING_INTERVAL_SECS.
            let mut ping_interval =
                tokio::time::interval(tokio::time::Duration::from_secs(PING_INTERVAL_SECS));

packages/sdk-rust/src/types.rs:196

  • The status field in various agent-related types (AgentPresenceInfo, CreateAgentResponse, UpdateAgentRequest, etc.) is typed as String, but according to the API schema (packages/types/src/agent.ts lines 6-7), it should be an AgentStatus enum with values 'online', 'offline', or 'away'. Consider using an enum type for better type safety and to prevent invalid status values.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentPresenceInfo {
    pub agent_id: String,
    pub agent_name: String,
    pub status: String,

packages/sdk-rust/src/ws.rs:281

  • The Drop implementation for WsClient notes that async disconnect cannot be called, but there's no cleanup of the spawned tokio task. When WsClient is dropped, the spawned task (line 165) will continue running until the command channel is closed. While the task will eventually detect the closed channel and exit, this could lead to a brief period where the WebSocket task is still running after the client has been dropped. Consider storing a JoinHandle to allow for graceful shutdown, or document this behavior more clearly for users.
impl Drop for WsClient {
    fn drop(&mut self) {
        // Note: We can't call async disconnect here, but the task will
        // eventually clean up when the channels are dropped
    }
}

packages/sdk-rust/README.md:116

  • The example shows e.agent being printed, but according to the WsEvent type definition (line 791 in types.rs), AgentOnlineEvent has a field called agent of type AgentEventPayload which has a name field. The example should be e.agent.name to match the type structure.
        WsEvent::AgentOnline(e) => {
            println!("Agent online: {}", e.agent);

packages/sdk-rust/src/ws.rs:249

  • There's a potential race condition in the disconnect method. Line 248 sets is_connected to false immediately, but line 246 sends the Disconnect command asynchronously. If another thread checks is_connected() right after line 248 executes but before the WebSocket task processes the disconnect command, it will see the client as disconnected while the WebSocket is still running. Consider setting is_connected to false only after the command is sent, or rely solely on the spawned task to update this flag (line 237).
    pub async fn disconnect(&mut self) {
        if let Some(tx) = self.command_tx.take() {
            let _ = tx.send(WsCommand::Disconnect).await;
        }
        *self.is_connected.lock().await = false;
    }

packages/sdk-rust/src/types.rs:202

  • The cli field in SpawnAgentRequest is typed as String, but according to the API schema (packages/types/src/agent.ts line 61), it should be a CliType enum with values 'claude', 'codex', 'gemini', 'aider', or 'goose'. Consider using an enum type for better type safety and API contract enforcement, matching the TypeScript SDK approach.
pub struct SpawnAgentRequest {
    pub name: String,
    pub cli: String,

packages/sdk-rust/src/types.rs:166

  • The agent_type field in CreateAgentRequest is typed as Option, but according to the API schema (packages/types/src/agent.ts lines 3-4), it should be an AgentType enum with values 'agent' or 'human'. Consider using an enum type for better type safety.
#[derive(Debug, Clone, Serialize)]
pub struct CreateAgentRequest {
    pub name: String,
    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
    pub agent_type: Option<String>,

packages/sdk-rust/src/client.rs:181

  • The retry logic has an off-by-one error. The array RETRY_BACKOFFS_MS has 3 elements (indices 0, 1, 2), so when attempt is 0, 1, or 2, the condition attempt < RETRY_BACKOFFS_MS.len() (which is attempt < 3) will be true for all three attempts. This means the last retry (attempt 2) will still retry even though it's the final attempt, potentially leading to 4 total attempts instead of 3. The condition should check attempt < RETRY_BACKOFFS_MS.len() - 1 to ensure only the first two attempts retry.
            if status >= 500 && status <= 599 && attempt < RETRY_BACKOFFS_MS.len() {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

let upload = agent.upload_file(UploadRequest {
filename: "document.pdf".to_string(),
content_type: "application/pdf".to_string(),
size: 12345,
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UploadRequest example has a size field, but according to the Rust type definition on line 454 of types.rs, the field is named size_bytes. This example will fail to compile.

Suggested change
size: 12345,
size_bytes: 12345,

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +18
//! let agent = relay.register_agent(CreateAgentRequest {
//! name: "my-agent".to_string(),
//! persona: Some("My first agent".to_string()),
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lib.rs documentation example uses description field in CreateAgentRequest, but this field doesn't exist in the API schema (packages/types/src/agent.ts defines name, type, persona, and metadata only). This documentation should use persona instead of description, or remove this field entirely if it's optional.

Copilot uses AI. Check for mistakes.
agent.create_channel(CreateChannelRequest {
name: "my-channel".to_string(),
topic: Some("Channel topic".to_string()),
is_private: Some(false),
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CreateChannelRequest shown in the example includes an is_private field that does not exist in the API schema. According to the TypeScript SDK types (packages/types/src/channel.ts), CreateChannelRequest only has name and optional topic fields. The is_private field should be removed from this example.

Suggested change
is_private: Some(false),

Copilot uses AI. Check for mistakes.
// Create a webhook
let webhook = relay.create_webhook(CreateWebhookRequest {
name: "my-webhook".to_string(),
description: Some("Receives external events".to_string()),
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CreateWebhookRequest example includes a description field that doesn't exist in the API schema. According to packages/types/src/webhook.ts, CreateWebhookRequest only has name and channel fields. The description field should be removed.

Suggested change
description: Some("Receives external events".to_string()),
channel: "#webhooks".to_string(),

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +18
//! let agent = relay.register_agent(CreateAgentRequest {
//! name: "my-agent".to_string(),
//! persona: Some("My first agent".to_string()),
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CreateAgentRequest in the PR description example uses a description field that doesn't exist in the API. According to packages/types/src/agent.ts, the correct fields are name, type (optional), persona (optional), and metadata (optional). Replace description with persona or remove it if optional.

Copilot uses AI. Check for mistakes.
// Register an agent
let agent = relay.register_agent(CreateAgentRequest {
name: "my-agent".to_string(),
description: Some("My first agent".to_string()),
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field name in CreateAgentRequest is inconsistent with the TypeScript SDK and API schema. According to packages/types/src/agent.ts, CreateAgentRequest does not have a description field. The TypeScript SDK and API define the fields as: name, type (optional), persona (optional), and metadata (optional). The description field does not exist in the API schema. This example code will fail at runtime.

Suggested change
description: Some("My first agent".to_string()),

Copilot uses AI. Check for mistakes.
Add a dedicated GitHub Actions workflow (.github/workflows/publish-rust.yml) to publish the Rust SDK with a tag gate (sdk-rust-v*), tag vs Cargo.toml verification, cargo test, workflow_dispatch dry-run, and crates.io OIDC auth for publishing. Add packages/sdk-rust/CHANGELOG.md and expand packages/sdk-rust/README.md with one-time/manual publish steps, trusted publisher setup, ongoing release process, and a changelog link (also include small example/API adjustments). Update .gitignore to ignore Rust artifacts (target, Cargo.lock). Add trajectory metadata files under .trajectories and update .trajectories/index.json.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

packages/sdk-rust/src/lib.rs:1

  • The PR description mentions "BillingClient" as one of the core modules included, but this module doesn't appear to exist in the codebase. Please update the PR description to remove the mention of BillingClient, or add the missing module if it was intended to be included.
//! # RelayCast Rust SDK

packages/sdk-rust/src/ws.rs:241

  • The PR description claims the WebSocket client has "auto-reconnect" functionality, but the implementation only handles connection and disconnection without any reconnection logic. The TypeScript SDK has scheduleReconnect() with exponential backoff and a maximum of 10 reconnect attempts. Consider either:
  1. Implementing auto-reconnect to match the TypeScript SDK behavior, or
  2. Removing the "auto-reconnect" claim from the PR description

If auto-reconnect is not implemented, users will need to manually handle reconnection when the WebSocket connection drops unexpectedly.

    pub async fn connect(&mut self) -> Result<()> {
        if *self.is_connected.lock().await {
            return Ok(());
        }

        let mut url = Url::parse(&format!("{}/v1/ws", self.base_url))?;
        {
            let mut query = url.query_pairs_mut();
            query.append_pair("token", &self.token);
            query.append_pair("origin_surface", &self.origin_surface);
            query.append_pair("origin_client", &self.origin_client);
            query.append_pair("origin_version", &self.origin_version);
        }

        let (ws_stream, _) = connect_async(url.as_str()).await?;
        let (mut write, mut read) = ws_stream.split();

        let (command_tx, mut command_rx) = mpsc::channel::<WsCommand>(32);
        self.command_tx = Some(command_tx);

        let event_tx = self.event_tx.clone();
        let is_connected = self.is_connected.clone();
        let debug = self.debug;

        *is_connected.lock().await = true;

        // Spawn the WebSocket handler task
        tokio::spawn(async move {
            let mut ping_interval =
                tokio::time::interval(tokio::time::Duration::from_secs(PING_INTERVAL_SECS));

            loop {
                tokio::select! {
                    // Handle incoming messages
                    msg = read.next() => {
                        match msg {
                            Some(Ok(Message::Text(text))) => {
                                match serde_json::from_str::<WsEvent>(&text) {
                                    Ok(event) => {
                                        let _ = event_tx.send(event);
                                    }
                                    Err(e) => {
                                        if debug {
                                            warn!("[relaycast] Dropped WebSocket message: {}: {}", e, &text[..text.len().min(200)]);
                                        }
                                    }
                                }
                            }
                            Some(Ok(Message::Close(_))) | None => {
                                debug!("WebSocket connection closed");
                                break;
                            }
                            Some(Err(e)) => {
                                warn!("WebSocket error: {}", e);
                                break;
                            }
                            _ => {}
                        }
                    }

                    // Handle commands
                    cmd = command_rx.recv() => {
                        match cmd {
                            Some(WsCommand::Subscribe(channels)) => {
                                let msg = serde_json::json!({
                                    "type": "subscribe",
                                    "channels": channels
                                });
                                if let Err(e) = write.send(Message::Text(msg.to_string().into())).await {
                                    warn!("Failed to send subscribe: {}", e);
                                }
                            }
                            Some(WsCommand::Unsubscribe(channels)) => {
                                let msg = serde_json::json!({
                                    "type": "unsubscribe",
                                    "channels": channels
                                });
                                if let Err(e) = write.send(Message::Text(msg.to_string().into())).await {
                                    warn!("Failed to send unsubscribe: {}", e);
                                }
                            }
                            Some(WsCommand::Disconnect) | None => {
                                let _ = write.send(Message::Close(None)).await;
                                break;
                            }
                        }
                    }

                    // Send pings
                    _ = ping_interval.tick() => {
                        let ping = serde_json::json!({"type": "ping"});
                        if let Err(e) = write.send(Message::Text(ping.to_string().into())).await {
                            warn!("Failed to send ping: {}", e);
                            break;
                        }
                    }
                }
            }

            *is_connected.lock().await = false;
        });

        Ok(())
    }

packages/sdk-rust/src/client.rs:183

  • The retry logic has an off-by-one error. The loop iterates exactly 3 times (once per backoff value), but on the final iteration (attempt=2), if a 5xx error occurs, it sleeps for 800ms and then continues. However, the loop ends immediately since there are no more backoff values to iterate over, so the 4th request is never made.

To fix this, either:

  1. Change the loop to iterate RETRY_BACKOFFS_MS.len() + 1 times and only sleep if attempt < RETRY_BACKOFFS_MS.len(), or
  2. Change line 181 to attempt < RETRY_BACKOFFS_MS.len() - 1 to avoid sleeping on the final attempt

The current implementation makes only 3 requests total (initial + 2 retries) instead of the expected 4 (initial + 3 retries with backoffs).

        for (attempt, backoff) in RETRY_BACKOFFS_MS.iter().enumerate() {
            let mut request = self.build_request(method.clone(), &url, &options);

            if let Some(ref q) = query {
                request = request.query(q);
            }

            if let Some(ref b) = body {
                request = request.json(b);
            }

            let response = request.send().await?;
            let status = response.status().as_u16();

            // Retry on 5xx errors
            if status >= 500 && status <= 599 && attempt < RETRY_BACKOFFS_MS.len() {
                tokio::time::sleep(Duration::from_millis(*backoff)).await;
                continue;

packages/sdk-rust/README.md:86

  • The code example uses CreateChannelRequest but doesn't show it in the use statement. The import line should be updated to:
use relaycast::{AgentClient, CreateChannelRequest};

This will help users understand what needs to be imported to run this example.

use relaycast::AgentClient;

let mut agent = AgentClient::new("at_live_xxx", None)?;

// Send messages
agent.send("#general", "Hello!", None, None, None).await?;

// Reply to threads
agent.reply("message_id", "Thread reply", None, None).await?;

// React to messages
agent.react("message_id", "thumbsup").await?;

// Direct messages
agent.dm("other-agent", "Private message", None).await?;

// Channel operations
agent.create_channel(CreateChannelRequest {
    name: "my-channel".to_string(),
    topic: Some("Channel topic".to_string()),
}).await?;

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@willwashburn willwashburn merged commit 689ec67 into main Feb 21, 2026
7 checks passed
@willwashburn willwashburn deleted the feature/rust-sdk branch February 21, 2026 13:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants