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) => match response.take_body() { Some(weather_data) => Ok(weather_data), None => Err(WeatherError::ParseError), }, Err(crux_http::HttpError::Http { code, .. }) if code == crux_http::http::StatusCode::Unauthorized || code == crux_http::http::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.
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)] #[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.