Building capabilities
We covered effects and commands in detail, and hinted throughout at capabilities — the developer-friendly APIs you actually use when writing core code. Time to look at them directly, both using them and building our own.
In practice, apps need a fairly limited number of capabilities — typically around seven, almost certainly fewer than ten. The weather app uses six: Render, KeyValue, Http, Location, Secret, and Time. Capabilities are reusable across apps — if you build one that others would benefit from, the Crux team would like to hear about it.
Using a capability
Capabilities don't return a Command directly — they return a command builder, which lets you chain behaviour before committing to a specific event. We saw the abstract shape in chapter 5: Http::get(...).expect_json().build().then_send(Event::ReceivedResponse).
The weather app's current-weather fetch shows the same pattern in production code:
#![allow(unused)] fn main() { /// Fetch current weather for a specific location #[must_use] pub fn fetch<Effect, Event>( location: Location, api_key: ApiKey, ) -> RequestBuilder< Effect, Event, impl std::future::Future<Output = Result<CurrentWeatherResponse, WeatherError>>, > where Effect: From<Request<HttpRequest>> + Send + 'static, Event: Send + 'static, { Http::get(WEATHER_URL) .expect_json::<CurrentWeatherResponse>() .query(&CurrentWeatherQuery { lat: location.lat.to_string(), lon: location.lon.to_string(), units: "metric", appid: api_key.into(), }) .expect("could not serialize query string") .build() .map(|result| match result { Ok(mut response) => response .take_body() .map_or(Err(WeatherError::ParseError), |weather_data| { Ok(weather_data) }), Err(crux_http::HttpError::Http { code, .. }) if code == StatusCode::UNAUTHORIZED || code == StatusCode::FORBIDDEN => { Err(WeatherError::Unauthorized) } Err(_) => Err(WeatherError::NetworkError), }) } }
Http::get(...) starts a builder, .expect_json::<T>() pins down the response type, .query(...) adds URL parameters, .build() produces a RequestBuilder, and .map(...) translates the shell's Result<Response, HttpError> into the more convenient Result<CurrentWeatherResponse, WeatherError>. The caller finishes it off with .then_send(SomeEvent) — fetch returns a builder, not a command, so callers can hook it into their own event type.
That's how a capability gets used. But where do these APIs come from? Let's build one.
A simple custom capability: Location
Render ships in crux_core; crux_http, crux_kv, and crux_time are separate crates Crux publishes. Location services aren't — they work differently enough across platforms that a cross-platform crate would do more harm than good, and they're specific enough that we didn't want to maintain an official one either. So the weather app defines its own.
If you're upgrading an existing app, note that
crux_http0.19 switched fromhttp-typesto the standardhttpcrate — see the Migratingcrux_httpto nativehttptypes guide.
A capability is two things:
- A protocol for talking to the shell — an operation type and a response type.
- An ergonomic API for the core developer — usually a handful of command-builder functions.
Here's the whole protocol for Location:
#![allow(unused)] fn main() { //! A custom capability for accessing the device's location. //! //! Two operations — checking whether location services are enabled and //! fetching the current coordinates — exchanged with the shell through //! [`LocationOperation`] and [`LocationResult`]. The developer-facing //! command builders live in the [`command`] submodule. pub mod command; use crux_core::capability::Operation; use facet::Facet; use serde::{Deserialize, Serialize}; /// Geographic coordinates as returned by the shell. #[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] pub struct Location { pub lat: f64, pub lon: f64, } /// Operations the core can ask the shell to perform. #[derive(Facet, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[repr(C)] pub enum LocationOperation { /// Ask whether location services are currently enabled and authorised. IsLocationEnabled, /// Ask for the device's current coordinates. GetLocation, } /// Values the shell can return in response to a [`LocationOperation`]. #[derive(Facet, Clone, Serialize, Deserialize, Debug, PartialEq)] #[repr(C)] pub enum LocationResult { /// Whether location services are enabled and authorised. Enabled(bool), /// The current location, or `None` if the shell couldn't determine it. Location(Option<Location>), } impl Operation for LocationOperation { type Output = LocationResult; } }
Two operation variants (IsLocationEnabled, GetLocation), two result variants (Enabled(bool), Location(Option<Location>)), and an impl Operation for LocationOperation pairing them. The Operation trait is Crux's way of saying "when you see this operation, expect this response type" — the macro-generated Effect type uses it so the core and shell agree on the wire format.
The developer API is equally small:
#![allow(unused)] fn main() { //! Command builders for the [location capability](super). //! //! Each builder issues one [`LocationOperation`] and narrows the shell's //! [`LocationResult`] to the specific type the caller cares about. They're //! generic over `Effect` and `Event` so they can be reused from any Crux //! app whose `Effect` type can wrap a location request. use std::future::Future; use crux_core::{Command, Request, command::RequestBuilder}; use super::{Location, LocationOperation, LocationResult}; /// Asks the shell whether location services are currently enabled. #[must_use] pub fn is_location_enabled<Effect, Event>() -> RequestBuilder<Effect, Event, impl Future<Output = bool>> where Effect: Send + From<Request<LocationOperation>> + 'static, Event: Send + 'static, { Command::request_from_shell(LocationOperation::IsLocationEnabled).map(|result| match result { LocationResult::Enabled(val) => val, LocationResult::Location(_) => false, }) } /// Asks the shell for the device's current coordinates. #[must_use] pub fn get_location<Effect, Event>() -> RequestBuilder<Effect, Event, impl Future<Output = Option<Location>>> where Effect: Send + From<Request<LocationOperation>> + 'static, Event: Send + 'static, { Command::request_from_shell(LocationOperation::GetLocation).map(|result| match result { LocationResult::Location(loc) => loc, LocationResult::Enabled(_) => None, }) } }
Each function issues one operation and narrows the response. is_location_enabled returns bool; get_location returns Option<Location>. The shared LocationResult carries both variants, so each .map(...) pins the response to the one that operation expects and falls back to a safe default for the other — false for the enabled check, None for the location fetch. Secret, later in the chapter, uses unreachable!() for the same situation; both patterns have their place.
Notice the generic signatures: both functions are generic over Effect and Event. The trait bound Effect: From<Request<LocationOperation>> says the caller's Effect type must be able to wrap a location request — every #[effect]-generated enum implements this automatically, so the bound is always satisfied in practice. Being generic lets us drop this capability into any Crux app, not just this one.
A richer example: Secret
Location is about as minimal as a capability gets. Secret — storing, fetching, and deleting an API key — has a bit more going on, and it shows a pattern worth calling out.
Narrowing the shell's response
The shell's SecretResponse is a single enum with six variants: Missing, Fetched, Stored, StoreError, Deleted, DeleteError. Each operation has its own pair: Fetch produces Missing or Fetched, Store produces Stored or StoreError, and Delete produces Deleted or DeleteError. If a caller holds a SecretResponse directly, the type doesn't tell them which operation it's responding to — they'd have to handle variants that can't apply to their call.
The capability fixes this by defining three narrower response types — SecretFetchResponse, SecretStoreResponse, SecretDeleteResponse — and having each command builder return its own. The wide SecretResponse stays as the shell protocol; the core developer only ever sees the narrowed versions.
Here's the protocol:
#![allow(unused)] fn main() { //! A custom capability for storing and retrieving secrets (e.g. API keys). //! //! The shell-facing protocol is intentionally simple: three operations //! (fetch, store, delete) with one [`SecretResponse`] enum covering all //! outcomes. The developer-facing command builders in the [`command`] //! submodule narrow that wide response into smaller per-operation types //! ([`SecretFetchResponse`], [`SecretStoreResponse`], //! [`SecretDeleteResponse`]) so callers only see the variants that apply. pub mod command; use crux_core::capability::Operation; use facet::Facet; use serde::{Deserialize, Serialize}; /// The key under which the weather API key is stored. pub const API_KEY_NAME: &str = "openweather_api_key"; /// Operations the core can ask the shell to perform. #[derive(Facet, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[repr(C)] pub enum SecretRequest { /// Fetch the secret stored under the given key (if any). Fetch(String), /// Store `value` under `key`, replacing any existing value. Store(String, String), /// Delete the secret stored under the given key. Delete(String), } impl Operation for SecretRequest { type Output = SecretResponse; } /// The shell-facing response — every variant any operation might produce. /// /// The developer-facing command builders narrow this down to the variants /// a specific operation can actually return. #[derive(Facet, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[repr(C)] pub enum SecretResponse { /// Fetch: no secret stored under this key. Missing(String), /// Fetch: here's the key and its stored value. Fetched(String, String), /// Store: the secret was stored successfully. Stored(String), /// Store: storing failed — the string carries the error message. StoreError(String), /// Delete: the secret was removed. Deleted(String), /// Delete: deletion failed — the string carries the error message. DeleteError(String), } /// The developer-facing response for [`command::fetch`]. #[derive(Facet, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[repr(C)] pub enum SecretFetchResponse { /// No secret is stored under this key. Missing(String), /// The stored secret value. Fetched(String), } /// The developer-facing response for [`command::store`]. #[derive(Facet, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[repr(C)] pub enum SecretStoreResponse { /// The secret was stored successfully under `key`. Stored(String), /// Storage failed; the string carries the error message. StoreError(String), } /// The developer-facing response for [`command::delete`]. #[derive(Facet, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[repr(C)] pub enum SecretDeleteResponse { /// The secret was removed. Deleted(String), /// Deletion failed; the string carries the error message. DeleteError(String), } }
And the developer API:
#![allow(unused)] fn main() { //! Command builders for the [secret capability](super). //! //! Each builder issues one [`SecretRequest`] and narrows the shell's wide //! [`SecretResponse`] down to the [`SecretFetchResponse`], //! [`SecretStoreResponse`], or [`SecretDeleteResponse`] that's relevant //! to that operation. They're generic over `Effect` and `Event` so any //! Crux app can adopt them. use std::future::Future; use crux_core::Request; use crux_core::command::RequestBuilder; use super::{ SecretDeleteResponse, SecretFetchResponse, SecretRequest, SecretResponse, SecretStoreResponse, }; /// Fetches the secret stored under `key`, if any. #[must_use] pub fn fetch<Ef, Ev>( key: impl Into<String>, ) -> RequestBuilder<Ef, Ev, impl Future<Output = SecretFetchResponse>> where Ef: From<Request<SecretRequest>> + Send + 'static, Ev: Send + 'static, { let key = key.into(); crux_core::Command::request_from_shell(SecretRequest::Fetch(key)).map(|response| match response { SecretResponse::Missing(key) => SecretFetchResponse::Missing(key), SecretResponse::Fetched(_, value) => SecretFetchResponse::Fetched(value), _ => unreachable!("fetch only produces Missing or Fetched"), }) } /// Stores `value` under `key`, replacing any existing secret. #[must_use] pub fn store<Ef, Ev>( key: impl Into<String>, value: impl Into<String>, ) -> RequestBuilder<Ef, Ev, impl Future<Output = SecretStoreResponse>> where Ef: From<Request<SecretRequest>> + Send + 'static, Ev: Send + 'static, { let key = key.into(); let value = value.into(); crux_core::Command::request_from_shell(SecretRequest::Store(key, value)).map(|response| { match response { SecretResponse::Stored(key) => SecretStoreResponse::Stored(key), SecretResponse::StoreError(msg) => SecretStoreResponse::StoreError(msg), _ => unreachable!("store only produces Stored or StoreError"), } }) } /// Deletes the secret stored under `key`. #[must_use] pub fn delete<Ef, Ev>( key: impl Into<String>, ) -> RequestBuilder<Ef, Ev, impl Future<Output = SecretDeleteResponse>> where Ef: From<Request<SecretRequest>> + Send + 'static, Ev: Send + 'static, { let key = key.into(); crux_core::Command::request_from_shell(SecretRequest::Delete(key)).map( |response| match response { SecretResponse::Deleted(key) => SecretDeleteResponse::Deleted(key), SecretResponse::DeleteError(msg) => SecretDeleteResponse::DeleteError(msg), _ => unreachable!("delete only produces Deleted or DeleteError"), }, ) } }
Each builder issues a request, then .map(...) narrows the wide SecretResponse down to the operation-specific type. The unreachable!() calls document an invariant: because the shell only ever produces the "right" variants for a given operation, the other arms should never fire. If they do, there's a bug in the shell's handler that the panic surfaces rather than hides.
Using these builders looks no different to the location ones: call secret::command::fetch(API_KEY_NAME) and finish with .then_send(...) to bind the eventual SecretFetchResponse to an event.
What capabilities provide
Putting it together, a capability gives you two things:
- A protocol — operation and response types marked with the
Operationtrait, which define the wire format between core and shell. - A developer API — small command-builder functions that speak in convenient Rust types rather than the raw protocol.
In ports-and-adapters vocabulary, capabilities are the ports; the shell-side code that actually carries out each operation is the adapter. The core expresses what it wants done; the shell decides how to do it. Keeping that separation tight is what makes the core portable.
Speaking of the shell — it's time to look at how these operations get carried out on each platform. That's the next chapter.