Services & contracts

Publish a reusable capability and call it from another Curio through a versioned contract.

Verified September 11, 2026

On this page

Define the provider#

A public service is a versioned capability implemented by a Curio Backend. This example uses provider ID dev.example.text-tools and service ID dev.example.text-tools. The names are illustrative; no such provider is automatically installed.

Add this fragment to the provider’s manifest, alongside its real Backend configuration:

json
{
  "services": {
    "provides": [{
      "id": "dev.example.text-tools",
      "version": "0.0.1-alpha.1",
      "contract": "contracts/text-tools.json"
    }]
  }
}

The service ID must be under the provider’s Curio namespace. Include the contract and executable in package.files. See Backend integration for process and protocol setup.

Author Rust, export JSON#

Rust is the single authored contract source; JSON Schema is the artifact consumed by Host, CLI, and generators. Authors do not maintain handwritten JSON Schema or a second TypeScript interface.

A Rust Backend registers its actual handler input and output types and exports them through the Backend Kit; see the Backend example. A non-Rust Backend can keep declarations in a standalone Rust exporter. This contract/src/main.rs declares uppercase and progress without implementing their business behavior:

rust
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use trove_contract::ServiceContract;

#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct Input { text: String }

#[derive(Serialize, JsonSchema)]
struct Output { text: String }

#[derive(Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ProgressParams { task_id: String }

#[derive(Serialize, JsonSchema)]
struct Progress { progress: f64 }

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut contract = ServiceContract::new(
        "dev.example.text-tools", "0.0.1-alpha.1",
    )
    .method::<Input, Output>("uppercase")
    .event::<ProgressParams, Progress>("progress");

    let uppercase = contract.methods.get_mut("uppercase").unwrap();
    uppercase.timeout_ms = Some(5_000);
    uppercase.idempotent = true;
    contract.events.get_mut("progress").unwrap().coalesce = "latest".into();

    std::fs::create_dir_all("contracts")?;
    std::fs::write(
        "contracts/text-tools.json",
        serde_json::to_vec_pretty(&contract)?,
    )?;
    Ok(())
}

Use this contract/Cargo.toml for the standalone exporter:

toml
[package]
name = "text-tools-contract"
version = "0.0.1-alpha.1"
edition = "2021"

[dependencies]
trove-contract = { path = "../vendor/trove-backend-0.0.1-alpha.1/contract" }
schemars = "=1.2.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

From the Curio root, update the kit and export:

sh
trove kit update --backend
cargo run --manifest-path contract/Cargo.toml

Add the export command to bindings.generate. Ship contracts/text-tools.json with the package; consumers can generate clients from it. The exporter has no Host, Wire runtime, or TypeScript parser dependency. Business Backends remain free to use Python, Go, JavaScript, or another language while implementing the same Wire requests and lifecycle.

Input schemas follow Rust deserialization rules; results and event payloads follow serialization rules. This keeps input defaults, omitted fields, and explicit output null values distinct. Put constraints and documentation on Rust types, and do not hand-edit generated JSON.

The CLI validates contract structure. The default Host checks registered methods and topics; full business-payload JSON Schema validation is not enabled by default. Providers must still validate input and implement declared behavior. idempotent does not enable retries; service/version must match services.provides.

Declare the consumer dependency#

Save the provider’s actual contract in the consumer project and merge this manifest fragment:

json
{
  "services": {
    "requires": [{ "id": "dev.example.text-tools", "version": ">=0.0.1-alpha.1 <0.0.2", "optional": false }]
  },
  "bindings": {
    "services": [{
      "curio": "dev.example.text-tools",
      "contract": "contracts/text-tools.json",
      "output": "src/generated/text-tools.ts"
    }]
  }
}

Run trove generate, then call from a module under src/:

ts
import { host } from "@trove/plugin-sdk";

const TextTools = await host.curios.get("dev.example.text-tools");
const result = await TextTools.uppercase({ text: "hello" });
console.log(result.text);

The argument to get is a Curio ID. It checks installation, enablement, public capabilities and declared version requirements, then returns a reference without starting a process. A method call or subscription asks Host to start the provider Backend, wait for ready, and forward the operation. It never opens UI. Concurrent calls share one startup.

Acquire A, B and other references separately by ID; Promise.all can prepare them concurrently. Missing providers fail during get with curio.not_installed; disabled providers with curio.disabled; providers without public capabilities with curio.no_public_services. Use host.curios.start(id) to warm a provider explicitly.

services.requires declares compatibility by service ID. It neither installs dependencies nor acts as an access allowlist. Optional means the author designed a fallback; it does not turn a failed call into success.

Generate TypeScript#

Run trove generate, then trove check. Generate runs the optional bindings.generate exporter, validates contracts and creates output. Dev and pack prepare the same files automatically; check is read-only and reports bindings.stale for outdated output.

Each bindings.services entry generates createClient / Client / serviceId. Adding curio also generates src/generated/curios.d.ts, augmenting the SDK’s CurioRegistry with ID → API types. Override the declaration path with bindings.curios and include it in tsconfig. No runtime registry import or manual registration is needed.

A service ID equal to its Curio ID puts methods at TextTools.uppercase(). A service named dev.example.text-tools.files mounts at TextTools.files.xxx(). Multiple services compose one reference; each service exposes its own events member. Namespace segments cannot be then/events; service methods cannot be then/events or collide with child services. Get rejects ambiguity with curio.ambiguous_api. These restrictions belong to the convenience reference API; standalone contract clients can still use original service names.

You may supply your own interface with host.curios.get<MyApi>(id). This generic is a compile-time assertion, not remote compatibility validation; provider contracts and requires declarations are recommended. Standalone createClient modules remain available for service-based composition and do not themselves perform Curio ID discovery. Private generated clients route to @self; their naming and adoption are the author’s choice.

Add events deliberately#

Declare subscription parameters separately from payloads. Coalesce accepts none or latest, but current runtime does not guarantee automatic event merging; providers implement throttling. Release subscriptions when their task ends:

ts
import { host } from "@trove/plugin-sdk";

const TextTools = await host.curios.get("dev.example.text-tools");
const subscription = await TextTools.events.progress(
  { taskId: "preview" },
  event => console.log(event.progress),
);
await subscription.close();
console.log(await subscription.closed);

The Rust kit declares topics with .event::<Params, Payload>("progress"). ctx.emit_for("progress", params, payload) matches the caller, service, topic and exact JSON parameters. Empty-parameter topics can use ctx.emit. Business events are separate from subscription.closed terminal reasons. See the complete subscription protocol.