Skip to main content

Telo SDK (Rust)

The Rust SDK provides the authoring surface for Telo controllers written in Rust. It defines the shared contracts (traits and lifecycle types) that controllers use to plug into the kernel, so module code stays consistent across languages.

Crates​

The SDK is three crates, one package:

  • telorun-sdk (sdk/rust) — what a controller crate depends on, and the only one it should: the controller backends (napi, native), the bridge macros #[controller] and #[function] expand to, and every item of telorun-sdk-core re-exported at the same paths.
  • telorun-sdk-core (sdk/rust/core) — everything but the backends: the serde-facing CEL value types, the typed frame, plain encodings, the value-type reader, the log record model, the controller traits and the controller-protocol conformance run. It has no features and links no backend, so a runtime (the Rust kernel) can hold all of it without compiling one in. It carries @telorun/sdk's version.
  • telorun-sdk-macros (sdk/rust/macros) — the #[controller] and #[function] attributes, re-exported by telorun-sdk. It carries @telorun/sdk's version.

The split is a Rust packaging boundary only: each file still twins its Node counterpart in sdk/nodejs by name.

The CEL value domain is not the SDK's own. telorun-sdk-core depends on telorun-cel-value (cel/rust/value), the crate that declares every CEL value, its invariants and its written text, and re-exports that domain by name — as the Node SDK re-exports @telorun/cel's. A controller crate still depends on telorun-sdk alone and reaches all of it there.

What It Provides​

Status: Both backends are implemented. napi loads a controller into the Node.js kernel; native loads one into the Rust kernel (kernel/rust) over the C ABI in telorun-abi.

  • Controller trait (Controller) — author-facing contract with register, create, invoke, and snapshot hooks. Implement on your struct, add #[controller] to the impl block, and the SDK generates the FFI bindings.
  • Resource context (ResourceContext) — per-resource handle passed to create. Today exposes create_type_validator(type_ref) for resolving named or inline schemas into a DataValidator.
  • Schema validation (DataValidator) — validate(data) returns Ok(()) when the value conforms, otherwise a structured error.
  • Shared error type (ControllerError) — carries code + message; the kernel surfaces code as the structured error code.
  • Data exchange — serde_json::Value is the universal payload type, re-exported as telorun_sdk::Value.
  • CEL value types (Timestamp, Duration, Bytes, Uint64) — the CEL types JSON has no number or string for, in the form a #[derive(Serialize, Deserialize)] struct holds. They are serde-facing wrappers: Timestamp and Duration sit over the domain's CelTimestamp and CelDuration and convert both ways with From, while Uint64 and Bytes mark a u64 and a Vec<u8> as a CEL uint and bytes. Each implements serde's Serialize / Deserialize: it writes its plain encoding (RFC 3339 text in UTC with its fraction trimmed, seconds such as "5400s", base64url, digits) and reads either that or the typed frame's tagged form. A Timestamp holds an instant to the nanosecond and compares as an instant, whatever offset it was written with: it reads any offset and up to nine fractional digits, refusing a tenth rather than rounding it away, and writes 2026-01-15T07:30:00Z or 2026-01-15T07:30:00.000000001Z. A plain i64 is a CEL int; a CEL uint is Uint64.
  • The CEL value domain (CelValue and what it holds) — telorun-cel-value's union, re-exported: for code that handles a value dynamically rather than through a typed struct. Its sixteen variants are Null, Bool, Int (i64), Uint (u64), Double, String, Bytes, List, Map (CelMap), Record (CelRecord), Timestamp (CelTimestamp), Duration (CelDuration), Type (CelTypeValue), Optional (CelOptional), Error (CelError) and Host (CelHostValue). Re-exported beside it: CelEvaluationCode, CelMapKey, SourceRange, ReservedTypeName (the refusal of CelHostValue::named for a name the domain's own values carry), the constructors cel_type_value, cel_some and cel_none, and the functions cel_timestamp, cel_timestamp_from_millis, timestamp_nanos, parse_timestamp, format_timestamp, cel_duration_from_nanos, duration_nanos, duration_nanos_from_text, parse_duration, format_duration, cel_map_from_entries and cel_map_keys. Every one of these types can be built through those names: one with private fields through its constructor, and CelError, whose fields are public, by literal. Two values compare with == by identity in the domain, never by CEL's equality: Int(1) is not Uint(1), and NaN equals NaN.
  • Two map representations. A CelRecord is a map whose keys are all strings — what a struct, a HashMap<String, _> and an untagged JSON object become. A CelMap holds int, uint, bool and string keys together, built with cel_map_from_entries, which refuses a key of any other type and two keys CEL equality makes one (1 and 1u). A Map whose keys are all strings equals a Record of the same pairs.
  • Typed frame (typed_frame) — to_frame / from_frame carry any serde value through the frame internal boundaries use (kernel/specs/durable-execution.md §6), byte-identical to the Node SDK on the shared conformance vectors; encode_typed_frame / decode_typed_frame do the same for a CelValue. Four variants have no frame and are refused on write with ERR_TYPED_FRAME_UNENCODABLE at the value's path: Type, Optional, Error and Host. to_frame / to_value refuse what serde hands them and the frame cannot carry — an integer beyond i64 (a CEL uint is Uint64), a map with a key that is not an int, uint, bool or string, a map with two keys that are one key — with the same code and the JSON Pointer of the offending node (the map itself for a key). An untagged object reads as a Record and a tagged map as a CelMap; the writer orders members itself, so neither representation's own order reaches the text. A duration is written and read within protobuf's range of ±315,576,000,000 s, which is wider than the range CEL computes in.
  • No plain JSON writer. The Node SDK's plain-json.ts, the writer for readers outside Telo, has no Rust twin: nothing in the Rust half writes to such a reader. A Rust controller that serializes a value for one itself goes through serde_json, which differs from the Node writer on doubles — NaN and ±Infinity become null (Node writes "NaN", "Infinity", "-Infinity") and a negative zero stays -0.0 (Node writes 0).

Author principle: Rust developers write Rust, nothing else. A controller crate is Cargo.toml + src/*.rs — no build.rs, no package.json, no JS tooling, no awareness of which kernel will load it.

use telorun_sdk::{controller, Controller, InvokeContext, ResourceContext, Result, Value};

pub struct MyController {
code: String,
}

#[controller]
impl Controller for MyController {
fn create(manifest: Value, _ctx: &dyn ResourceContext) -> Result<Self> {
Ok(MyController {
code: manifest["code"].as_str().unwrap_or("").to_string(),
})
}

fn invoke(&self, input: Value, ctx: &InvokeContext) -> Result<Value> {
if ctx.cancellation.is_cancelled() {
return Ok(Value::Null);
}
Ok(serde_json::json!({ "echoed": input }))
}
}

Place this in modules/<your-name>/rust/src/lib.rs and reference it from your module's telo.yaml:

controllers:
- pkg:cargo/<your-cargo-name>?local_path=./rust

Functions​

A callable kind (capability: Telo.Callable with controllers:) is implemented by the Function trait: Config, Args and Output are your own serde types, create(config, ctx) builds one instance per resource, and call(&self, args) answers one CEL call — synchronously, since a CEL expression has nowhere to wait. #[function(entry = "…")] exports it under the entry a PURL's #fragment names; the entry defaults to the snake_case of the type.

use serde::Deserialize;
use telorun_sdk::{function, Function, FunctionContext, Result, Timestamp, Value};

pub struct IsBefore;

#[derive(Deserialize)]
pub struct Instants { a: Timestamp, b: Timestamp }

#[function(entry = "is_before")]
impl Function for IsBefore {
type Config = Value;
type Args = Instants;
type Output = bool;

fn create(_config: Value, _ctx: &dyn FunctionContext) -> Result<Self> { Ok(IsBefore) }
fn call(&self, args: Instants) -> Result<bool> { Ok(args.a < args.b) }
}
controllers:
- pkg:cargo/<your-cargo-name>?local_path=./rust#is_before

FunctionContext offers logging and nothing else. What create allocates is released by Drop: the kernel destroys the instance at teardown and on reload. The configuration crosses as the resource's plain JSON, and arguments and results as typed frames, so a timestamp, an int64, a Uint64 or Bytes keeps its CEL type both ways; the Node kernel has already filled defaults and validated the arguments against the declared params, and validates the result against returns. An Err or a panic fails the calling expression as ERR_FUNCTION_FAILED, carrying your code — ERR_CONTROLLER_PANIC for a panic. A configuration the kind's schema accepted but your Config type cannot read fails creation with ERR_FUNCTION_CONFIG_INVALID: the schema and the type disagree. An entry must be ASCII letters, digits and underscores, or the attribute is a compile error. An instance the garbage collector releases before the kernel destroyed it still runs Drop; a panic there is written to stderr, since there is no caller left to report it to.

Natively a function is the telo_function__<entry> symbol returning telorun-abi's TeloFunction vtable (create, call, destroy, free, with a TeloFunctionHost whose one slot is log), part of ABI version 3 (abi=telo-3). The Rust kernel evaluates no CEL, so it hosts no functions; under napi the entry is a namespace exporting createFunction.

When to Use It​

Use the SDK when building or extending Telo controllers in Rust. It is not the kernel itself; it is the contract layer that keeps controller behavior consistent across the polyglot runtime.

The SDK ships two backends, gated by Cargo features:

  • napi — N-API bindings for the Node.js kernel.
  • native — a C-ABI vtable export for the Rust kernel, defined in abi/.

There is no default backend, deliberately. A controller crate carries no [features] block, so the backend can only be chosen from outside it — and --no-default-features on that build would apply to the controller crate, not to this dependency. Each kernel therefore selects one as a dependency feature when it builds the crate:

KernelBuild invocation
Node.jscargo build --release --features telorun-sdk/napi
Rustcargo build --release --features telorun-sdk/native

A bare cargo check / cargo clippy / rust-analyzer run compiles the traits and your impl with no bridge at all, which is what keeps the inner loop working on a fresh clone.

Your controller's source and Cargo.toml do not change between kernels. The Controller trait and serde_json::Value are backend-independent; only the generated bridge differs.

Under napi, an integer in a Value your controller returns reaches the Node.js kernel as a JavaScript number when it lies within ±(2^53−1), and as a BigInt — a CEL int — beyond it, whatever its sign, so no integer arrives rounded.

#[controller] takes an optional entry naming the exported controller, which is what a pkg:cargo PURL's #fragment selects:

#[controller(entry = "writeline_controller")]
impl Controller for WriteLine { /* … */ }

Omit it and the entry defaults to the snake_case of the type, and the crate's default entry is exported — the one a fragment-less PURL resolves to. Two entry-less controllers in one crate collide at link time, which is the right failure: only one can be "the crate's controller".

The entry means the same thing on both backends. Natively it names the exported C symbol; under napi it becomes the export namespace, so the Node loader projects module.<entry>.create. That is also what lets one crate carry several controllers under napi, where every bridge would otherwise export a flat create / register.

Today the SDK covers Telo.Runnable / Telo.Invocable capabilities. Service, Mount, and Provider are not yet in the trait set and are added as controllers need them.

Errors​

Telo distinguishes two kinds of failure from an Invocable / Runnable:

  • Operational failures — anything other than a declared domain error. In the current Rust surface, this is any ControllerError whose code is not declared in the controller's Telo.Definition, plus panics, I/O failures, validator rejections (ERR_VALIDATION_FAILED), and serde errors (ERR_JSON). These propagate to the kernel's infrastructure layer (HTTP → Fastify 5xx, sequence → bubbles up) and represent bugs or environment failure.
  • Domain failures — errors whose code is part of the invocable's public contract (e.g. UNAUTHORIZED, EXPIRED). Route handlers match on the code via catches: entries; sequences handle them in try/catch. The Node.js SDK exposes a dedicated InvokeError type for this channel; the Rust SDK uses ControllerError with a declared code for now, and will gain a structured-error type matching the Node.js shape as the channel solidifies on the Rust side.
use telorun_sdk::{ControllerError, Result, Value};

fn verify(token: &str) -> Result<Value> {
if token.is_empty() {
return Err(ControllerError::new(
"UNAUTHORIZED",
"Token missing or invalid",
));
}
Ok(Value::Null)
}

A ControllerError your controller returns crosses to either kernel as two fields, never as text. The Rust kernel reads code and message from the C ABI's error payload. On the Node.js kernel, the napi bridge throws a JavaScript error whose .code is the controller's code and marks it as the controller's own, and the loader re-raises it as an InvokeError. So try: / catches: match a Rust controller's code the same way they match a JavaScript controller's, from register, create and invoke alike. Except where a call back into JavaScript threw: that exception is still pending and is what reaches the kernel, whatever code the controller returns in its place.

A value the napi bridge cannot hand to the controller — a byte chunk, a stream, anything JSON cannot represent — is refused before invoke runs. That refusal is the bridge's, not the controller's error, so it fails the dispatch with ERR_EXECUTION_FAILED and napi's message, as a JavaScript controller's plain Error does — a catch: sees it as INTERNAL_ERROR.

Controllers that return domain errors must declare their codes in their Telo.Definition:

kind: Telo.Definition
metadata: { name: VerifyToken }
capability: Telo.Invocable
throws:
codes:
UNAUTHORIZED: { description: Missing or invalid token. }
EXPIRED:
description: Token is past its expires_at.
data:
type: object
properties:
expiredAt: { type: string, format: date-time }
required: [expiredAt]

Undeclared codes emit an ${kind}.${name}.InvokeRejected.Undeclared observability event — the analyzer catches these statically, regardless of which SDK the controller is written in.

Composers that propagate rather than originate codes can declare:

throws:
inherit: true # union of everything I call (requires x-telo-step-context)
# or
passthrough: true # union is whatever my inputs.code resolves to (Run.Throw-style)

inherit is driven by the analyzer's dataflow pass over x-telo-step-context arrays. See modules/run/docs/structured-errors.md for the end-to-end flow.