Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. A protocol for talking to the shell — an operation type and a response type.
  2. 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 Operation trait, 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.