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.
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.
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):
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:
Connect your wallet (header button) and hit + Publish app.
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.
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).
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.
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.
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:
reference
meaning
my-hello:1.0.0
A 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
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"),
}
}
}
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.
entry
meaning
reached at
http:N
your app serves HTTP on port N itself (max one entry)
/x/{id}/…, the same URL as a web app
tcp:N
a raw TCP listener
/x/{id}/tcp/N bridge · /x/{id}/tls/N + public relay (ch. 10)
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 & 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:
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-targetgpu 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):
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.
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:
field
meaning
name
the mount: your app sees /enc/<name>
endpoint · bucket · path
where the ciphertext lives (any S3-compatible endpoint; public hosts only)
provider · region
S3 dialect hints, when the endpoint needs them
filenameEncryption · directoryNameEncryption
how names were packed (defaults: standard, on - must match the push)
maxMb
plaintext cap (default 1024); like /data, it is RAM-backed, and a volume that outgrows it gets the app killed by the audit sweep
readOnly
drop the S3 credentials right after the pull; disables push-back
unlock · keyId
UI hints: lead with wallet or password; keyId is the stable label the wallet signs, so renaming a volume doesn't change its key
credsEnvelope
S3 credentials, sealed under the owner's wallet key - safe to publish (below)
encrypted
default 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:
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
capability
web app (default)
service app (declared ports)
inbound HTTP
your handler, fresh instance per request
http:N: you bind and serve it yourself
sockets
none
bind 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
environment
empty
ENCLAVE_PORTS only
memory
linear memory capped at the deployment's cpuShare × node RAM, enforced by the runtime
host / exec / SSH
none: structurally absent, not merely denied
Numbers to know
limit
value
notes
wasm artifact
≤ 2 GB
enforced at upload and again at fetch
app specs (vramMb/gpuGflops/memMb/cpuGflops)
exact MB + GFLOPS, per version
set the MINIMUM deploy shares (spec ÷ server spec, larger of memory/compute, ceil to %)
memory cap
= memMb
exactly what you ask for, runtime-enforced; the CPU share is derived from it
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 deploy
That 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. 10–11).
409 when opening a tcp/udp bridge
The app hasn't actually bound that port (yet); the bridge refuses until the enclave confirms the bind. Check the deployment logs.
state vanishes between requests
Web apps get a fresh instance per request. Persist in /data (ch. 08).
runs locally, memory-traps deployed
Local 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
wasmtime serve (or run, for service apps) runs it locally, including with the enclave-parity flags.
No hardcoded ports anywhere; everything comes from ENCLAVE_PORTS.
All writes go under /data, stay under the cap, and survive being wiped.
Publish on the Apps tab → wait for the version's Approved badge → deploy with Use in Deploy.
Verify your own deployment's attestation, then hand the same check to your users.
The enclave CLI
One executable file, wallet-native - your wallet is your account. Point it at a key and every operation on this page becomes a one-liner; state lives in ~/.config/enclave/ and nothing else touches your machine.
Install
Three routes, same result: the enclave command on your PATH. Each needs node ≥ 20; pick the one that fits.
install · Linux / macOS
curl -fsSL https://get.enclave.host | sh
install · Windows (PowerShell)
irm https://get.enclave.host/install.ps1 | iex
install · from a clone, via npm (any OS)
git clone https://github.com/EnclaveHost/enclave
cd enclave/cli
npm i
npm i -g .
Authenticate
No signup step: the CLI authenticates with whatever key you point it at. Generate a fresh one or import the wallet you already use; commands that spend need USDC on Base under that address.
bring a wallet
# generate fresh, or import the key you already use
enclave key new # prints the address; fund it with USDC on Base
enclave key import # reads a private key from stdin; or ENCLAVE_KEY env
enclave whoami # address, USDC balance, running deployments
Auth is SIWE under the hood: commands that need the API sign a one-time challenge (GET /auth/nonce → POST /auth/login) and cache the bearer token until it expires. On-chain commands (create, fund, publish) sign Base transactions with the same key - the CLI never sends your key anywhere.
create + fund + wait until live, one shot; prints the app URL. Shares are fractions of one card / one node; omitted, they default to the app's catalog minimums
EnclaveDeployments.create() · fundWithAuthorization · POST /claim-hint
enclave ls
your deployments, state and funded time remaining
GET /deployments · GET /account
enclave status <id>
one deployment: state, runner, lease, balance, URL
GET /deployments/{id}
enclave logs <id> [-f]
your app's stdout/stderr; -f follows
GET /deployments/{id}/logs
enclave fund <id> --usdc 5 | --eth 0.002
top up runtime by the second
fundWithAuthorization / fundEth
enclave attest <id>
fetch the CPU + GPU attestation reports and verify them locally; prints measurements and the verdict
GET /deployments/{id}/attestation
enclave restart <id>
stop + relaunch the app in place: same version, endpoint and balance (app state is ephemeral) - the fix for a wedged instance, no wallet tx
POST /deployments/{id}/restart
enclave stop <id>
take the work item off the queue, then terminate; unspent balance stays on the record for a later reactivation (payments are final - nothing is withdrawable)
switch a deployment to another approved version of its app (default: the latest); paid time carries over - the runner restarts the app in place on the new version
EnclaveDeployments.setAppRef() · POST /claim-hint
enclave publish app.wasm --slug hello
validate the component, pin to IPFS, cut a catalog version; add --fee $/hr to charge deployers an hourly fee, paid straight to your wallet (capped on-chain, immutable per version)
ipfs.enclave.host/add-wasm · EnclaveAppCatalog
enclave apps [query]
browse or search the on-chain catalog
EnclaveAppCatalog reads
enclave encvol message <keyId>
print the canonical message a wallet signs for a volume key (ch. 13)
local only - nothing sent anywhere
enclave encvol derive --key-id K | --sig 0x…
an encrypted volume's password + salt: the CLI wallet signs the canonical message itself (deterministic, RFC 6979), or derive from a signature made in any other wallet
local only - nothing sent anywhere
enclave encvol seal-creds --key-id K
encrypt S3 credentials (AWS_* env, or --access-key/--secret-key) under the wallet key → a credsEnvelope value for the public App Config; the app then unlocks with one signature, nothing typed
local only - nothing sent anywhere
enclave pricing · availability · gpu
rates + live ETH quote · fleet headroom · GPU probe state
GET /pricing · /availability · /gpu
enclave account
address, payment forwarder, totals
GET /account
Global flags: --json for machine-readable output, -x to print every REST call and transaction before it is sent (the curl-equivalent escape hatch), --base / ENCLAVE_API_BASE to target an enclave directly instead of the gateway, --rpc for your own Base RPC.
The whole lifecycle, in five lines
terminal
enclave publish target/wasm32-wasip2/release/hello.wasm --slug hello
enclave deploy hello:1 --fund 2 # create, fund $2, wait for live; shares = app minimums
enclave attest 0x3f2a9c1b # verify before you send data (any unique id prefix)
curl https://3f2a9c1b.app.enclave.host/ # → hello from Enclave
enclave logs 0x3f2a9c1b -f # watch it serve
Everything the console does, nothing it hides: any command run with -x shows the exact API traffic, ready to replay with curl.
The MCP server
Coding agents connect over the Model Context Protocol at https://mcp.enclave.host (Streamable HTTP). It exposes the full platform surface as typed tools: the catalog, pricing, live capacity, deployments, logs, attestation, sign-in, and uploads. An agent holding a funded key can browse the catalog, deploy, fund, watch logs, publish, and verify attestation end to end.
Connect
One URL, no signup, no API key. Pick your client:
connect · Claude Code
claude mcp add --transport http enclave https://mcp.enclave.host
Once connected, the guide tool (topic getting-started) walks the agent through everything below on its own.
No keys, by design
No tool accepts a private key, signs anything, or submits a transaction. Reads are direct; every state change comes back as an unsigned Base transaction{chainId, to, data, value}, validated against live chain state first (catalog approval, share minimums, the GPU cap, publisher fee snapshots), for the agent to sign with your own wallet. Sign-in and uploads take locally produced signatures. Session tokens for owner-scoped reads ride per-call parameters or the Authorization header; transactions never need a session.
attestation documents; real verification stays client-side against the pinned repo
auth_nonce · auth_login
SIWE sign-in: fetch the message, sign it locally, trade the signature for a session token
upload_token
wallet-signed authorization for pinning wasm and images to IPFS
plan_deploy
validate + price a deployment, returns the unsigned create() transaction
build_fund
unsigned funding transactions: USDC approve + fund, or payable fundEth
build_stop · build_resume · build_upgrade
unsigned lifecycle transactions; upgrade validates approval, share fit, and the fee snapshot
build_publish
unsigned publishVersion transaction from a pinned CID
claim_hint
nudge the fleet to claim funded work right now
A typical deploy, agent-side
list_apps → plan_deploy → sign and send the create transaction → read the new id from the receipt's Created event → build_fund → sign and send → claim_hint → poll get_deployment until running. The app serves at its own origin, https://<first-8-hex-of-id>.app.enclave.host.
The CLI and MCP are equivalent surfaces: anything an agent plans over MCP can also be run with enclave one-liners, and both talk to the same API and contracts underneath.
The Enclave API
Everything the service does, fully scoped. Authenticate with an Ethereum signature, pay USDC or ETH per deployment, and orchestrate confidential compute over plain HTTPS. Prefer a terminal? The enclave CLI wraps every operation below.