The Rust libraries give services, CLIs, devices, and other participants a typed async interface to Trellis. Generated Cargo SDKs provide typed request, event, feed, and operation descriptors; participant facades wrap those descriptors in small, contract-shaped APIs.

Use this page as a practical map of the Rust surface. For exact signatures, see the Rustdoc links on the API Reference.

Crates

The normal Rust entrypoint is the trellis-rs Cargo package, imported as the trellis_rs Rust crate. It re-exports the public runtime modules you usually need:

  • trellis_rs::client: outbound connection, RPC, events, feeds, operations, transfers, state, and auth helpers.
  • trellis_rs::service: service runtime, handler registration, event publishing, service resources, operation providers, and service errors.
  • trellis_rs::contracts: contract manifest and schema helper types.
  • trellis_rs::jobs: service-local jobs types.
  • trellis_rs::sdk::*: Trellis-owned generated SDK surfaces embedded in the runtime crate.

Project contracts generate Cargo SDK crates under generated/packages/cargo/. Those crates contain schema-derived Rust types, descriptor types, client helpers, and service registration helpers.

Rust participant facades are generated for participant contracts. A facade crate usually exposes:

  • connect_service(...) for service participants
  • connect_user(...) for user/app/CLI style participants
  • ConnectedService and ConnectedClient
  • Client<'_> and Service<'_> facades
  • owned, state, and uses::<alias> modules

Connect as a service

Service participant facades provide the most convenient service entrypoint.

use trellis_sdk_orders_service::{connect_service, ServiceConnectOptions};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let mut service = connect_service(ServiceConnectOptions::new(
        "http://localhost:3000",
        "orders-service",
        &std::env::var("ORDERS_SERVICE_SESSION_KEY_SEED")?,
    ))
    .await?;

    service.run().await?;
    Ok(())
}

The generated helper presents the contract manifest, authenticates the service instance, resolves resource bindings, and owns the service runtime loop.

Connect as an app, CLI, or other user participant

User-style participants use the generated facade’s connect_user(...) helper when available, then call through the typed client() facade.

use orders_console_participant::connect_user;
use trellis_rs::client::UserConnectOptions;

let connected = connect_user(UserConnectOptions {
    servers: "nats://127.0.0.1:4222",
    sentinel_jwt,
    sentinel_seed,
    session_key_seed_base64url: session_key_seed,
    contract_digest: orders_console_participant::contract::CONTRACT_DIGEST,
    timeout_ms: 30_000,
})
.await?;

let client = connected.client();
let order = client
    .orders()
    .get(&orders_sdk::OrdersGetRequest {
        order_id: "ord_123".into(),
    })
    .await?;

println!("{}", order.status);

Use the generated facade and Rustdoc for the exact bootstrap path your app or CLI uses. Some programs receive NATS and sentinel connection details from a Trellis HTTP bootstrap or local configuration before constructing UserConnectOptions.

Connect as a device

Device programs use the same generated descriptors and facades after activation. Activation establishes the device identity and deployment approval; the connected client then exposes only the selected surfaces from the device participant contract.

If you need the lower-level connection primitives, use trellis_rs::client types such as DeviceConnectOptions and TrellisClient. Prefer generated participant facades when your project has them.

Surface facades

Generated Rust facades use snake_case method names derived from Trellis surface names. For example, Orders.Get becomes orders_get(...) in a flat alias facade, or client.owned().orders_get(...) for a participant’s owned surface. Service provider facades group handler registration by surface kind.

RPC

Generated SDK clients expose descriptor-backed RPC helpers.

use orders_sdk::client::OrdersServiceClient;
use orders_sdk::OrdersGetRequest;

let orders = OrdersServiceClient::new(trellis_client);
let response = orders
    .rpc()
    .orders()
    .get(&OrdersGetRequest {
        order_id: "ord_123".into(),
    })
    .await?;

Participant alias facades provide smaller methods for the surfaces selected by the participant contract.

let response = participant
    .client()
    .orders()
    .orders_get(&orders_sdk::OrdersGetRequest {
        order_id: "ord_123".into(),
    })
    .await?;

Events

Use generated event descriptors for publish and subscribe.

use futures_util::StreamExt;
use orders_sdk::events::OrdersShippedEventDescriptor;
use orders_sdk::OrdersShippedEvent;

trellis_client
    .publish::<OrdersShippedEventDescriptor>(&OrdersShippedEvent {
        order_id: "ord_123".into(),
        customer_id: "cust_456".into(),
        shipped_at: now_iso(),
    })
    .await?;

let mut events = trellis_client
    .subscribe::<OrdersShippedEventDescriptor>()
    .await?;

while let Some(event) = events.next().await {
    let event = event?;
    tracing::info!(order_id = %event.order_id, "order shipped");
}

For explicit ack/nak/term control or event metadata such as event id/time, use subscribe_messages(...). Generated event structs are event bodies only; runtime metadata comes from the message headers.

Direct publish::<Descriptor>(...) is the normal default. It prepares the event payload, assigns event metadata, publishes to JetStream, and returns when the publish is acknowledged.

Use prepare_event::<Descriptor>(...) plus an outbox only when publishing must be coupled to service-local database state. PreparedTrellisEvent preserves the subject, encoded payload, and message metadata for later publishing, but intentionally carries no contract id or contract digest. Persisted outbox rows therefore do not depend on the publisher’s current deployment metadata.

use orders_sdk::events::OrdersShippedEventDescriptor;
use orders_sdk::OrdersShippedEvent;
use trellis_rs::client::{dispatch_outbox_once, OutboxStore, SqliteOutboxStore};

let prepared = trellis_client.prepare_event::<OrdersShippedEventDescriptor>(
    &OrdersShippedEvent {
        order_id: "ord_123".into(),
        customer_id: "cust_456".into(),
        shipped_at: now_iso(),
    },
)?;

let tx = sqlite.transaction()?;
update_order_status(&tx, "ord_123", "shipped")?;

{
    // Use the same transaction connection so local state and the outbox row
    // commit or roll back together.
    let mut outbox = SqliteOutboxStore::new(&tx);
    outbox.enqueue("outbox:ord_123:shipped", &prepared).await?;
}

tx.commit()?;

let mut outbox = SqliteOutboxStore::new(&sqlite);
dispatch_outbox_once(&mut outbox, |event| async {
    trellis_client.publish_prepared(&event).await
})
.await?;

SQL-backed services own migrations and transaction boundaries. The SQLite and Postgres stores are adapters over caller-owned connections; put the Trellis outbox/inbox tables into your normal service migration flow, and use your service’s database transaction when the event enqueue must be atomic with local state.

Use an inbox only when a handler is not naturally idempotent. If replaying the message would be harmless because the handler writes by a stable business key, no inbox is needed.

use trellis_rs::client::{InboxReceipt, InboxStore, SqliteInboxStore};

let mut inbox = SqliteInboxStore::new(&sqlite);
let Some(event_id) = message.event_id() else {
    return Err(anyhow::anyhow!("event message missing Nats-Msg-Id"));
};

match inbox.record_received(event_id).await? {
    InboxReceipt::Accepted => apply_non_idempotent_side_effect(message.payload()).await?,
    InboxReceipt::Duplicate => return Ok(()),
}

NatsKvOutboxStore and NatsKvInboxStore are durable NATS KV queue and dedupe helpers for services that do not have SQL state. They are not transactional with unrelated database side effects, so do not use them to claim that an arbitrary DB write and an event enqueue committed atomically.

Feeds

Feeds return async streams of typed events.

use futures_util::StreamExt;
use orders_sdk::feeds::OrdersLiveFeedDescriptor;
use orders_sdk::OrdersLiveInput;

let mut stream = trellis_client
    .feed::<OrdersLiveFeedDescriptor>(&OrdersLiveInput {
        customer_id: Some("cust_456".into()),
    })
    .await?;

while let Some(frame) = stream.next().await {
    let frame = frame?;
    println!("{:?}", frame);
}

Operations

Operations are caller-visible async workflows. Start them with a generated operation descriptor, then wait or watch progress.

use futures_util::StreamExt;
use orders_sdk::operations::OrdersProcessOperation;
use orders_sdk::OrdersProcessInput;
use trellis_rs::client::OperationEvent;

let op = trellis_client
    .operation::<OrdersProcessOperation>()
    .start(&OrdersProcessInput {
        order_id: "ord_123".into(),
    })
    .await?;

let terminal = op.wait().await?;
println!("{:?}", terminal.output);

let mut events = op.watch().await?;
while let Some(event) = events.next().await {
    match event? {
        OperationEvent::Progress { snapshot } => println!("{:?}", snapshot.progress),
        OperationEvent::Completed { snapshot } => {
            println!("{:?}", snapshot.output);
            break;
        }
        _ => {}
    }
}

Participant alias facades expose selected operations as methods returning an OperationInvoker.

let op = participant
    .client()
    .orders()
    .orders_process()
    .start(&orders_sdk::OrdersProcessInput {
        order_id: "ord_123".into(),
    })
    .await?;

Service provider APIs

Service participant facades provide generated handler registration methods. Register handlers before service.run().await?.

use orders_sdk::{OrdersCreateRequest, OrdersCreateResponse};
use trellis_rs::service::{HandlerResult, ServiceHandlerContext};

async fn create_order(
    _ctx: ServiceHandlerContext,
    input: OrdersCreateRequest,
) -> HandlerResult<OrdersCreateResponse> {
    Ok(OrdersCreateResponse {
        order_id: input.order_id,
        status: "pending".into(),
    })
}

service.handle().rpc().orders().create(create_order);

Feed providers return a stream of typed events.

service.handle().feed().orders().live(move |_ctx, input| {
    orders_live_stream(input)
});

Operation providers implement the generated operation descriptor through the trellis_rs::service::ServiceOperationProvider trait, or use lower-level operation runtime types when you need explicit control over accept, get, wait, watch, and cancel paths.

service
    .handle()
    .operation()
    .orders()
    .process(orders_process_provider);

Service code can also publish its own events through the service runtime.

service
    .publish_orders_shipped(&orders_sdk::OrdersShippedEvent {
        order_id: "ord_123".into(),
        customer_id: "cust_456".into(),
        shipped_at: now_iso(),
    })
    .await?;

Rust Result, async, and streams

Rust Trellis APIs use ordinary Result<T, E> and async functions.

  • Outbound client calls usually return Result<T, trellis_rs::client::TrellisClientError>.
  • Service handlers return trellis_rs::service::HandlerResult<T>, an alias for a service result type.
  • Event subscriptions and feeds use futures_util::Stream values whose items are also Results.
  • Operation refs expose async control methods such as wait(), cancel(), and watch().

Handle declared business failures separately from unexpected platform failures when your contract models them. Use normal Rust error propagation for boundary code where returning the error to the caller is appropriate.

Rustdoc

Use the API Reference for Rustdoc links and pending Rustdoc status. The guides show common patterns, while Rustdoc is the place to verify exact structs, trait bounds, constructor options, and generated facade methods.