Develop for Enclave: WebAssembly app guide, CLI, and API reference

Develop

From idea to enclave.

Everything needed to ship a confidential app: the guide takes you from an empty directory to an attested endpoint, the CLI drives every operation from a terminal, coding agents plug in over MCP, and the API reference documents every call the platform answers.

Build your first Wasm app.

This guide takes you from an empty directory to a paid, attested, publicly reachable app: first the Hello walkthrough end to end, then a worked example for every capability the platform gives your code. That covers HTTP handling, the /data scratch filesystem, raw TCP/UDP service ports, GPU inference, user-held-key encrypted volumes, and attestation your users can check themselves. Everything here runs against the same public API the Deploy console uses.

01The app model

An Enclave app is a WebAssembly component that targets wasi:http, the format wasmtime serve runs. It exports one interface, wasi:http/incoming-handler: the runtime hands your code an HTTP request, your code returns a response. Like a serverless function, except it runs inside a hardware-attested confidential enclave, and anyone can verify that.

Inside the enclave your app runs in a strict capability sandbox, in its own OS process, launched by the exact same wasmtime command you can run locally:

  • Filesystem: a single private, writable /data directory (RAM-backed scratch, torn down with the deployment). Nothing else is visible, and paths that escape it (/data/../..) are refused by the runtime. Chapter 08 →
  • Environment: empty, except ENCLAVE_PORTS for service apps. No host env ever leaks in.
  • Network: none by default beyond the HTTP socket the runtime serves for you. Declaring open ports switches the app to service mode with wasi:sockets. Chapter 09 →
  • GPU: none by default. A deployment that buys a GPU share gets wasi-nn - ONNX, GGUF (llama.cpp) and image-diffusion (stable-diffusion.cpp) inference through the enclave's runtime, on an MPS-capped slice of the enclave's GPU. Chapter 12 →
  • Memory: the guest's linear memory is capped at the deployment's CPU share of the node's RAM (cpuShare × 64 GB), enforced by the runtime - always at least the memMb the app's specs declare, since the specs floor the share.
  • No host reach: no processes, no exec, no SSH (a Wasm app isn't an OS; there's nothing to SSH into, and the platform ships no SSH channel at all).

Two kinds of app, one format:

  • Web apps (the default, and this guide's walkthrough): export the handler; the platform owns the listener and routes requests to you.
  • Service apps: declare open ports (http:N / tcp:N / udp:N) and run as a long-lived command component that binds its own sockets: databases, IRC servers, DNS resolvers. Chapter 09 →

And one catalog every app reference points at:

  • The on-chain app store: the Apps tab: anyone publishes, bytes live on IPFS addressed by CID, listings live in the EnclaveAppCatalog contract on Base. First-party samples (hello-world, nn-demo, llm-chat) are published there too - the enclave image ships no deployable apps, so every deploy resolves to a CID the enclave fetches and hash-verifies. Chapter 04 →
The contract

If wasmtime serve runs your .wasm locally, the enclave runs it too: the manager launches the identical command per tenant. That's the whole compatibility story, and it's why every chapter here ends with a local command you can verify before spending a cent.

02Toolchain setup

The walkthrough uses Rust, the most mature component toolchain today (other languages in chapter 15). You need the WASI 0.2 target, cargo-component, and, for local testing, the same wasmtime runtime the enclave uses:

terminal
# Rust component toolchain
rustup target add wasm32-wasip2
cargo install cargo-component

# the runtime the enclave runs (wasmtime 45+), for local testing
curl https://wasmtime.dev/install.sh -sSf | bash
wasmtime --version

03Hello, enclave

Five steps from nothing to a running app: scaffold, declare the world, write the handler, build, run. The result is byte-for-byte the kind of component the platform's own hello-world sample is.

Step 1: scaffold a component crate

terminal
cargo component new hello --lib && cd hello
cargo add wasi        # WASI 0.2 bindings

Step 2: declare the world

The world tells the toolchain what your component imports and exports. An Enclave web app exports exactly one thing:

wit/world.wit
package enclave:hello;

world hello {
  export wasi:http/incoming-handler@0.2.0;
}

Step 3: write the handler

src/lib.rs
use wasi::http::types::{
    Fields, IncomingRequest, OutgoingBody, OutgoingResponse, ResponseOutparam,
};

wasi::http::proxy::export!(Component);

struct Component;

impl wasi::exports::http::incoming_handler::Guest for Component {
    fn handle(_req: IncomingRequest, out: ResponseOutparam) {
        // 1. make a response and take its body handle
        let resp = OutgoingResponse::new(Fields::new());
        let body = resp.body().unwrap();

        // 2. hand the response to the host FIRST, then stream the body
        ResponseOutparam::set(out, Ok(resp));
        let stream = body.write().unwrap();
        stream.blocking_write_and_flush(b"hello from Enclave\n").unwrap();
        drop(stream);

        // 3. finishing the body is what completes the response
        OutgoingBody::finish(body, None).unwrap();
    }
}
Version drift

The wasi crate's binding APIs shift between releases. Pin the version documented for your cargo-component and let the compiler guide the exact handler signature; the shape above (proxy::export!, set the outparam, write, finish) is the stable wasi:http pattern.

Step 4: build

terminal
cargo component build --release --target wasm32-wasip2
# artifact: target/wasm32-wasip2/release/hello.wasm

This emits a component (layer 1 in the wasm preamble), not a plain core module. The upload gateway and the enclave both check that field and reject core modules, so anything that builds this way is already in the right format.

Step 5: run it locally

terminal
wasmtime serve --addr 127.0.0.1:8080 target/wasm32-wasip2/release/hello.wasm
curl 127.0.0.1:8080/          # → hello from Enclave

Works? Then it works in the enclave. For a full dress rehearsal, add the exact flags the enclave launches with, giving you the same sandbox, memory cap, and /data preopen your deployment will get (the cap is your deployment's CPU share of the node's RAM - at least your published memMb; the 128 MB minimum shown here):

terminal · enclave parity
mkdir -p scratch
wasmtime serve -Scli -Shttp -Sp3 \
  --dir scratch::/data \
  -W max-memory-size=134217728 \
  --addr 127.0.0.1:8080 target/wasm32-wasip2/release/hello.wasm

04Publish to the app store

Your app's bytes go on IPFS, addressed by their CID (a hash of the exact wasm). The listing goes on Base, in the EnclaveAppCatalog contract. Publishing is one wallet transaction on the Apps tab:

  1. Connect your wallet (header button) and hit + Publish app.
  2. Pick a slug, your app's stable id, e.g. my-hello (a–z, 0–9, hyphens, ≤ 40 chars). Slugs live in your wallet's namespace: the on-chain app id is keccak256(publisher, slug), so nobody can squat or write to your lineage. The slug doesn't have to match your crate name.
  3. Set a version label (e.g. 1.0.0) and choose your hello.wasm. The file is validated (component check, ≤ 2 GB), uploaded, pinned to IPFS, and the CID field fills in (or paste a CID you pinned yourself).
  4. Set the display name, a description, and the app's exact minimum resources on four axes: the VRAM (GB) and GPU compute (TFLOPS) it needs on one card (both 0 for CPU-only apps, which is most of them), and the RAM (MB - also the runtime-enforced memory cap; the Hello app is fine at 128) and CPU compute (GFLOPS, at least 1 - every app computes something - and note the unit: a whole 16-vCPU node is only ≈1000 GFLOPS ≈ 1 TFLOPS, so declaring the node's full GFLOPS claims the entire node and forces a 100% CPU share; most small apps want 1-50) it needs on the node. These specs set the MINIMUM shares on the deploy page's two dials (spec ÷ server spec, the larger of the memory and compute axes); deploys below them are refused. Only for service apps, add the open ports the version may bind (chapter 09). Leave ports empty for a standard web app.
  5. Optionally set an hourly fee for running your app. It is stored on the version record, shown to every deployer up front, and paid straight to your publisher wallet: whenever a deployer funds a deployment of your app, your share of that funding is forwarded to your wallet in the same transaction (non-custodial, like all Enclave payments). The fee is capped on-chain by the platform, immutable per version like the ports and config, and covered by the owner's approval - re-pricing means publishing a new version, so an approved app can never silently change its price. Leave it at 0 for a free app.
  6. Publish to Base: one transaction, signed by your wallet, attributed on-chain to you.

Versioning rules (enforced by the contract)

  • Versions are append-only history: you never edit a release, you publish the same slug again with a new version label and CID. editApp changes display metadata; setActive delists the app; yankVersion pulls a bad release (kept for history, hidden from browsing).
  • A release's CID belongs to its app forever: no other app can ever list the same bytes, and yanking or delisting doesn't free it. The same app can re-list its own CID in a new version - that's the metadata fix (same bytes, corrected specs or ports; the add version button pre-fills it) - and the enclaves' deploy gate follows the newest listing of a CID, which starts Pending again (a metadata change is re-approved like any release). After the fix lands, yank the superseded version. Publishing a new version to a delisted slug automatically relists the app.
  • A CID can be listed once across the whole catalog, so a CID maps unambiguously to one app version.
  • Each version carries its own open-ports config; it can change from release to release.
Approval gates deploys

Publishing is permissionless, but every version starts Pending and the enclave refuses to deploy its CID (403 not_approved) until the catalog owner approves that exact CID on-chain. A new release starts Pending again. Your app card on the Apps tab shows the per-version status badge. The separate verified badge is a curation signal only: it never gates execution; the CID does.

05Deploy it

A deployment names an app, a slice of the card, and who may reach it. The app reference is always a catalog listing:

referencemeaning
my-hello:1.0.0A store app by slug and version (or <publisher>/my-hello:1.0.0 to disambiguate). It resolves against the on-chain catalog to that version's exact ipfs://<cid>; the enclave fetches those bytes, verifies every block hashes to the CID (a tampering gateway fails the check), confirms it's a component, and runs it. The verified CID is folded into the attestation.

Unlisted CIDs can't deploy: the catalog listing carries everything a deploy needs on-chain - the resource specs that floor the dials, the open ports, the publisher's hourly fee if the version charges one (shown in the rate before you sign; paid to the publisher's wallet out of each funding), and the owner's approval of that exact CID. Built something new? Publish it first (chapter 04), then deploy the slug.

The easy path is the Deploy console: your published app's card even has a Use in Deploy button that prefills the reference and open ports. Tick Dry run to inspect every call before spending anything. The console speaks the same public API you can drive yourself:

Sign in: an Ethereum signature - your wallet is your account

deploy.js · SIWE handshake
const API = "https://api.enclave.host/v1";

const addr  = (await ethereum.request({ method: "eth_requestAccounts" }))[0];
const { message } = await (await fetch(`${API}/auth/nonce?address=${addr}`)).json();
const signature   = await ethereum.request({ method: "personal_sign", params: [message, addr] });
const { token }   = await (await fetch(`${API}/auth/login`, {
  method: "POST", headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ message, signature }),
})).json();          // short-lived bearer token, bound to your address

Create the deployment: one transaction, you own the record

A deployment is an on-chain work item, not one enclave's private state. create() on the EnclaveDeployments contract records the intent - the app's catalog version record (catalog://<appId>/<versionIndex>), the two shares (in 1/1000ths), visibility - under your address, and mints the id (keccak256(sender, nonce), the Created event's first topic). The reference names a version, never bytes: runners take the wasm, config and ports from that immutable record - exactly what the catalog owner approved. (CID references are refused: a CID names bytes, and several versions with different approved configs can share bytes.) Because the ledger holds the spec and the balance, the deployment survives enclave updates and crashes: runners serve it under expiring leases, and when a runner dies, any other enclave picks the work up. Nothing to sign in besides your wallet.

deploy.js · EnclaveDeployments.create()
// create(appRef, gpuMilli, cpuMilli, appPort, ports, isPublic, configCid,
//        feeRecipient, feePerSec6)
// appRef = catalog://<appId>/<versionIndex> - the on-chain version record
// shares are 1/1000ths: gpuShare 0.25 -> 250; a GPU app needs gpuMilli >= cpuMilli
// appPort/ports ride along untrusted (the record decides); configCid must be ""
// feeRecipient/feePerSec6 copy the version's publisher fee (its publisher's
// wallet + the per-second fee; 0x0/0 for a free app) - snapshotted like the
// rate, and runners refuse a record that under-declares it (the consoles and
// the CLI fill this in for you)
create("catalog://0x06a4…c13/5", 250, 50, 8080, "", true, "", "0x0…0", 0)
// -> the receipt's Created event: topics[1] is your deployment id (bytes32)

Attach model volumes: big weights, attested, not in the wasm

An enclave can carry attested read-only model volumes (Tinfoil Modelwrap): weights declared in the enclave's config become a dm-verity image whose root hash is part of the enclave measurement, so the attestation commits to the exact bytes. A version attaches volumes by name and the runner mounts each read-only at /models/<name> inside the guest - so a multi-GB model reaches your app without riding the app wasm (no include_bytes!, no IPFS weight fetch, none of the 4 GiB linear-memory ceiling on the weights). Volumes ride the version's config - its volumes key - set at publish (the publish form's App config box, or enclave publish --config) and delivered to the app as ENCLAVE_CONFIG straight from the on-chain record. Like the bytes, specs and ports, the config is immutable once published and covered by the version's approval - a publisher cannot change an approved release's behavior, and a deployer cannot deploy behavior the owner never reviewed; a new config means a new version and a fresh review.

the version's config → ENCLAVE_CONFIG
// published WITH the version (enclave publish --config '…'); the runner reads it
// from the catalog record, mounts each volume at /models/<name> (read-only), and
// hands the whole object to the app as ENCLAVE_CONFIG.
{ "volumes": ["qwen2.5-0.5b"], "system_prompt": "…app config…" }
// GET /availability lists the volumes the fleet carries (name, bytes, which enclaves).

Fund it: pay-per-second, credited on-chain, no custody

Funding lands in the deployment's on-chain balance6 (the money itself forwards in the same transaction - the contract holds nothing, and payments are final). Your rate is snapshotted at create: gpuShare × $6/hr + cpuShare × $3/hr, plus the app's hourly publisher fee if it charges one. On a paid app every funding is split in that same transaction: the publisher's share (their fee's fraction of the rate) goes straight to their wallet, the rest to Enclave - still nothing custodied. Pay either way:

EnclaveDeployments · two ways to fund
// USDC: sign an EIP-3009 ReceiveWithAuthorization (gas-free wallet signature,
// to = the EnclaveDeployments contract, nonce prefixed with the id's first 16 bytes), then ONE tx:
fundWithAuthorization(bytes32 id, address from, uint256 value,
                      uint256 validAfter, uint256 validBefore,
                      bytes32 nonce, bytes signature)

// ETH: one payable call; credited at the Chainlink ETH/USD rate read IN the contract:
fundEth(bytes32 id) payable

Enclaves sweep the ledger for funded, unleased work about once a minute; POST /v1/claim-hint {"id": …} skips the wait by asking the fleet to claim right now. The claiming enclave fetches your wasm from IPFS, verifies it hashes to the CID, launches it, and renews its lease while healthy - balance / rate seconds of runtime, burned lease by lease, with clean handovers refunding the unused tail. The console does all of the signing and encoding for you.

Watch it come up

The ledger shows the claim (get(id).runner + a live leaseUntil); the runner's status API shows the launch. The SIWE token from above authorizes the owner-only status route - sign in with the same wallet that sent create().

deploy.js · poll until running
let d;                                   // id = the bytes32 from the Created event
do {
  await new Promise(r => setTimeout(r, 2000));
  d = await (await fetch(`${API}/deployments/${id}`, {
    headers: { "Authorization": `Bearer ${token}` },
  })).json();
} while (d.status !== "running");

console.log(d.network.endpoint);     // https://…/x/0x…
// your app's own origin: the id's first 8 hex chars as a subdomain
console.log(`https://${id.slice(2, 10)}.app.enclave.host`);

Keep it alive, or stop it

  • Top up: another fundWithAuthorization / fundEth with a fresh nonce, any time - each payment adds amount / rate seconds to the on-chain balance.
  • Upgrade: setAppRef(id, "catalog://<appId>/<newIndex>") (owner-only) repoints the deployment at another approved version of the app - paid time, shares and the live lease all stay on the record, and the runner restarts the app in place on the new version within about a minute (new wasm fetched before the old instance stops). No second buy-in to pick up a new release; the dashboard's Version control and enclave upgrade are exactly this call. Shares bought at create() are immutable - and so is the publisher-fee snapshot - so a version whose minimums exceed the shares, or whose publisher fee exceeds the snapshot, can't be switched to - deploy that one fresh.
  • Stop: setActive(false) on the contract (owner-only) takes the work off the queue - the current runner's watcher tears it down and releases the lease, refunding the unused tail to your deployment's balance. That balance stays on the record: setActive(true) later resumes from what's left, but it can never be withdrawn to your wallet - payments are final, so fund in small top-ups. DELETE /v1/deployments/{id} alone only stops the CURRENT run; an active, funded deployment gets re-claimed.
  • Fairness: the quantum of trust is the lease (30 min). A runner that dies mid-lease has burned that lease - that is the worst case; clean shutdowns and enclave updates release() and lose you nothing, and another enclave resumes your app from its CID.

06Call your app

Every deployment serves HTTP on its own path and, through the gateway, its own origin (so one app can never touch another app's cookies or storage, or the site's):

terminal
# public deployment: anyone, no auth
curl https://3xk9f2qa.app.enclave.host/            # per-deployment origin (id without "dep_")
curl https://<enclave-host>/x/dep_3xK9f2Qa/    # same app, direct to its enclave

# private deployment (public: false): owner's bearer token only
curl -H "Authorization: Bearer $LOGIN_TOKEN" https://<enclave-host>/x/dep_3xK9f2Qa/
  • Any method, any subpath, headers and body pass through to your handler. The one exception: the Authorization header authenticates at the platform and is stripped before your app sees the request; the token never reaches tenant code.
  • public only governs who may send requests. Confidentiality is unaffected either way; the operator can't see inside the enclave regardless.
  • Your app's println! / stderr output lands in the deployment log: GET /v1/deployments/{id}/logs (owner-only).

07HTTP in depth: routing, headers, body

Everything a web app needs beyond hello: reading the method, path and headers, consuming the request body, and writing a status, headers, and a streamed response. This drop-in src/lib.rs shows all of it:

src/lib.rs · echo app
use wasi::http::types::{
    Fields, IncomingRequest, Method, OutgoingBody, OutgoingResponse, ResponseOutparam,
};

wasi::http::proxy::export!(Component);
struct Component;

fn read_body(req: IncomingRequest) -> Vec<u8> {
    let body = req.consume().unwrap();
    let stream = body.stream().unwrap();
    let mut buf = Vec::new();
    while let Ok(chunk) = stream.blocking_read(64 * 1024) {
        if chunk.is_empty() { break; }
        buf.extend_from_slice(&chunk);
    }
    buf                       // a stream error here means end-of-body (Closed)
}

fn respond(out: ResponseOutparam, status: u16, ctype: &str, payload: &[u8]) {
    let headers = Fields::new();
    headers.set(&"content-type".to_string(), &[ctype.as_bytes().to_vec()]).unwrap();
    let resp = OutgoingResponse::new(headers);
    resp.set_status_code(status).unwrap();
    let body = resp.body().unwrap();
    ResponseOutparam::set(out, Ok(resp));
    let stream = body.write().unwrap();
    for chunk in payload.chunks(4096) {       // write in ≤ 4096-byte chunks
        stream.blocking_write_and_flush(chunk).unwrap();
    }
    drop(stream);
    OutgoingBody::finish(body, None).unwrap();
}

impl wasi::exports::http::incoming_handler::Guest for Component {
    fn handle(req: IncomingRequest, out: ResponseOutparam) {
        let method = req.method();                               // Method::Get, Method::Post, …
        let path   = req.path_with_query().unwrap_or_default();  // "/echo?x=1"

        match (method, path.as_str()) {
            (Method::Get, "/") =>
                respond(out, 200, "text/plain", b"routes: GET /headers - POST /echo\n"),

            (Method::Get, "/headers") => {                       // request headers
                let mut s = String::new();
                for (name, value) in req.headers().entries() {
                    s.push_str(&format!("{name}: {}\n", String::from_utf8_lossy(&value)));
                }
                respond(out, 200, "text/plain", s.as_bytes());
            }

            (Method::Post, p) if p.starts_with("/echo") => {     // request body
                let body = read_body(req);
                respond(out, 200, "application/octet-stream", &body);
            }

            _ => respond(out, 404, "text/plain", b"not found\n"),
        }
    }
}
terminal · test it
wasmtime serve --addr 127.0.0.1:8080 target/wasm32-wasip2/release/hello.wasm
curl 127.0.0.1:8080/headers
curl -X POST --data-binary 'round trip' 127.0.0.1:8080/echo
Fresh instance per request

wasmtime serve instantiates your component per request: globals and statics do not survive from one request to the next. State that must outlive a request goes in /data (next chapter). Also note the 4096-byte ceiling per blocking_write_and_flush call; chunk larger payloads as above.

08The /data scratch filesystem

Every deployment gets a private, writable /data directory, so ordinary file-using code (a SQLite database, a cache, config, logs) ports to wasm without a rewrite. In Rust it's plain std::fs; every language's stdlib file I/O maps to the same wasi:filesystem interface. Extending the echo app with a hit counter:

src/lib.rs · add a route
use std::fs;

// (Method::Get, "/hits"): a per-deployment counter that survives across requests
(Method::Get, "/hits") => {
    let hits: u64 = fs::read_to_string("/data/hits")
        .ok().and_then(|s| s.trim().parse().ok())
        .unwrap_or(0) + 1;
    fs::write("/data/hits", hits.to_string()).unwrap();
    respond(out, 200, "text/plain", format!("visit #{hits}\n").as_bytes());
}

Test locally with the parity flag from chapter 03 (--dir scratch::/data). Know exactly what /data is:

  • Private: your app sees only its own /data; there is no preopen for anything else, and escaping paths are refused by the runtime. Isolation is the wasi capability model, not a convention.
  • Ephemeral: it's RAM-backed scratch inside the enclave's already-encrypted memory, torn down when the deployment ends. No persistence, no shared storage: treat it as scratch, not a database of record. For data that persists under a key only your user holds, see encrypted volumes (ch. 13).
  • Capped: usage is limited by the version's storage_mb (default 256 MB); an app that exceeds it is killed by the audit sweep, since ramdisk files consume RAM the memory cap doesn't cover. Publish with storage_mb: 0 to opt out of /data entirely.

09Service apps: open ports & ENCLAVE_PORTS

Declaring open ports (on the version you publish, prefilled into the deploy request by Use in Deploy) changes how your app runs: instead of wasmtime serve calling your handler, the enclave launches it with wasmtime run as a long-running command component (a main()) granted wasi:sockets: it binds its declared ports itself, and may also make outbound TCP/UDP connections and DNS lookups. Rust's std::net on wasm32-wasip2 maps straight onto it.

entrymeaningreached at
http:Nyour app serves HTTP on port N itself (max one entry)/x/{id}/…, the same URL as a web app
tcp:Na raw TCP listener/x/{id}/tcp/N bridge · /x/{id}/tls/N + public relay (ch. 10)
udp:Na datagram socket/x/{id}/udp/N bridge · per-deployment IPv6 (ch. 11)

Declared ports are logical: your app's stable, advertised interface, like a container's EXPOSE. Each deployment gets its own actual loopback bind, so any number of tenants can run "the tcp:5432 app" simultaneously with zero conflicts; URLs always use the logical number and route by deployment id. The mapping arrives in one environment variable:

the ENCLAVE_PORTS contract
ENCLAVE_PORTS=tcp:5432=31245,udp:9053=31246     # logical=actual; BIND THE ACTUAL
The one rule

Read ENCLAVE_PORTS and bind the actual ports. Never hardcode. The socket grant is coarse, so an audit sweep polices the fine print: an app that binds a port it wasn't assigned is killed on the spot. Labels are 1–19999 (8080/8091 reserved for infrastructure); labels below 1024 (say udp:53) are always remapped to an unprivileged actual. Up to 8 entries per version.

Parsing the contract is a few lines; every service example below uses this helper:

src/main.rs · ENCLAVE_PORTS helper
/// "tcp:7000=31245,udp:9053=31246" → the actual port for a logical entry.
fn actual_port(logical: &str) -> u16 {
    std::env::var("ENCLAVE_PORTS").unwrap_or_default()
        .split(',')
        .find_map(|e| e.trim().strip_prefix(&format!("{logical}="))?.parse().ok())
        .unwrap_or_else(|| panic!("{logical} not in ENCLAVE_PORTS"))
}

Readiness: the enclave waits (~20 s) for your first http:/tcp: port to accept before marking the deployment running, so bind early in main(). Both launch modes also pass -S p3, so you may target the async WASIp3 API surface instead of wasip2's poll-based one. Same sandbox, same rules; guest toolchains for p3 are still young.

10TCP: bridge, in-enclave TLS, public relay

A complete TCP echo service. Note it's a command component (no --lib) with a main() that runs for the deployment's lifetime:

src/main.rs · tcp echo
use std::io::{Read, Write};
use std::net::TcpListener;

fn main() {
    let port = actual_port("tcp:7000");             // ← the helper from chapter 09
    let listener = TcpListener::bind(("0.0.0.0", port)).expect("bind");
    println!("echo on {port}");                     // shows up in your deployment logs
    for conn in listener.incoming() {               // wasip2 has no threads:
        let Ok(mut sock) = conn else { continue };  // serve connections one at a time
        let mut buf = [0u8; 4096];
        while let Ok(n) = sock.read(&mut buf) {
            if n == 0 || sock.write_all(&buf[..n]).is_err() { break; }
        }
    }
}
terminal · build &amp; test in run mode
cargo component new tcp-echo && cd tcp-echo     # no --lib: a command component
cargo component build --release --target wasm32-wasip2

# exactly how the enclave launches a service app:
wasmtime run -Scli -Sp3 -Stcp -Sudp -Sinherit-network -Sallow-ip-name-lookup \
  --env ENCLAVE_PORTS=tcp:7000=7000 \
  target/wasm32-wasip2/release/tcp-echo.wasm
printf 'ping\n' | nc 127.0.0.1 7000              # → ping   (another terminal)

Publish it with open ports tcp:7000, deploy, and reach it three ways:

terminal · clients
# 1. WebSocket bridge on the attested origin (private deployments: append ?token=<bearer>)
websocat --binary tcp-listen:127.0.0.1:7000 \
  "wss://<enclave-host>/x/dep_3xK9f2Qa/tcp/7000"
nc 127.0.0.1 7000                                # → your app, end to end

# 2. Direct public TLS via the relay: stock clients, no websocat. Public deployments
#    only; hostname = deployment id with its underscore hyphenated (dep_abc123 → dep-abc123)
psql "host=abc12345.app.enclave.host port=5432 sslmode=verify-full"
irssi -c abc12345.app.enclave.host -p 6667 --tls

Your app does nothing for the TLS path: keep speaking plain TCP on the assigned port. The platform mints its key + cert inside the enclave at boot; the untrusted relay only routes ciphertext by SNI, and the session terminates in-enclave at /x/{id}/tls/{port}. Verifying clients can bind the session to the attestation by pinning the cert published over the attested origin:

terminal · pin the bridge cert
curl -s https://<enclave-host>/v1/tls-bridge | jq -r .fingerprint256
openssl s_client -connect abc12345.app.enclave.host:6667 \
  -servername abc12345.app.enclave.host </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256       # must match

11UDP: datagrams & per-deployment IPv6

Same shape as TCP: bind the actual port, speak datagrams:

src/main.rs · udp echo
use std::net::UdpSocket;

fn main() {
    let port = actual_port("udp:9053");             // ← the helper from chapter 09
    let sock = UdpSocket::bind(("0.0.0.0", port)).expect("bind");
    let mut buf = [0u8; 65535];
    loop {
        let Ok((n, from)) = sock.recv_from(&mut buf) else { continue };
        let _ = sock.send_to(&buf[..n], from);
    }
}

Every public deployment gets its own dedicated IPv6 address (deterministic from its id, out of the relay's routed /64). Its declared tcp:N and udp:N ports are reachable at [address]:N - at the real port you declared, routed by destination IP, no SNI and no remapping. The address is in the deploy/status response under network.address (and network.tcp / network.udp per protocol). TCP is raw passthrough (any protocol - your app owns TLS or plaintext); publish with udp:9053 or tcp:5432:

terminal · clients
# 1a. UDP, direct to the deployment's own IPv6 (public deployments)
printf 'ping' | socat -t2 - 'UDP6:[2a01:4f9:c013:9b52:9c1e:…]:9053'

# 1b. TCP, direct to the SAME address at the real port (any protocol)
psql 'host=2a01:4f9:c013:9b52:9c1e:… port=5432 …'
nc 2a01:4f9:c013:9b52:9c1e:… 5432

# 2. the WebSocket bridge on the attested origin (1 message = 1 datagram)
websocat --binary udp-listen:127.0.0.1:9053 \
  "wss://<enclave-host>/x/dep_3xK9f2Qa/udp/9053?token=<bearer>"
printf 'ping' | socat -t2 - UDP:127.0.0.1:9053
  • IPv6 only: a box has one IPv4; v4-only clients can't reach the direct endpoint (the bridge still works anywhere WebSockets do).
  • The relay sees cleartext: unlike the TCP path, the UDP relay is not a confidentiality boundary. If your protocol needs privacy, encrypt at the app layer (e.g. DTLS).
  • Datagram boundaries are preserved end to end; idle bridge flows close after 120 s.

12GPU inference (wasi-nn)

Buying a GPU share doesn't just reserve silicon - it grants an interface. A deployment with gpuShare > 0 is launched with wasi-nn: your component imports wasi:nn and hands the runtime a model. Three backends sit behind the same interface - ONNX Runtime for ONNX graphs, a bundled llama.cpp (ggml) backend for GGUF language models, and a bundled stable-diffusion.cpp backend for image-generation checkpoints. execution-target gpu runs on the card (the CUDA provider / CUDA-offloaded llama.cpp / GPU diffusion); cpu runs on the node. A deployment without a GPU share doesn't get the import at all - the component fails to instantiate - so the boundary is structural, not a quota.

  • The share is hardware-enforced per process. Your app's runtime process joins the enclave's MPS daemon with an SM cap equal to your gpuShare and a VRAM cap of gpuShare × 140.4 GB - the same mechanism the platform uses everywhere, and exactly the budget the deployment priced.
  • GPU means GPU. The platform's runtime is patched so target gpu either runs on CUDA or fails the load() - never a silent fallback to the CPU. On the ONNX path that means the CUDA provider initializes or the load fails (ONNX Runtime's default is a quiet CPU fallback); on the GGUF path the whole model offloads to your slice by default, and the backend refuses to run if the CUDA module didn't actually load; the diffusion backend is strict the same way. Want CPU inference? Ask for cpu.
  • Isolation is the capability model again: graphs and tensors are opaque handles scoped to your instance. Nothing like a device pointer or a raw kernel ever crosses the boundary.

Import the interfaces in your world (WIT deps vendored from wasmtime 45, wasi:nn 0.2.0-rc-2024-10-28):

wit/world.wit
package you:app;

world app {
  import wasi:nn/tensor@0.2.0-rc-2024-10-28;
  import wasi:nn/errors@0.2.0-rc-2024-10-28;
  import wasi:nn/inference@0.2.0-rc-2024-10-28;
  import wasi:nn/graph@0.2.0-rc-2024-10-28;
  export wasi:http/incoming-handler@0.2.6;
}

Load once, compute per request - the whole API is four calls, and it drives all three backends. The ONNX path first. Tensors cross the boundary as little-endian bytes in any ONNX element type - fp16, fp32, fp64, bf16, i32, i64, u8 - and outputs come back in the model's actual dtype. Zero-size dimensions are legal (an empty KV cache bootstrapping a decoder's first step), and compute returns owned tensor handles you can feed straight back as the next step's inputs, so KV-cache bytes never round-trip through your linear memory.

src/lib.rs · inference
use bindings::wasi::nn::graph::{load, ExecutionTarget, GraphEncoding};
use bindings::wasi::nn::tensor::{Tensor, TensorType};

static MODEL: &[u8] = include_bytes!("model.onnx");

let graph = load(&[MODEL.to_vec()], GraphEncoding::Onnx, ExecutionTarget::Gpu)?;
let ctx   = graph.init_execution_context()?;
let x     = Tensor::new(&[1, 4], TensorType::Fp32, &input_bytes);   // little-endian f32s
let out   = ctx.compute(vec![("X".into(), x)])?;                     // named tensors, in and out

Try it without writing code: the nn-demo app in the Apps catalog is exactly this. Deploy it with 1% GPU + 1% CPU and hit /?target=gpu; it runs a tiny ONNX graph and echoes which target served it.

The GGUF path: generative LLMs via llama.cpp. GraphEncoding::Ggml loads any GGUF model - split-GGUF families included - on the runtime's bundled llama.cpp backend. The natural delivery is a model volume (ch. 05): attach a GGUF volume to your version and the runtime preloads it once at process start as a named graph, registered under the volume's name. load_by_name() then hands you the graph without the weights ever entering your linear memory - so model size is bounded by your share of the card, not by wasm32's 4 GiB. (Small GGUFs can still ride load() from embedded bytes.) The inference contract is an autoregressive decode loop: feed "tokens" (i32, dims [1, n], chunk your prefill at 512 tokens per compute), get back "logits" (fp32, [1, n_vocab], for the last token), sample, feed the sampled token back. The KV cache lives inside the execution context and persists across compute() calls - a fresh context is a fresh sequence. Tokenization and sampling are yours: the backend speaks token ids, not strings.

src/lib.rs · GGUF decode loop
use bindings::wasi::nn::graph::load_by_name;

let graph = load_by_name("qwen2.5-0.5b")?;   // the attached GGUF volume's name
let ctx   = graph.init_execution_context()?; // fresh context = fresh sequence
let mut next = prompt_ids;                   // Vec<i32>, ≤ 512 per compute
loop {
    let t   = Tensor::new(&[1, next.len() as u32], TensorType::I32, &le_bytes(&next));
    let out = ctx.compute(vec![("tokens".into(), t)])?;  // ("logits", fp32 [1, n_vocab])
    let id  = sample(&out);                  // your sampler: greedy, top-p, …
    if id == EOS { break; }
    emit(id); next = vec![id];               // KV cache persists in `ctx`
}

And for the full-scale proof, deploy llm-chat from the same catalog: an OpenAI-compatible LLM service with a web chat UI in one small component - and no weights in the wasm at all. Its versions attach GGUF model volumes; the runtime preloads each as a named graph, and the app drives the whole tokens-in/logits-out decode through wasi:nn on your MPS slice of the GPU: chunked prefill, KV cache resident in the execution context, tokenizer and sampler in the guest, target gpu strict.

The image path: diffusion via stable-diffusion.cpp. The same four calls generate images. Attach an image-checkpoint volume - SD-Turbo-class full checkpoints, SDXL, FLUX-family GGUF quants, or split-component DiT families (Qwen-Image, Z-Image) - and the runtime preloads it through the bundled stable-diffusion.cpp backend as a named graph: load_by_name(), weights never in your linear memory, exactly like GGUF volumes. The contract is one compute() per image, all named tensors: prompt (utf-8 bytes) is the only required input; negative_prompt, steps (i32, default 20), seed (i64), width/height (i32, 64-multiples, default 512), cfg (fp32, default 7.0 - set 1.0 for turbo models) and sample_method/scheduler (sd.cpp names) are optional. Back comes image: u8, dims [1, h, w, 3], RGB row-major. Generation blocks inside the call, and requests against the same checkpoint queue - one generation at a time per model.

src/lib.rs · one image
let graph  = load_by_name("sd-turbo")?;      // the attached checkpoint volume's name
let ctx    = graph.init_execution_context()?;
let prompt = b"a lighthouse in a storm, oil painting";
let out    = ctx.compute(vec![
    ("prompt".into(), Tensor::new(&[prompt.len() as u32], TensorType::U8, prompt)),
    ("steps".into(),  Tensor::new(&[1], TensorType::I32, &4i32.to_le_bytes())),
    ("cfg".into(),    Tensor::new(&[1], TensorType::Fp32, &1.0f32.to_le_bytes())), // turbo: guidance off
])?;                                         // ("image", u8 [1, 512, 512, 3], RGB)

Getting the model in. Three routes. Embed it (include_bytes!) for anything small - it ships inside your published CID, so it's attested with your code; the ceilings are the ≤ 2 GB wasm artifact and the ≤ 4 GiB linear memory that load() passes bytes through. Attach a model volume (ch. 05) for anything big: attested read-only weights mounted at /models/<name> - and a GGUF or image-checkpoint volume is additionally preloaded as a named wasi-nn graph for load_by_name(), skipping your linear memory entirely. Or fetch at startup over outbound HTTP into memory and check the hash yourself before loading - the platform verifies your code by CID, a fetched model is your responsibility.

Local testing

Stock wasmtime release binaries don't compile wasi-nn in - and the platform's runtime carries patches you'll want locally too (strict GPU target, the full tensor-dtype set, zero-size tensors, the GGUF and diffusion backends; unpatched wasmtime rejects everything but fp32 and has no ggml or sdcpp at all). Build the same runtime the enclave uses: in a wasmtime 45 checkout, git apply wasm/wasmtime-onnx-gpu-strict.patch (and wasm/wasmtime-nn-ggml.patch for GGUF, plus wasm/wasmtime-nn-sdcpp.patch on top of it for diffusion) from the Enclave repo, point ORT_LIB_LOCATION at the official Microsoft onnxruntime-linux-x64-gpu distribution, and cargo build --release --features "wasi-nn,wasmtime-wasi-nn/onnx-cuda,wasmtime-wasi-nn/ggml" - the ggml feature links the prebuilt enclave-llamacpp libraries from the repo's releases via ELL_LIB_LOCATION (add wasmtime-wasi-nn/sdcpp, linking libenclave_sd via ESD_LIB_LOCATION, for the diffusion backend); then wasmtime serve -S nn app.wasm, adding -S nn-graph=ggml::<dir> to preload a local GGUF (or sd::<dir> for an image checkpoint) under the directory's basename like the platform does with volumes. (Avoid the onnx-download feature: it pulls a prebuilt ONNX Runtime with precompiled kernels for consumer GPUs only - it works on a desktop card but JIT-hangs on datacenter parts. The MS build is exactly how the platform's own runtime is built - see wasm/Dockerfile.wasmtime in the repo.) Without CUDA libraries installed, target gpu fails (by design); test with cpu locally and gpu on the enclave.

13Encrypted volumes

Confidential storage where only your user holds the key. A directory is encrypted client-side with rclone crypt and the ciphertext pushed to any S3-compatible bucket the owner controls; the deployment's App Config names where that ciphertext lives - never a key. At unlock time the key enters through the app, over the deployment's in-enclave-terminated TLS, and the enclave pulls and decrypts into a live mount. The bucket, the network, and the operator's host only ever see ciphertext: file names and contents are both encrypted at rest, and plaintext exists only on the CVM's encrypted ramdisk while the volume is unlocked.

Push data

scripts/enclave-encvol.sh in the Enclave repo is the client half: it encrypts locally and syncs ciphertext up, mirroring exactly what the enclave runs at unlock, so a push here always pulls there.

terminal · encrypt + upload
AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=… \
ENCVOL_PASSWORD=… ENCVOL_SALT=… \
  scripts/enclave-encvol.sh push ./my-data \
  --endpoint https://s3.example.com --bucket my-bucket --path vol --name docs

It prints the exact encVolumes App Config snippet to publish. pull and ls round-trip the volume from anywhere else you hold the key.

Name it in the App Config

Everything in the config is public and non-secret - it says where ciphertext lives and how it was packed:

fieldmeaning
namethe mount: your app sees /enc/<name>
endpoint · bucket · pathwhere the ciphertext lives (any S3-compatible endpoint; public hosts only)
provider · regionS3 dialect hints, when the endpoint needs them
filenameEncryption · directoryNameEncryptionhow names were packed (defaults: standard, on - must match the push)
maxMbplaintext cap (default 1024); like /data, it is RAM-backed, and a volume that outgrows it gets the app killed by the audit sweep
readOnlydrop the S3 credentials right after the pull; disables push-back
unlock · keyIdUI hints: lead with wallet or password; keyId is the stable label the wallet signs, so renaming a volume doesn't change its key
credsEnvelopeS3 credentials, sealed under the owner's wallet key - safe to publish (below)
encrypteddefault true; false mounts the bucket verbatim, no crypt layer (below)

What your app sees

The deployment starts immediately, with an empty live preopen per volume - no key ceremony on the critical path. After unlock, decrypted files appear in place; your code needs no crypto and no S3 client, just std::fs reads. Three environment variables are the whole contract:

the ENCLAVE_ENC contract
ENCLAVE_ENC=docs,models          # volume names; each mounted at /enc/<name>
ENCLAVE_ENC_API=http://…         # the in-enclave manager's loopback unlock plane
ENCLAVE_ENC_TOKEN=…              # bearer for that plane - only your app holds it

Your app drives the plane over loopback: GET $ENCLAVE_ENC_API for per-volume status (locked / syncing / unlocked / pushing), and POST …/unlock (password, optional salt, optional S3 credentials), …/sync (encrypt local edits back to the bucket), …/lock (wipe plaintext, drop credentials) - all authenticated with the token, which must never reach a browser. The first-party encrypted-volumes app in the store is the reference implementation: a complete unlock UI whose /api/* proxy keeps the token server-side.

Three ways to unlock

Password. The manager takes an opaque password (+ optional salt) and runs the pull. Simplest, and what headless automation uses.

Wallet. No password to keep: the key is derived from a deterministic ECDSA personal_sign over a canonical message naming the volume - only the wallet holder can reproduce it, nothing is stored or verified server-side, and no transaction is made. The contract is byte-exact across the CLI, the push script, and the app:

the derivation contract (v1)
message  = "Enclave encrypted volume key v1\nvolume: <keyId>\n\n
            Signing derives this volume's encryption key. Only sign in
            apps you trust with its contents."
sig      = lowercase 65-byte personal_sign hex, 0x-prefixed
password = sha256_hex( sig + "\n" + "enclave-encvol-v1:password" )
salt     = sha256_hex( sig + "\n" + "enclave-encvol-v1:salt" )

Wallet + sealed credentials: one signature, nothing typed. Bucket credentials can ride the public config encrypted under the same signature - enclave encvol seal-creds prints the credsEnvelope value. At unlock, one signature derives the crypt key and opens the credentials, in the browser, before anything is sent:

the credentials envelope (v1)
encKey / macKey = sha256( sig + "\n" + "enclave-encvol-v1:creds-enc" / ":creds-mac" )
envelope        = "encv1:" + base64( iv[16] || AES-256-CTR(encKey, iv, credsJSON)
                                            || HMAC-SHA256(macKey, iv || ct)[32] )

Plain volumes: skip the crypt layer

Not everything is secret. "encrypted": false mounts the bucket verbatim - same live /enc/<name> preopen, same unlock/sync/lock lifecycle and caps, no crypt anywhere. Think public datasets, published model weights, assets your app serves. Unlock then takes S3 credentials only - an empty body suffices for a public-read bucket - and refuses a password outright; filenameEncryption / directoryNameEncryption don't apply. Push data with any S3 client, or enclave-encvol.sh push --plain to get the matching config snippet. The bucket host sees every byte of a plain volume: keep anything confidential in an encrypted one.

Restarts, locking, write-back

Nothing survives a restart, by design: an unlocked volume is plaintext in enclave RAM plus credentials in the manager's memory, and a re-claim, update, or crash returns the volume to locked - someone holding the key must unlock again. Wallet mode makes that a ten-second signature rather than a secret to retrieve; Lock is the same wipe on demand. Push-back (sync) re-encrypts local edits to the bucket with the same key - it needs write-capable credentials at unlock and a volume not marked readOnly.

Trust fine print

rclone crypt authenticates file contents, not the tree: a malicious bucket can serve a stale or partial file set, though never forged bytes. The credsEnvelope is exactly as sensitive as the volume - the wallet that signs for the key opens the credentials, so guard that wallet accordingly. Wallet unlock needs a deterministic (RFC 6979) EOA signer - MetaMask, Ledger, and the CLI wallet qualify; smart-contract and randomized MPC wallets don't, so give those users password mode.

14Attestation for your users

The platform's core promise extends to your app: a user can verify, cryptographically and before sending a byte of data, that they're talking to an attested enclave running exactly the code that was measured. For apps deployed by CID, the attestation includes your app: its app.cid field names the exact bytes that ran, hash-verified against the CID inside the enclave by the measured runtime, with a coverage note stating precisely what covers it (the boot measurement covers the runtime; the runtime pins your app).

verify.js · attested client
import { SecureClient } from "tinfoil";

const c = new SecureClient({ baseURL: "https://<enclave-host>" });
await c.ready();          // verifies the CPU TEE report (AMD SEV-SNP on this fleet) + Sigstore
                          // code provenance, compares measurements, pins TLS
const res = await c.fetch("/x/dep_3xK9f2Qa/");   // provably served by attested code
terminal · independent paths
# fully independent check with tinfoil-cli:
tinfoil attestation verify -e <enclave-host> -r EnclaveHost/enclave

# the enclave's attestation (public), and your deployment's view of it (owner):
curl https://<enclave-host>/v1/attestation
curl -H "Authorization: Bearer $LOGIN_TOKEN" \
  https://<enclave-host>/v1/deployments/dep_3xK9f2Qa/attestation

Point your users at the attestation walkthrough for what each link in the chain proves, or embed the check in your own client as above. For raw-TCP services, add the bridge-cert pin so even the TLS session is bound to the measured enclave.

15Other languages

The platform runs components, not a language: anything that emits a wasi:http component works. Mature routes today:

  • Rust: cargo-component, as used throughout this guide. The most complete support, including std::net for service apps.
  • JavaScript / TypeScript: jco componentize (ComponentizeJS) turns a JS module exporting a fetch-style handler into a wasi:http component.
  • Python: componentize-py builds components from Python code implementing the same world.
  • Go: TinyGo's wasip2 target with the wasi:http bindings.

The acceptance test is identical regardless of toolchain. If this runs, it deploys:

terminal
wasmtime serve --addr 127.0.0.1:8080 app.wasm && curl 127.0.0.1:8080/

One caveat for service apps: raw-socket support (wasi:sockets) varies by toolchain: Rust's stdlib maps today; check yours before designing around tcp:/udp: ports.

16Limits & sandbox reference

What your code can touch

capabilityweb app (default)service app (declared ports)
inbound HTTPyour handler, fresh instance per requesthttp:N: you bind and serve it yourself
socketsnonebind assigned actuals · outbound TCP/UDP · DNS lookups
GPU (wasi-nn)only with gpuShare > 0: ONNX + GGUF (llama.cpp) + image diffusion (stable-diffusion.cpp) via wasi:nn on an MPS-capped slice (SM% = share, VRAM = share × 140.4 GB); no share = no import (ch. 12)
filesystem/data only: private, RAM-backed, ephemeral, capped by storage_mb
environmentemptyENCLAVE_PORTS only
memorylinear memory capped at the deployment's cpuShare × node RAM, enforced by the runtime
host / exec / SSHnone: structurally absent, not merely denied

Numbers to know

limitvaluenotes
wasm artifact≤ 2 GBenforced at upload and again at fetch
app specs (vramMb/gpuGflops/memMb/cpuGflops)exact MB + GFLOPS, per versionset the MINIMUM deploy shares (spec ÷ server spec, larger of memory/compute, ceil to %)
memory cap= memMbexactly what you ask for, runtime-enforced; the CPU share is derived from it
/data cap (storage_mb)256 MB default · 0 = no /dataaudit sweep kills on breach
open ports≤ 8 entries · labels 1–199998080/8091 reserved · labels < 1024 always remapped
GPU/VRAM share0–100% of one card0 = CPU-only · VRAM+compute in proportion · floored at the app's minimum · × $6/hr
CPU/RAM share1–100% of the nodeRAM+vCPU in proportion (sets the memory cap) · floored at the app's minimum · ≤ GPU share for GPU apps · × $3/hr
readiness window~20 sfirst http:/tcp: port must accept, or the deploy fails
body writes≤ 4096 bytes per callchunk larger payloads (ch. 07)
UDP bridge idle120 sper WebSocket flow

17Troubleshooting

symptomcause → fix
"a core wasm module, not a wasi:http component"You built a plain module. Build with cargo component (or a toolchain that emits components); the upload gateway and the enclave both check the component layer field.
403 not_approved on deployThat version is still Pending (or was Rejected); the catalog owner approves each CID before it can run. Check the status badge on your app card in Apps.
"firewall: bound unassigned port(s) … app killed"The app hardcoded a port. Read ENCLAVE_PORTS and bind the actual ports (ch. 09).
"did not open port in time"Bind your first http:/tcp: port early in main(); the readiness window is ~20 s.
"storage: /data used … exceeds the cap; app killed"Write less, or publish the next version with a larger storage_mb.
502 no_http at /x/{id}The app declares only tcp:/udp: ports, so there's no HTTP to proxy. Use the bridge or relay paths (ch. 1011).
409 when opening a tcp/udp bridgeThe app hasn't actually bound that port (yet); the bridge refuses until the enclave confirms the bind. Check the deployment logs.
state vanishes between requestsWeb apps get a fresh instance per request. Persist in /data (ch. 08).
runs locally, memory-traps deployedLocal wasmtime is uncapped unless you pass -W max-memory-size. Test with the parity command (ch. 03) at your published memMb (the cap at the minimum CPU share).
"component imports instance wasi:nn/… not found in the linker"The app imports wasi:nn but the deployment bought no GPU share. Redeploy with resources.gpuShare > 0 (ch. 12).
wasi-nn fails: "Failed while accessing backend: …"The suffix carries the actual backend cause (ONNX Runtime, llama.cpp or stable-diffusion.cpp) - read it. Common ones: CUDA can't initialize (target gpu on a CPU-only node or local wasmtime without CUDA libraries - strict on purpose, no silent CPU fallback; use cpu there), an input tensor's name/shape/dtype not matching the model, a model operator the provider doesn't support, a load_by_name() name that doesn't match an attached GGUF or checkpoint volume, or a GGUF prefill batch over the 512-token compute limit (chunk it).
deploy fails: "GPU interface warming up" / "GPU interface unavailable on this node"GPU deployments launch only after the enclave's boot-time CUDA readiness probe passes (so a broken GPU path fails your deploy loudly instead of hanging your app). "Warming up" resolves in a minute or two - retry; "unavailable" carries the probe's diagnosis and is an operator problem, not yours.

Ship checklist

  1. wasmtime serve (or run, for service apps) runs it locally, including with the enclave-parity flags.
  2. No hardcoded ports anywhere; everything comes from ENCLAVE_PORTS.
  3. All writes go under /data, stay under the cap, and survive being wiped.
  4. Publish on the Apps tab → wait for the version's Approved badge → deploy with Use in Deploy.
  5. Verify your own deployment's attestation, then hand the same check to your users.