The actor model

Governing ADR: ADR-0074 (the unified actor model — capabilities and components are one model, not two) with ADR-0079 (the lifecycle stages) and ADR-0033 (the #[actor] macro), extended by ADR-0096 (a wasm module exports several actor types), ADR-0114 (a component spawns inline children), and ADR-0099 (actor identity and addressing), plus ADR-0166 (declared placement permissions). This model is stable; it's the spine everything else hangs off. Signatures here were read from the current SDK (aether-actor) and runtime (aether-substrate).

The engine is built from one kind of thing: the actor. The renderer, the audio mixer, the filesystem, a component you load — all of them are actors, with no privileged class of "system object" sitting above them. Everything in the substrate is an actor, and the only way actors ever interact is by mail.

If you understand actors, most of the engine follows: every subsystem in this guide is some actor (or a handful) doing a job. This page covers the actor itself — what it is, the lifecycle it moves through, how you write one — and the two hosts it runs under, native capabilities and wasm components.

What an actor is

An actor is some private state paired with a set of typed handlers. It sits idle until mail arrives; when an envelope lands, the handler registered for that kind runs with exclusive &mut access to the state, updating it and sending mail of its own. Nothing happens except in response to a message.

Two properties make it tractable to reason about:

  • Actors communicate only by mail. No actor holds a reference into another actor's memory, calls another's methods, or shares a lock with it. The only way to affect another actor is to send it a kind it handles. This is what lets the same model span an in-process capability and a sandboxed wasm component without either knowing which it's talking to — mail is the only coupling, so the host is an implementation detail. (See capability = reachability for the security consequence.)
  • An actor only ever runs on one thread at a time. The scheduler guarantees no two threads run an actor's handlers at once, so an actor can freely mutate the data on its own struct — its state is plain fields, no Mutex, no RefCell, no atomics, just ordinary sequential Rust. (How the scheduler enforces this — the run-token — is the concurrency page; here, take it as a guarantee you can build on.)

What flows between actors — the kinds, their ids, the wire encoding — is the type system. How it routes and in what order — mailboxes, FIFO, fire-and-forget — is mail & scheduling. The rest of this page stays with the actor on the receiving end: how it's built and how it runs.

The lifecycle

Every actor — regardless of host — moves through the same three authored stages (ADR-0079). Each stage gets a different context, and the context is the contract: it's exactly what you're permitted to do at that point.

stagewhenctx allowsuse it for
initonce, at bootresolve only — no mailbuild and return the initial state
wireafter init, mailbox now publishedfull send + resolvesubscribe to input, announce yourself, kick off a self-poll; return Err to fail the birth
handlerssteady state, one call per inbound kindfull send + resolve + replythe actor's actual behavior
unwireon every exit, after the inbox drains and before dropfull send + resolvefinal broadcast, signal monitors, flush state

The three stages exist because constructing an actor and letting it participate in the mail system are different moments, and only the second is safe to send from. init runs while the actor is still being built: its mailbox isn't published yet, peers may not have booted, and it returns Result<Self, ActorInitError> so a failure aborts the load cleanly. Sending mail from there would mean announcing yourself before you're addressable, or mailing a peer that doesn't exist yet. So init stays a pure synchronous constructor — resolve kind ids and mailbox addresses, assemble state, return it (or fail with an ActorInitError that surfaces instead of leaving a half-built actor behind).

wire is the first point where sending is safe. It runs once init has succeeded, the mailbox is live, and the chassis is past its boot barrier, so peers are addressable and replies can route back. That's why mail-driven setup lives here: subscribing to the tick or input streams, announcing yourself to a peer, starting a poll loop by mailing yourself. An actor that needs to subscribe at startup would have nowhere safe to do it if init were the only hook.

wire returns the same result type init does, Result<(), ActorInitError> for a component and Result<(), BootError> for a native actor, and a hook that fails fails the birth (ADR-0247 rule 3). Whoever asked for the actor is told: a load or spawn answers Err with the hook's message, a boot manifest entry fails the build, a parent's staged spawn completes with SpawnError::WireFailed, and a composed capability fails the chassis build. The actor never goes live. Because the hook was entered, unwire still runs before the actor drops, and mail the hook sent from a birth that holds its outbound mail is discarded with it. A guest that traps in wire fails its birth the same way, except that no more of its code runs, unwire included. Every hook is written with the return type and ends in Ok(()); #[actor] refuses one without it.

unwire is the mirror at the other end, and it exists for the same reason in reverse — teardown often needs to send, whether that's a closing broadcast, a signal to monitors, or a final flush to a peer, and Rust's Drop can't reach cleanly into the mail system. It runs after the inbox has drained but before the actor drops, so its sends still land in live peers (mail to one that's already gone warn-drops). It absorbs what used to be a separate on_drop hook.

What an actor wired, it unwires: unwire runs on every exit the engine takes (ADR-0247 rule 5). That is the actor asking to close (ctx.shutdown(), or a component's aether.component.drop), a birth cancelled after wire ran, a boot that rolled back, and engine teardown, where an actor sitting idle is woken to run it. So whatever wire acquired can be released in unwire and relied on to be released. Only a process that is killed or aborts skips it. At teardown the engine settles a closing actor's held replies without answering them, since every requester is closing too.

Both hooks are optional: wire defaults to Ok(()) and unwire to a no-op. Override them only when you have mail-driven setup or teardown to do.

The context

Every lifecycle method and handler is handed a context (ctx) — the actor's only handle to the world outside its own state. Through it the actor resolves addresses, sends mail, and replies to whoever sent the current message; depending on where it's running it can also spawn a child actor, persist state for a successor, or ask to shut down. Anything that reaches past the actor's own fields goes through the context, and you never construct one — the runtime passes it in for the duration of a call and takes it back when the call returns, so an actor touches the world only while a handler is running, never through a stashed handle.

There's more than one context type because what an actor is allowed to do changes from stage to stage, and the type is how that's enforced. The context handed to init can resolve addresses but has no send method at all — so "init can't mail" isn't a rule you have to remember, it simply won't compile. wire, handlers, and unwire get a context that can send and reply; the hot-swap hooks get one that can persist state. That's what "the context is the contract" means literally: the method you're in determines which context type you hold, and that type determines what compiles.

Host matters as well as stage. Resolving, sending, and replying are common to both; a few operations are host-specific. A native capability can spawn any instanced child actor and ask to shut itself down. A component creates children inline — instances of the other actors its own module exports, co-located in its wasm instance (ADR-0114, and the cardinality section below) — while its own load, drop, and replace are driven from outside. The concrete context types differ by host too — WasmInitCtx/WasmCtx in a component and NativeInitCtx/NativeCtx in a capability. Lower-level resolver/sender traits support shared helpers, but actor lifecycle signatures use the concrete context for their host and stage.

Authoring an actor

You declare the receive side with the #[actor] attribute on one impl block, and each #[handler::<class>] method is a handler — the macro infers the kind it handles from the method's third parameter:

#![allow(unused)]
fn main() {
#[actor(root, depends(LifecycleCapability, RenderCapability))]
impl WasmActor for Hello {
    const NAMESPACE: &'static str = "example.hello";

    fn init(_ctx: &mut WasmInitCtx<'_>) -> Result<Self, ActorInitError> {
        Ok(Hello)
    }

    fn wire(&mut self, ctx: &mut WireCtx<'_, '_>) -> Result<(), ActorInitError> {
        ctx.subscribe::<LifecycleCapability, Tick>();
        Ok(())
    }

    #[handler::event]
    fn on_tick(&mut self, ctx: &mut WasmCtx<'_>, _tick: Tick) {
        ctx.send::<RenderCapability>(&TRIANGLE);   // draw every tick
    }

    #[handler::request]
    fn on_ping(&mut self, _ctx: &mut WasmCtx<'_>, ping: Ping) -> Pong {
        Pong { seq: ping.seq }
    }
}
}

Ping is the kind on_ping handles; Tick is the kind on_tick handles. The macro reads those parameter types and generates the dispatch table that routes an inbound envelope to the right handler by matching its kind id — a compile-time const (K::ID), so there's no runtime registration and no host round-trip to resolve an address. A handler with no match falls through to an optional #[fallback] (taking the raw Mail<'_>); omit the fallback and the actor is a strict receiver — unhandled kinds are reported, not silently dropped.

You address peers by type — ctx.send::<RenderCapability>(&payload), on a ctx whose actor declares depends(RenderCapability), compiles only if that actor actually handles the payload's kind, and both the mailbox id and kind id resolve at compile time. The handler takes the decoded mail by value and gets &mut self because nothing else can touch the state concurrently.

Declaring placement

Actor identities declare where they may legally appear with root and child_of(...) arguments on the same #[actor] attribute:

#![allow(unused)]
fn main() {
#[actor(singleton, root)]
pub struct ComponentManager;

#[actor(instanced, child_of(ComponentManager, TestComponentManager))]
pub struct ComponentWorker;
}

root implements the Root marker: the actor may be placed without an actor parent. child_of(A, B, ..) is one list, written once, as depends(..) is: it becomes the actor's Declared::Parents, and each entry implements ChildOf<Parent> at its position in that list and records one permitted direct parent edge. A hand-written ChildOf impl does not compile, so the list is the actor's whole placement set. An actor may be both a root and a permitted child.

Parentless native entry points consume that permission as a compile-time bound. Chassis composition (Builder::with_actor and with_actor_configured), chassis-level instanced spawn, pumped-actor boot, and the matching test and harness adapters all require Root beside their existing native actor bounds. A child-only actor therefore cannot cross one of those root placement surfaces, but remains valid through typed child placement such as NativeCtx::spawn_child, where its declared ChildOf<Parent> edge is checked instead. No runtime metadata lookup is involved in either check.

A wasm actor declares placement with the same arguments — root, instanced, root, instanced, child_of(P), or instanced, root, child_of(P) (ADR-0241 §5) — but the permission is checked at run time, from the module's aether.actor.lineage section. root writes a Root record for every cardinality, and it is the permission the component host checks when it loads the type at itself, boots it as its module's boot type, or publishes a module whose boot type it is: each is refused with the operation's Err, naming the type and the placements it does declare, before the module publishes or anything is staged. A child_of(P) record never satisfies it. A spawn naming a parent is checked the same way against a child_of(P) record naming the proven parent's type. A loaded guest is named as a native actor is — NS, NS:key, or parent/NS:key — so a wasm root emits the same Root marker impl a native root does.

These are placement permissions, not runtime facts. They do not say that an instance is live, that a parent owns or supervises a child, or that the actor has singleton versus instanced cardinality. The actor's own Addressable::NAMESPACE remains the only namespace declaration; generated native inventory and wasm manifest records derive names and type tags from the actor types rather than copying string literals.

TCP uses multiple parent permissions because accepted and outbound sessions have different real lineages:

#![allow(unused)]
fn main() {
#[actor(singleton, root)]
pub struct TcpCapability;

#[actor(instanced, child_of(TcpCapability))]
pub struct TcpListenerActor;

#[actor(instanced, child_of(TcpCapability, TcpListenerActor))]
pub struct TcpSessionActor;
}

The two permitted lineages give an accepted session and an outbound session with the same name distinct identities. The lineage is chosen where the session is spawned: the listener spawns accepted sessions beneath itself, and the capability spawns dialed sessions directly beneath itself. A consumer reaches either one through the stamped sender of the session's own mail, never by naming it.

ChildOf therefore does not choose one global parent for an actor type. It is checked where a child is placed (spawn_child) and where an embedder looks one up (child::<P, C>), and the parent at that point supplies the canonical lineage and identity fold.

External actor addresses

Rust actor code continues to resolve peers by type. String-addressed boundaries such as MCP, configuration, and harness calls may additionally use an ADR-0166 short path rooted in an actor namespace:

aether.window/:main

The substrate expands that spelling from the generated Root and ChildOf inventory before it performs the ordinary canonical registry lookup. With the window capability's one instanced child family, the address above expands to:

aether.window/aether.window.instance:main

The same expansion reads every published module's lineage (ADR-0241 §5): its #[actor(root)] exports, each exported and private type's cardinality, and its child_of(..) edges. The index is rebuilt in the registry-owner apply that publishes a module, and a module's facts about a native namespace are ignored, so a module never changes a native short path.

A loaded component needs no short path to reach it: it is named by its own namespace, so its canonical address is already short (aether.kit.mesh, or aether.kit.camera:main for an instanced one). Its children are reached by a hole beneath it: game.world/:north/:gate for an inline child and its own inline child, or game.world/:k for a guest a Spawn with parent placed at game.world/NS:k. A parent that several instanced child types list in their child_of(..) has several candidates for a hole, and the hole fills with whichever of them holds its key live.

A path is /-separated steps. After the root, a bare step always names a singleton child, namespace:discriminator names an instance of that instanced child, and :discriminator is a hole naming an instance of the one instanced child declared under the current actor. A path with no hole is canonical and never consults the declarations; a path with a hole is short, and its first step must be a bare root namespace. A hole fills directly when the current actor has exactly one logical instanced-child namespace. If several are declared, the declarations bound the candidates and liveness picks among them: the hole fills with the one candidate whose canonical path parent/namespace:discriminator is live, and later holes expand beneath it. None live is a no-live-candidate error; two or more live is an ambiguity error, since a key is unique per type rather than per parent. Both list every explicit namespace:discriminator step the caller can use. Nothing is remembered between uses, so such a short path names whatever holds its key at the moment it is resolved; store the canonical path it expanded to when the reference must stay put. The older :// spelling is refused with an error naming the hole form.

Short paths are boundary input, never actor identity. The registry expands them before hashing and stores, lists, and reverse-reports only the canonical path. Unknown roots, illegal segments, ambiguous children, a hole with no live candidate, path-limit violations, and a valid expansion with no live mailbox remain distinct resolution errors.

At the boundary the text becomes an aether_data::ErasedActorPath. Its grammar is checked when it is built or decoded, so a malformed address fails there rather than in the engine; it is stored as written, holes included; and it becomes a position only in the engine's resolve_address, which fills holes and checks liveness.

Reply classes

A handler declares how it answers through its class marker (ADR-0112, ADR-0134). The single class answers 0-or-1 through its return value — -> R sends R back, -> () sends nothing — and four words spell it, each naming why the handler's mail arrives:

AttributeIts mail isReturnParameters
#[handler::request]a request it must answerO or Pending<O>3
#[handler::tell]a command that needs no answernone3
#[handler::event]a publication it subscribed tonone3
#[handler::response]the answer to this actor's own requestnone3, or 4 with its stored context

#[actor] checks each signature against its word, and each refusal names the word that fits: a request that returns nothing is a tell, event, or response, and a tell, event, or response that returns a value is a request. The words take no arguments. tell and event expand identically: the macro cannot see whether a kind arrives through a subscription, so event records the author's intent. None of the four is published — the inputs manifest and the contract rows see only the single class and the return type. #[handler::single], the older spelling, is refused with an error naming the four words (#7202).

A response takes the context its request stored with send_with_context as an optional fourth parameter (ADR-0243 §10). The dispatch arm takes it before the call. With context: C, a reply that arrives without a C — an uncorrelated mail of the reply kind, a context stored as another kind, or none stored — does not run the handler: the arm logs an error in the actor's own log ring, naming the handler, the reply and context kinds, and the request id, and counts the mail as handled. It never panics, because any sender can send the reply kind. With context: Option<C>, the handler runs either way and receives the take as it is. A response with no fourth parameter may still call ctx.take_context itself, for example to try several context kinds in turn.

A tell or a request states what its sender must handle in its ctx type, as the ctx's sender: the ctx's type arguments are receiver, sender, mode (ADR-0231 §11). A receiver that mails its sender back later, such as a session that delivers frames to whoever dialed it, needs the sender to handle those kinds, and this is where it says so:

#![allow(unused)]
fn main() {
#[handler::tell]
fn on_take_focus(&mut self, ctx: &mut WasmCtx<'_, Self, FocusHolder>, _take: TakeFocus) {
    let holder = ctx.sender(); // ProtocolRef<FocusHolder>
    self.holder = Some(holder);
    ctx.send_to(holder, &FocusGained);
}
}

The one type argument drives two checks. At build time, an actor sends this kind with the plain ctx.send::<Window>(&TakeFocus) only when it has a handler for each of FocusHolder's kinds; one that lacks a handler gets an error naming it, and nothing is added at the call site. At receipt, the engine casts the sender to FocusHolder before the handler runs, and ctx.sender() is then the proven ProtocolRef<FocusHolder>, with no Option and no ctx.cast. Mail the build cannot see, such as a call relayed from MCP or mail with no sender, fails that cast and never reaches the handler: the refusal is logged in the receiver's log, a tell relayed by aether.rpc.server ends its call with the refusal, and a request is answered with its own reply, built from a PathRefused naming the sender, so a request that states a sender replies a kind that is From<PathRefused>. A kind whose handler requires something of its sender is not sent through a ProtocolRef: a protocol's rows are covered only by handlers that ask nothing. A handler that states nothing keeps Anyone, whose ctx.sender() is an Option<ErasedActorRef>. A helper that a stating handler passes its ctx to takes the sender as a type parameter, as it takes the actor: fn dial<A, S>(ctx: &mut NativeCtx<'_, A, S>, ..). A sender is refused on an event, a response, an unchecked handler, a Departed<W> handler, a batched &[K] handler, a handler in a set, a #[fallback], a task completion, and a lifecycle hook, and a reply mode written in the sender's position is refused with the corrected spelling.

The unchecked class (#[handler::unchecked(reason = "…")]) takes an Unchecked ctx and issues its own replies by hand (ctx.reply / ctx.reply_to), which the engine does not check. It gives up the reply check, so it is only for a handler that replies more than once, replies from outside the actor, or relays a request, and its reason says which: the reason rides the inputs manifest and the native handler inventory, and describe_component / describe_handlers show it beside the row. It is never a default — a handler that never replies to its own inbound mail is a tell, event, or response with a silent -> ().

A request that answers one exact kind in a later turn returns -> Pending<R>. The offload dispatch calls mint that receipt for work a worker finishes; for a reply no worker produces, ctx.hold::<R>() returns the Pending<R> with a move-only Held<R> ticket, which the actor keeps in state and answers with exactly one R through Held::answer, from any later handler (ADR-0243).

A bounded many-item answer is one reply whose kind carries a list, as the log, trace, and cost tails do. Incremental or unbounded delivery publishes to subscribers (Publishes<K> / subscribe).

#[actor] records each handler's answer as a type-level contract row, impl Contract<K> for A { type Reply = …; type Sender = …; type Index = … }: the reply kind O for -> O or -> Pending<O>, Silent for -> (), and Undeclared for an unchecked handler, and as Sender the protocol its sender parameter names, or Anyone when it takes none. Each row names its position in the actor's one type-level row list, Contracts::Rows, so a row exists only where a handler does: a hand-written row for a kind the actor does not handle does not compile, and a handled kind of a public actor is declared pub (ADR-0231 §10). It also emits Contracts::CONTRACTS, the same rows as (KindId, ReplyContract) pairs in the vocabulary the inputs manifest and the native handler inventory report, with an adopted handler set's rows appended. A #[fallback] contributes neither. The reply checks ADR-0231 specifies are built on them. A route publishes its actor's rows, and whether it has a #[fallback], on its route record when it goes Live: a native actor its own, a wasm component its guest's (republished on replace), and an inline child's alias its own type's.

A protocol names a set of contract rows under a stable type, independent of any implementation (ADR-0231 §2). #[protocol] on a trait of signatures declares one: each method is a row, -> O single and no return silent, and the explicit return -> Undeclared unchecked. The trait becomes a unit struct whose impl Protocol lists the rows as type Rows = (Row<K, O>, …). MeshLoader: CoveredBy<R> holds when the target R has a contract row for every kind with the exact reply, from a handler that asks nothing of its sender. Rows match by kind, never by method name; a #[fallback] has no row and an unchecked handler's Undeclared row covers only an explicit unchecked protocol row. That row promises the target handles the kind without imposing a reply-handler obligation on the sender. Coverage is sealed: aether-actor computes it from Rows, and a hand-written CoveredBy impl does not compile. RowSet::CONTRACTS on the rows is their list in the same (KindId, ReplyContract) vocabulary as Contracts::CONTRACTS.

#![allow(unused)]
fn main() {
#[protocol]
pub trait MeshLoader {
    fn load(mail: LoadMesh) -> MeshLoadResult;
    fn set_mode(mail: SetMode);
    fn forward(mail: Forward) -> Undeclared;
}

// is the declaration
pub struct MeshLoader;

impl Protocol for MeshLoader {
    type Rows = (
        Row<LoadMesh, MeshLoadResult>,
        Row<SetMode, Silent>,
        Row<Forward, Undeclared>,
    );
}
}

Typed paths

An actor names another actor by type in what it stores or sends through a typed path: ActorPath<R> names an R, and ProtocolPath<P> names an actor covering the protocol P (ADR-0230 §2, ADR-0231 §3). An actor writes an ActorPath<R> with one of two type constructors, whose bounds check the topology at compile time. ActorPath::<R>::instance(&key) compiles for a root instanced R (R: Root + Instanced) and writes R::NAMESPACE:key. ActorPath::<C>::child(&parent, &key) compiles for an instanced C declared beneath the parent's actor (C: ChildOf<P> + Instanced) and writes <parent>/C::NAMESPACE:key; it refuses only a path past the depth or byte cap. Each step is a type's NAMESPACE and its key, so the text is the canonical name the registry gives that instance, and writing it reads no registry. path.narrow::<P>() keeps the text under the narrower claim and compiles only for P: CoveredBy<R>.

Both paths are kind fields, carried as the path text with ErasedActorPath's schema. Decoding either accepts only a well-formed canonical path, and decoding an ActorPath<R> also refuses a path whose leaf namespace is not R::NAMESPACE, so an ActorPath<R> that exists names an R. A decoded ProtocolPath<P> is checked against the mail registry's published contract for the route at its path, live or closed: the decode refuses a path no route has stood at, one still starting, or one whose route does not publish every row of P, so a ProtocolPath<P> that exists names an actor, live or closed, covering P. A refused decode reaches no handler. A request whose path did not prove is still answered: its reply kind implements From<PathRefused>, and the dispatch answers O::from(refused) naming the path and why, while a silent or unchecked row drops the refusal with a warn. A request kind carrying a path whose reply lacks From<PathRefused> does not compile. Native dispatch decodes with the registry, and a guest's decode asks it through one host call per path field (route_rows_p32), so a guest proves and refuses a ProtocolPath<P> as a native receiver does, in mail, in its config, and in saved state. A kind with no protocol path makes no host call. Neither path grants a send: a closed actor's path decodes, since names are never reused, and the route can leave after the decode, so its receiver's resolve proves that a live actor stands at the path and the handler answers the closure itself. The first consumer, a Bloomery unit's driver, is to write its journal's storage source this way (ADR-0240 D7):

#![allow(unused)]
fn main() {
let source: ProtocolPath<ArtifactStorage> =
    ActorPath::<JournalActor>::instance(&unit_key).narrow::<ArtifactStorage>();
}

A native actor that receives a ProtocolPath<P> proves it with ctx.resolve(&path), which returns a ProtocolRef<P> or a ResolveError (ADR-0231 §3). resolve folds the canonical text and reads the route table once: a route must stand under exactly that name and be Live, or it refuses NotLive, naming the path, never a position. It compares no rows: P was proven when the path was narrowed or decoded. ctx.send_to(reference, &kind) and its context-carrying siblings take the ProtocolRef<P> only for a kind P lists; any other kind is a compile error, whatever else the target handles:

#![allow(unused)]
fn main() {
// `run.source: ProtocolPath<ArtifactStorage>`
match ctx.resolve(&run.source) {
    Ok(storage) => ctx.send_to(storage, &read),
    Err(error) => tracing::warn!(%error, "the run's source is refused"),
}
}

The guest arm is WasmCtx::resolve, which takes either typed path and mints the reference its type names (#7205, #7501): the Bloomery bootstrap proves its journal and driver peers from ActorPath<R> fields with it, and a guest whose config names a peer by protocol resolves the ProtocolPath<P> in wire (Naming a peer by protocol).

A kind or config field naming an actor its receiver will later send to, such as a subscriber, a handler, a callback, or a source, is a typed path, never an ErasedActorPath (design rule R-0040). It is an ActorPath<R> when the holder needs one concrete actor type, and a ProtocolPath<P> when it needs only a protocol, such as a subscriber the publisher cannot name. The receiver proves it once on receipt, by its decode, and stores the typed proof resolve returns. An erased reference has no send verb (#6895, ADR-0231 §4), so an ErasedActorPath is left to name, render, compare, or monitor an actor, and to name a recipient at the untyped MCP and RPC boundary.

Helpers that only send

The class marker rides on the context type — a request, tell, event, or response handler's WasmCtx<'_> is WasmCtx<'_, Self, Anyone, Single> and an unchecked handler holds WasmCtx<'_, Self, Anyone, Unchecked> — which is what makes a stray ctx.reply in any of the four a compile error. The ctx's type arguments are receiver, sender, mode: the actor is the first, the sender the handler requires (Anyone when it states none) the second, and the reply mode the third. A ctx that omits its actor is typed by it: #[actor] fills in Self, so the ctx reaches only the actors the handler's actor declares with depends(R). Spelling Erased in that slot (WasmCtx<'_, Erased>) asks for the untyped view. One call deeper the class buys nothing: a helper you factor out of a handler to send something never touches the reply channel, yet pinning one class makes it uncallable from the others and staying generic means carrying an M: ReplyMode parameter it doesn't read. ctx.sends() hands out Sends<'_, A>, typed by the handler's actor — the outbound verbs that take a proof (send_to, plus send_detached_to through MailSender, both taking a typed target: an ActorRef<R> or a ProtocolRef<P> such as a sender cast with ctx.cast) with the marker dropped — so the helper takes &mut Sends<'_, A> and every handler class can call it. The view sends only through a reference it is handed: the actor mints one with ctx.actor_ref::<RenderCapability>() in wire and keeps it in a field, because minting it in the same call as ctx.sends() would not borrow-check. Sends<'_> alone names the erased view:

#![allow(unused)]
fn main() {
fn announce<A>(sends: &mut Sends<'_, A>, renderer: ActorRef<RenderCapability>, frame: &Frame) {
    sends.send_to(renderer, frame);
}

#[handler::event]
fn on_tick(&mut self, ctx: &mut WasmCtx<'_>, _t: Tick) {
    announce(&mut ctx.sends(), self.renderer, &self.frame);        // Single
}

#[handler::unchecked(reason = "example: the same helper from the unchecked class")]
fn on_redraw(&mut self, ctx: &mut WasmCtx<'_, Self, Anyone, Unchecked>, _r: Redraw) {
    announce(&mut ctx.sends(), self.renderer, &self.frame);        // Unchecked — same helper
    ctx.reply(&Acknowledged);                       // reply stays on the ctx
}
}

reply / reply_to / emit — and send_with_context, whose stashed context is recovered on the reply — stay on WasmCtx<'_, A, S, M>, so a helper that needs those still states which class it belongs to. That's the line: the reply class is load-bearing exactly where the reply is.

Sharing handlers across a family

A family of similar actors — the widgets in a set, the per-platform runtimes of one capability — tends to carry the same block of handlers. A handler set declares that block once (ADR-0169). #[handler_set] sits on a trait whose #[handler::<class>] methods carry the shared bodies as trait defaults, and whose required methods are the accessors those bodies reach through:

#![allow(unused)]
fn main() {
#[handler_set]
pub trait WidgetDefaults {
    fn widget_frame(&mut self) -> &mut WidgetFrame;
    fn widget_state(&mut self) -> &mut InteractionState;

    /// Release any half-finished interaction — an armed press, a live drag.
    fn cancel_activation(&mut self);

    #[handler::tell]
    fn on_frame(&mut self, _ctx: &mut WasmCtx<'_>, frame: WidgetFrame) {
        *self.widget_frame() = frame;
    }

    #[handler::event]
    fn on_focus_lost(&mut self, _ctx: &mut WasmCtx<'_>, _lost: FocusLost) {
        self.widget_state().lose_focus();
        self.cancel_activation();
    }
}
}

An actor adopts the set by naming it in #[actor] and implementing the trait:

#![allow(unused)]
fn main() {
impl WidgetDefaults for ToggleWidget {
    fn widget_frame(&mut self) -> &mut WidgetFrame { &mut self.frame }
    fn widget_state(&mut self) -> &mut InteractionState { &mut self.state }
    fn cancel_activation(&mut self) { self.arms.clear(); }
}

#[actor(instanced, root, child_of(WidgetPanel, ScrollWidget), handler_set(WidgetDefaults))]
impl WasmActor for ToggleWidget {
    // only toggle-specific handlers here
}
}

Dispatch tries the actor's own handlers first and consults the set on a miss, so an actor's local declarations stay authoritative over anything inherited:

match local arms  ->  DISPATCH_HANDLED
else set dispatch ->  DISPATCH_HANDLED
else #[fallback] / DISPATCH_UNKNOWN_KIND

A member that differs for one adopter is overridden the ordinary Rust way — by implementing that trait method — which keeps the kind owned by the set: one dispatch arm, one manifest record. Re-declaring the same kind as a local #[handler] instead is a coherence error, not a second definition.

A set member's ctx is typed by the adopting actor: WasmCtx<'_> in a set reads as WasmCtx<'_, Self>, where Self is whichever actor adopts the set. A default body that reaches another actor therefore states that reach on the trait, as a supertrait — pub trait SoundDefaults: DependsOn<AudioCapability> for a set whose handlers play notes through the audio capability — and every adopter must declare the dependency. #[handler_set] adds Sized to the supertraits as well. An override is a plain trait-method impl that no macro rewrites, so it spells the typed signature itself: WasmCtx<'_, Self>.

Set handlers reach the aether.kinds.inputs manifest exactly as local ones do, so describe_component reports an adopter's full receive surface and input subscription covers inherited kinds with no extra wiring. A set is wasm or native throughout, uses one authoring shape throughout, does not nest, and an actor adopts at most one — a family that outgrows one set wants a second set, not a chain.

A set's handlers are contract rows of each adopter too, at positions past the adopter's own, so an adopter covers a protocol that lists a set kind (ADR-0231 §2). The rows travel through a bridge macro the set emits and the adopter's #[actor] expands in the adopter's own module, so a set spells its kind types with paths that resolve from every adopter (crate::WidgetFrame), and a wasm set, whose bridge is crate-local, is adopted within the crate that defines it.

A native capability adopts a set the same way, with two differences that follow from how native actors are authored. Handlers are written against NativeCtx, and a capability with a type State writes them in the split shape — the state arrives as the first parameter rather than as a self receiver, so the accessors are associated functions over Self::State:

#![allow(unused)]
fn main() {
#[handler_set]
pub trait CounterSurface {
    fn counter(state: &mut Self::State) -> &mut Counter;

    #[handler::request]
    fn on_reset(state: &mut Self::State, _ctx: &mut NativeCtx<'_>, _mail: ResetCounter) -> ResetCounterResult {
        Self::counter(state).reset();
        ResetCounterResult::Ok
    }
}
}

Each adopter supplies only the accessor; the handler is written once against it.

And under the split identity / runtime shape the adoption is declared on #[runtime], in the runtime file where the dispatch table is emitted — the capability struct's #[actor] reads it back off that attribute when it harvests the file, so the set is named once:

#![allow(unused)]
fn main() {
#[runtime(handler_set(CounterSurface))]
impl NativeActor for ExampleCapability {
    // only this capability's own handlers here
}

impl CounterSurface for ExampleCapability {
    type State = ExampleCapabilityState;

    fn counter(state: &mut Self::State) -> &mut Counter {
        &mut state.counter
    }
}
}

A native set's kinds carry HandlesKind markers, so kind-checked sends to an adopter (ctx.send_to(&example, &ResetCounter) through an ActorRef<ExampleCapability>) compile for inherited kinds too. The markers travel through a macro_rules! bridge the set generates, which means a set's kind types need spellings that resolve at each adopter's #[actor] — for a capability crate, the names re-exported at its crate root. The bridge compiles only where the set's module does, so an adopter whose identity must compile without its runtime (a capability a wasm guest names) cannot take its markers from a native set; such a capability writes its handlers inline in its one #[runtime] impl instead, as WindowCapability does.

A #[cfg] on a set handler is resolved by the crate that defines the set, and that answer reaches every artifact the set produces, the markers included. An adopter inherits a surface that is already fixed: enabling a feature of its own, even one sharing the set's spelling, never changes which handlers it inherits. That is what keeps a set's dispatch chain and its markers from disagreeing about which kinds it handles — a marker for a kind the chain would not answer is a send that compiles and gets dropped at run time. When one adopter genuinely needs a handler the others do not, declare it locally in that adopter's own #[actor] block, where #[cfg] already means the adopter's configuration.

Put in a set only what is genuinely uniform. When bodies disagree on something load-bearing — which predicate cancels an activation, say — a shared body has to pick one reading and silently change the rest, which is worse than the repetition it removes.

The example above is illustrative. The in-tree wasm adopter is the HandlerSetAdopter fixture in crates/aether-test-fixtures-bundle/src/handler_set.rs, which crates/aether-component/tests/harness_handler_set.rs loads and mails.

Configuring an actor

An actor can take typed boot configuration. Declare a Config associated type and the chassis threads a decoded value into init as its leading argument:

#![allow(unused)]
fn main() {
#[actor(root)]
impl WasmActor for ProbeWithConfig {
    type Config = ProbeConfig;
    const NAMESPACE: &'static str = "probe_with_config";

    fn init(config: ProbeConfig, ctx: &mut WasmInitCtx<'_>) -> Result<Self, ActorInitError> { … }
}
}

Most actors need none. Omit Config and the #[actor] macro synthesizes () and injects the unused argument, so a no-config init stays the terse fn init(ctx: &mut WasmInitCtx<'_>) from the examples above — there's no type Config = () to write by hand.

The two hosts differ in one way, and it follows from how the config reaches them. A capability's config is built in-process by the chassis, so it can be any Send + 'static type. A component's config has to cross the wasm boundary as bytes, so it must be a Kind — encoded at the load edge, decoded on the way in. That seam aside, the authoring shape is identical. (How a component's config rides the load call, and how a chassis assembles its own layered config, are the components and configuration pages.)

Names and addressing

The NAMESPACE const on the Actor trait is the name an actor claims — the "hello", "camera", "aether.audio" in the examples above. From the name and the actor's place in the runtime tree come two ids, two distinct moments (ADR-0099):

  • NAMESPACE → ActorId, at compile time. The hash of the NAMESPACE names which actor this is — binary-unique, the same wherever the actor is hosted. An instanced actor (below) folds its runtime discriminator in: hash(NAMESPACE:subname).
  • Lineage → MailboxId, at creation. Where the actor sits is its lineage — the ordered ActorIds from the substrate root down to it, fixed when the actor is created. Its MailboxId is a hash chain over the lineage, one fold step per node, and mail routes to that.

For a capability the two coincide. It sits at the root, so its lineage is one node and the fold of one node is that node: MailboxId == ActorId, the NAMESPACE is the whole address (aether.audio, aether.render, aether.window), and ctx.send::<AudioCapability>(..) resolves to it as a compile-time const with no runtime lookup.

For a component the name is the one its module publishes for the type (ADR-0241 §5): a singleton sits at the root at NS, exactly as a capability does, an instanced type at NS:key, and one loaded beneath a parent at parent/NS:key — the canonical rendered address LoadResult.path hands back. The string is a display rendering of the lineage; the MailboxId is the fold over the nodes (the host registry parses a written path and folds it node by node), never a hash of the joined string.

There is one addressing verb: you address a type, and the type declares where it lives. ctx.send::<Camera>(..) routes through ctx.actor_ref::<Camera>(), which reads the resolver Camera declares and selects the routing seed from it — the root for a capability or a loaded root singleton — so the send site says who it is talking to and never where that peer sits. A declared dependency is always a root singleton, so moving the caller changes nothing about the route.

A load name is the one thing the type cannot declare, because it is a runtime fact. A replicas fan-out spawns several counter-keyed instances (NS:0, NS:1, …), each reached through the reference its own spawn proved, never by folding a name at the send site.

LoadResult.path is an ErasedActorPath: the host's resolve_address parser, at the MCP, RPC, and harness boundary, is the one place an ErasedActorPath becomes a position (ADR-0230). The reply carries no position. A successful load reply is sent by the loaded actor itself, so a requester, native or guest, keeps ctx.sender() from the reply as its reference and an embedder types the reply event's stamped sender.

Because the lineage is the address, two actors collide exactly when they would occupy the same position — same parent, same name. The substrate enforces one claimant per position at registration: a second capability claiming a taken root name fails to boot, and a component loaded under a name already in use comes back as a load error. This is not a compile-time check — two types can declare the same NAMESPACE string and compile cleanly; the collision only surfaces when the second one tries to register. For an instanced actor (below) the colliding unit is the full NAMESPACE:subname under one parent, not the shared prefix.

The tree is ordered, and the order is creation order (ADR-0248). A parent's children stand in the order they were created, and so do the root actors: the chassis composition chain as written, then the boot list as written, then whatever is loaded later. Nothing else orders actors. There is no reorder verb, rank, or ordering declaration, and an actor never states where it stands. The registry stamps each route with a birth serial when the route is first reserved, before init runs, so the order is the order the registry saw the requests and does not depend on how long each init takes. The serial stays with the route through a republish and after the actor closes, and a closed actor's name is never registered again, so a place is for life.

A native actor reads where another stands with ctx.lineage_order(reference), which answers an opaque LineageOrder to compare with others: a parent sorts before its children, a child between its parent and its parent's next sibling, and siblings in creation order. The value names no mailbox and is never mailed. Each reader gives the one order its own meaning; the renderer paints overlay draws by it (Rendering). A parent that needs control over where things stand creates empty children first, in the order it wants, and creates its content beneath them.

So that the read has no failure to report, a birth names a parent that holds a route record. Every production birth already does, because a child's name is built from its parent's. A name nested beneath a parent the registry has never seen is refused where the birth is applied: SpawnError::ParentUnknown for a spawn, RegistryEffectError::ParentUnknown for a staged effect. A test fixture that stands a route at a/b:k registers a first.

A dash in a namespace is a naming convention, not addressing grammar. Use it only for a genuine adjacent sibling of an existing bare base: aether.kit.camera-controller is the controller actor beside the bare aether.kit.camera actor. The dash has no addressing semantics — it makes neither actor a child of the other, and the full NAMESPACE still yields the ActorId before lineage yields the MailboxId. Do not use a dash merely to spell a multi-word segment; that is what an underscore is for, as in aether.substrate_harness.observer.

ctx.actor_ref::<Camera>() returns an ActorRef<Camera> for the physical trampoline mailbox: the trampoline and its loaded guest share one mailbox, while the guest type supplies the compile-time mail-handling surface. A const beside the call site holding what Camera::NAMESPACE already declares would be a second naming authority nothing checks against the first, and the bare-type spelling exists so it has nothing to hold.

One or many: cardinality

An actor type is either singleton or instanced, marked by the Singleton or Instanced trait, and the choice sets whether its NAMESPACE is a whole name or a prefix.

A singleton is one of a kind: at most one instance under a given parent, and its ActorId is the plain hash(NAMESPACE). Every capability is a root singleton — its one-node lineage makes its NAMESPACE the whole address, so you address it straight by type, ctx.send::<R>(..).

An instanced actor is one of many sharing a prefix. Its NAMESPACE is that prefix, and each live instance gets its own ActorId by folding a runtime discriminator in — hash(NAMESPACE:subname), rendered aether.tcp.session:42 — with its MailboxId folding that ActorId under the parent's lineage, so two instances under one parent differ by subname. The case that drives this is sockets: a singleton listener accepts connections and spawns a session actor per connection with ctx.spawn_child (ADR-0079), then reaches a specific one through the reference that spawn returned, kept in its own child map. A subname is never folded at a send site: an instance is reached through the reference its spawn returned or through child_as.

ctx.spawn_child is the native verb. A native capability names only the child type, and can spawn an Instanced native actor when that child declares ChildOf<Parent> for the actor doing the spawning: ctx.spawn_child::<TcpSessionActor>(subname, config, params). The parent comes from the ctx. A handler opts into the call by naming its own actor in its ctx signature — ctx: &mut NativeCtx<'_, Self, Anyone, Single>, or NativeCtx<'_, Self, Anyone, Unchecked> for an unchecked handler — and the #[actor] macro hands such a handler a ctx typed by the actor being dispatched. Every other handler keeps the plain NativeCtx<'_> and reaches no spawn surface, so a birth cannot be placed under a parent other than the one running.

A native birth stages during the handler turn and commits afterward. The handler chains any after_init bootstrap mail and ends with .stage() (or .stage_with(context) to carry a context kind forward), which does the local half of the work — the permission and subname checks, A::init, the transport — and appends one ordered prepared birth to the parent's buffer (ADR-0165). Nothing in the shared registry moves while the handler runs, so no spawn takes a global lock mid-turn. What comes back is a SpawnReceipt: the child's canonical_name, an ErasedActorPath derived from the parent's identity and proven against the ADR-0166 address grammar on the spot, which names the child for correlation, plus the birth's request id. A lineage too deep or too long for that grammar never gets a receipt: .stage() itself returns SpawnError::PathInvalid, and .stage_with hands its context back beside the error. Neither field is a send target: the child is not Live yet, so nothing can prove it. Mail the child must see first rides after_init on the birth itself, and later mail goes through ctx.send_to(&child, &k) once the Ok completion hands back its reference.

#![allow(unused)]
fn main() {
let Ok(receipt) = ctx
    .spawn_child::<TcpSessionActor>(Subname::Counter, config, params)
    .after_init(Hello)
    .stage()
else {
    return;
};
}

The receipt says the birth was accepted locally; the registry owner applies it after the handler returns, and that result is authoritative. It arrives back at the spawner through the ordinary task-completion path as TaskDone<SpawnOutcome<A>> for a child of type A, correlated to receipt.request, and owes nothing (ADR-0243 §9) — so an apply-time conflict (a name another actor won first, say) surfaces as one typed failure rather than a silent half-spawn. A handler that stages several births in one turn gets one outcome per birth, and those births commit or fail together: if the owner refuses one, that one's outcome carries the reason, every other outcome of the turn is SpawnError::ActivationRejected, none of them is born, and each name can be staged again on a later turn. A SpawnOutcome names itself on both arms:

#![allow(unused)]
fn main() {
struct SpawnOutcome<A> {
    canonical_name: ErasedActorPath,
    result: Result<ActorRef<A>, SpawnError>,
}
}

so a handler correlates the completion with the birth it staged by canonical_name, straight off the outcome, and stages with no context unless there is something the spawn genuinely does not know — a peer address, which leg of a multi-step plan this birth belongs to. A handler whose correlation key is not the child's name stages with that key as a context kind and takes it in the completion with ctx.take_context(); a live value the completion needs, such as a channel or a held reply, waits in actor state under that key. A handler that keeps or mails its child holds the Ok reference and sends through ctx.send_to(&child, &k), so a handler that mails its child after the bootstrap waits for that completion the same way one that must know the child is live before it reports success does. Synchronous commit still exists, but only at the boot/embedder boundary — BuiltChassis::spawn_actor / PassiveChassis::spawn_actor and their .finish() terminal, which block until the birth is live and hand back its ActorRef<A>, a proof you can immediately send through. The actors the chassis composed itself — each singleton capability and pumped actor — are recorded the same way when their routes go live, and actor_ref::<R>() on either chassis handle reads one back by type.

That terminal spans the chassis's one authority boundary, the registry authority seal. A chassis seals once boot is over: a built chassis after its driver's Start stage returns successfully, a passive chassis immediately before the PassiveChassis reaches you. Before the seal, boot writes the registry directly — it wants synchronous apply and read-your-writes with no scheduler thread in the picture yet. After it, there is no direct writer left to name, so an embedder's .finish() submits the birth to the owner and waits for it exactly the way a handler's staged birth is applied: Starting, wire at the actor's execution home, then Live. Every birth in a running engine follows that one protocol, whoever asked for it.

Waiting there is safe because of who waits: an embedder thread is not a pool worker, so it can never be the worker the owner needs to make progress. That is also why a handler has no such terminal — a handler blocking on the owner could be the last worker, so it stages instead.

When the handler that receives a completion owes a reply of its own and answers it by staging another birth, it hands that debt straight on with .continue_from(done, context). The reply the caller is waiting for is a DeferredReply — who is waiting, plus the settlement hold keeping their chain open — and it rides inside the TaskDone the handler already holds, so the successor stage inherits one continuously-held chain instead of closing and reopening it. Every synchronous failure in continue_from hands the value back untouched, so the terminal error still goes out exactly once:

#![allow(unused)]
fn main() {
match ctx.spawn_child::<Worker>(Subname::Named(&name), config, ()).continue_from(done, plan) {
    Ok(_) => { /* the successor now owns the reply */ }
    Err((error, done)) => done.resolve_err(ctx, &Failed { error: format!("{error:?}") }),
}
}

A handler with no TaskDone in hand — one that parks a caller across a worker thread, say — mints the same debt from its ctx with ctx.defer_reply_to(target) and passes that to continue_from instead. A typed Held<R> from ctx.hold is also an IntoDeferredReply, so continue_from takes it the same way and the chain stays held across the hand-off. Dropping a DeferredReply without replying releases its hold (settlement is never wedged) and then panics, in every build, which the scheduler escalates through the chassis aborter, because a lost reply strands the caller forever. An actor that closes with debts still owed while the engine keeps running answers them: the engine sends each Held<R> its R::unanswered() before releasing its hold (ADR-0243 §1), and an actor that keeps a DeferredReply in state answers it in unwire with the terminal it knows. An engine teardown settles held debts silently instead, because every requester is closing with it. abandon_for_actor_close, which releases a debt with no reply, survives only for the component host's boot waiters until #7008 removes it.

Wasm enforces the same ChildOf permission when a component creates a child inline (ADR-0114): the child is an Instanced actor its module also exports (or lists under private = [..]), co-located in the parent's wasm instance, and the spawn compiles only when the spawner declares the child in its spawns(..), whose emitted Spawns<Child> impl in turn compiles only when the child lists the spawner in its child_of(..). So spawns(..) is the rebuild manifest every export! checks, and the child's child_of(..) is its placement set; the one bound A: Spawns<C> proves both. One wasm crate can export several actor types (export!(public = [RootManager, Panel, …])), and a running instance stands up a child just as the listener stands up a session: ctx.spawn_inline::<Panel>(Subname::Named("body"), &config) names only the child type and reads the parent from the ctx as the native side does. Its two-type counterpart ctx.spawn_inline_child::<RootManager, Panel>(Subname::Counter, &config) stays for an erased or wire ctx, which does not know its actor: WasmCtx is addressed by tag rather than by Rust type, so the parent is named at the call and the SDK checks it against the ctx's registry-backed actor tag before allocating the child's alias; writing a different parent type earns an error rather than bypassing the declared edge. Either hands back an InlineChild<Panel>, whose send is checked against Panel's handler set. A parent keeping children of several types in one table narrows each handle with narrow::<P>() to a ProtocolRef<P> for a protocol the child covers, which compiles only where it does (ADR-0231 §3), and keeps the erased erase() reference as the table's identity key. A component spawns within the module it was built from; a foreign module comes in through load_component, which carries its own code and kinds — the boundary is covered in Components & lifecycle.

An inline child ends by closing, as any actor does (ADR-0241 §8): ctx.despawn_inline_child(child) closes it, and so does its parent's close. Each watcher gets a MonitorNotice sent from the child, and the child's name tombstones. The name is spent: a later monitor of it returns its handle and the watcher is sent the child's MonitorNotice at once, as for any actor that has already closed, and spawning the same key beneath the same parent fails with SpawnError::AliasAllocationFailed. A parent that wants a fresh child after a despawn spawns it under a new key, such as Subname::Counter.

A close runs unwire on what wired (ADR-0249 §4, §6). A parent's close runs each inline child's unwire before the parent's own, deepest first. A child that despawns itself runs unwire when the handler that asked returns, and is dropped after it. Each child runs unwire once, and only if its wire returned Ok. Spawning a name whose child is standing is not a second birth: the spawn answers the child that stands, wiring it first when it never wired, initialising nothing, and ignoring the config it was passed, so a wire that spawns a child is safe to run again (§5).

A component can also run as several instances of one type: an instanced type loaded under different keys is an independent actor at each NS:key. The loader hosts every component in a native trampoline actor, spawned once per load, but the actor is named by the guest's published namespace, never the trampoline's.

One model, two hosts

Here's the part that ties the engine together. There aren't two actor systems — there's one model with two hosts, differing in where the actor's code lives and how it reaches the outside world (ADR-0074):

  • A native capability is an actor compiled into the substrate, implementing NativeActor and linked at build time. It's the host an actor takes when what it does needs native Rust APIs or raw performance — the GPU through wgpu, the audio device through cpal, the filesystem, the OS input loop. The renderer, the audio mixer, the filesystem, the input streams, and the component-loader itself are all capabilities; together they're the chassis.
  • A component is an actor loaded at runtime as a wasm module, run sandboxed behind the wasm wall and reaching the outside world only by mailing capabilities. It implements WasmActor, and the substrate drives it through an FFI trampoline. This is the agent-facing extension path: new behavior with no substrate rebuild.

The two hosts preserve one actor model, but native capabilities also split their always-addressable identity from runtime state. Both have configuration and the same lifecycle intent; native handler signatures receive &mut Self::State while wasm handlers receive &mut self. The host contexts and machinery differ, but mail contracts remain symmetric. A current native capability looks like:

#![allow(unused)]
fn main() {
#[actor(singleton)]
pub struct AudioCapability;

pub struct AudioCapabilityState { /* native resources */ }

#[runtime]
impl NativeActor for AudioCapability {
    type State = AudioCapabilityState;
    type Config = AudioConfig;
    const NAMESPACE: &'static str = "aether.audio";

    fn init(config: AudioConfig, ctx: &mut NativeInitCtx<'_>)
        -> Result<Self::State, BootError> { … }

    #[handler::tell]
    fn on_note_on(state: &mut Self::State, ctx: &mut NativeCtx<'_>, note: NoteOn) { … }
}
}

Because the only coupling is mail, an actor can't tell whether the mailbox it sends to is backed by native Rust or sandboxed wasm — and doesn't need to. A component sends aether.render a DrawTriangle exactly as one capability sends another. This symmetry is the point of ADR-0074: one mental model, one macro, one lifecycle, and components get to reuse every pattern capabilities use.

So start here, with the actor, and the two host pages are just specializations:

Where to read more