Docs

Writing a service

Declaring an api gets you a validated system and a deploy that refuses:

deploy: component `api` declares a `cargo` toolchain, so the deploy reads a
crate manifest at systems/shop/src/components/api/main/ and there is none.

This page is that crate.

Before you can build

While the core is unpublished, a system's crates depend on it by path, so building requires a checkout of the platform repository beside your own:

[workspace.dependencies]
bluetext        = { path = "../bluetext/crates/bluetext" }
bluetext-macros = { path = "../bluetext/crates/bluetext-macros" }

bluetext is not on crates.io. Declaring and validating a system needs nothing but b; compiling one needs that checkout. Everything below is what the code looks like once you have it.

The workspace

A system is its own cargo workspace, at the system's root, and it is the one place the core is pinned:

# systems/shop/Cargo.toml
[workspace]
members  = ["src/models", "src/components/api/main"]
resolver = "3"

[workspace.dependencies]
bluetext        = { path = "../bluetext/crates/bluetext" }
bluetext-macros = { path = "../bluetext/crates/bluetext-macros" }

Members inherit with { workspace = true } and none spells a path of its own, so a moved checkout is one edit.

The component's crate

# systems/shop/src/components/api/main/Cargo.toml
[package]
name    = "api"
version = "0.1.0"
edition = "2024"

[dependencies]
bluetext        = { workspace = true }
bluetext-macros = { workspace = true }
shop_models     = { path = "../../../models" }
wire            = { package = "api_wire", path = "../wire" }
tokio           = { version = "1", features = ["rt-multi-thread", "macros", "net"] }

[features]
default               = ["bluetext-unconfigured"]
bluetext-unconfigured = []
bluetext-development        = []

Two entries are not obvious.

wire is generated. b generate writes that crate from the shapes your endpoints name, so it exists before you build and you never edit it.

One feature per environment, plus bluetext-unconfigured as the default. A deploy builds with the feature for the environment it is landing, and that feature is what compiles this environment's resolved answers into the artifact. The default is a sentinel: a binary built without any of them refuses at startup rather than running with no configuration. Add a feature here for every environment your system declares.

The handlers

Handlers live in a module marked as the interface's endpoint set. The platform holds the module to the declaration: a handler whose name is not a declared endpoint, or whose types disagree with the shapes, does not compile.

// systems/shop/src/components/api/main/src/main.rs
use ::bluetext::Deadline;
use shop_models::{Context, OrdersCore};

#[bluetext_macros::endpoint_set(interface = "orders")]
pub mod orders {
    use super::{Context, Deadline, OrdersCore};

    /// The posture the endpoint file's `caller = { admit = "admit_anyone" }` names.
    pub async fn admit_anyone(_part: &OrdersCore) -> Context {
        Context
    }

    /// `GET /orders`
    #[bluetext_macros::endpoint]
    pub async fn list_orders(
        part: &OrdersCore,
        _ctx: Context,
        deadline: Option<Deadline>,
    ) -> Result<wire::OrderListWire, wire::OrderRefusalWire> {
        // …
    }
}

Read the signature, because it is the whole contract:

  • part is the business logic, handed in. A handler holds no state of its own and opens no connection.
  • ctx is what the admission function returned, so whatever a handler knows about its caller was decided in one place.
  • deadline is the timeout the endpoint file declared, arriving as a value the part can honour.
  • Result<response, refusal> is the endpoint's two declared shapes. Ok is the response; Err is the refusal, which is a normal answer rather than an error.

When something is neither

A store that will not answer is not an empty list, and it is not the endpoint's declared refusal either. There is no third channel in the signature, so a handler panics, and the platform renders that as a 500. A calling driver reads that as the peer is there and could not answer and retries, while the message stays in your log. Reaching for Ok with an empty body is the mistake that shape exists to prevent: it tells the caller there is nothing, which is a different and false statement.