Serve Subcommand
The vesl scaffold's src/main.rs is a clap dispatch with two arms — Demo (default) and Serve. The Serve arm boots out.jam, builds an AppState, and hands it to vesl_hull::serve, which mounts an axum::Router on the configured bind address.
cargo +nightly run --release -- serve # http://127.0.0.1:3000, demo signing key
cargo +nightly run --release -- serve --no-auth # loopback dev: skip API-key auth
cargo +nightly run --release -- serve --port 8080 # custom port, still loopback
HULL_API_KEY=mysecret cargo +nightly run --release -- serve --bind-addr 0.0.0.0 # LANFlags
| Flag | Default | Notes |
|---|---|---|
--port <PORT> | 3000 | TCP listen port. |
--bind-addr <ADDR> | 127.0.0.1 | Bind address. --no-auth is refused on any non-loopback bind. |
--no-auth | off | Disable API-key auth. Honored only when --bind-addr is loopback (127.0.0.1, ::1, localhost); otherwise the binary refuses to start. |
All other behavior — settlement mode, signing key, vesl.toml overrides — flows through the same surfaces the Demo arm uses. See Settlement Modes for the mode-selection logic.
Auth Model
The Serve arm checks two configuration surfaces at startup via vesl_hull::check_auth_config_with_bind(no_auth, bind_addr):
- Non-loopback bind +
--no-auth→ refuse to start. The combination would expose an unauthenticated kernel-poke surface to the LAN; the early-exit guard is unconditional. HULL_API_KEYenv var → if set and non-empty, every kernel-side endpoint requiresAuthorization: Bearer <key>. If unset, the server printsWARNING: HULL_API_KEY not set -- API endpoints are unauthenticatedto stderr and starts anyway.
The /health endpoint is always unauthenticated regardless of HULL_API_KEY — it's the readiness probe and must answer for orchestrators that don't carry the bearer token. The endpoint is gated on AppState::kernel_ready: 200 + {"status":"ok"} once the hull finishes booting, 503 + {"status":"booting","stage":"<stage>"} until then. Wire k8s readinessProbe (or any load-balancer health check) to the 200, so traffic stays off the pod during the boot window.
# Loopback dev: skip auth entirely.
cargo +nightly run --release -- serve --no-auth
# LAN / shared dev: require an API key.
HULL_API_KEY=$(openssl rand -hex 32) \
cargo +nightly run --release -- serve --bind-addr 0.0.0.0
# Then from another host:
curl -H "Authorization: Bearer $HULL_API_KEY" \
http://<host>:3000/statusFor production deployments where the seed phrase + bearer key + bind address all matter, lock down the host running the hull — the BIP-39/BIP-44 derivation gives the hull spend authority over any UTXO locked to the resulting pkh (see Dumbnet Walkthrough).
Rate-limit behavior
The hull's middleware stack paces requests rather than rejecting them on burst. The layer is tower's RateLimit with a tower::buffer::Buffer in front:
- Capacity: 200 requests per 60 seconds, shared across every authenticated endpoint.
- Buffer: 256 in-flight requests. Excess in-flight requests are queued, not rejected.
- Overflow: requests beyond the 256 buffer slot return HTTP 429 via the
HandleErrorLayerwrapper. Under the capacity ceiling, requests block until a slot frees instead of failing fast.
This is pacing, not strict rate-limiting. A 300-request burst against /status takes ~110 seconds to drain (~200 served, 100 paced); zero 429s under that load. A 257-deep concurrent burst (one request above the buffer ceiling) is what produces the 429.
Custom routes mounted through serve_with_extra_routes / router_with_extra inherit the same layer (the middleware stack wraps the merged Router — see Composing Custom Routes).
Swap to tower_governor::GovernorLayer if the deployment needs true burst-rejection semantics; the existing pacing layer is wired in crates/vesl-hull/src/api/mod.rs (router_with_extra_inner).
Endpoint Catalog
vesl_hull::router(state) returns an axum::Router mounting six endpoints. The handlers assume the kernel composes settle-graft — a kernel without it will reject the kernel-side pokes those handlers issue.
| Method | Path | Purpose | Auth |
|---|---|---|---|
POST | /commit | Commit key-value fields to a Merkle tree and register the root with the kernel. | HULL_API_KEY |
POST | /settle | Settle a note against the current registered root. Body shape varies by gate — see Catalog Gates / Hull settle routing. | HULL_API_KEY |
POST | /verify | Verify a field's Merkle proof against the registered root. | HULL_API_KEY |
GET | /tx/{tx_id} | Fetch a chain-attested receipt (requires fakenet/dumbnet settlement). | HULL_API_KEY |
GET | /status | Current state snapshot — fields, tree, hull-id, note counter, settlement mode, active gate, composed grafts, per-graft manifest sha256s. | HULL_API_KEY |
GET | /health | Readiness probe. 200 + {"status":"ok"} when the hull is ready; 503 + {"status":"booting","stage":"<stage>"} during boot. Always unauthenticated. | — |
Each endpoint's request/response shape sits in crates/vesl-hull/src/api/handlers/<endpoint>.rs (one file per handler); the shared PokeCrashError → HTTP mapping lives in crates/vesl-hull/src/api/error.rs. The 409 / 4xx mappings for kernel rejections are documented in Effect Catalog → settle-graft.
/status Response Shape
GET /status returns the operational snapshot a hull operator triages against. The full payload after one commit + one settle against a quickstart-shaped kernel:
{
"has_tree": true,
"field_count": 1,
"merkle_root": "5q8m7n4k2x9j6h3y8b1w7p4v2c5d8e1f9z3a6t0r4u7i2o5",
"notes_settled": 1,
"hull_id": 1,
"settlement_mode": "in-process",
"gate": "default-hash",
"grafts": [
"batch-graft", "clock-graft", "counter-graft", "forge-graft",
"guard-graft", "intent-graft", "kv-graft", "log-graft",
"mint-graft", "queue-graft", "rbac-graft", "registry-graft",
"settle-graft", "validate-graft"
],
"manifest_shas": {
"batch-graft": "8f3e2a1c…",
"clock-graft": "7b6d4f8a…",
"counter-graft": "2c9e1b6d…",
"forge-graft": "4d8a3f7c…",
"guard-graft": "9a3c5e2b…",
"intent-graft": "1f4b8d3a…",
"kv-graft": "6e2c1a9d…",
"log-graft": "3b7f4e8c…",
"mint-graft": "5a9d2c7b…",
"queue-graft": "0c4e6f1b…",
"rbac-graft": "8d3a7c2e…",
"registry-graft": "f7b2e4a9…",
"settle-graft": "f1b2c8e4…",
"validate-graft": "3e9d1c5b…"
}
}Field meanings:
has_tree/field_count/merkle_root— current commit-graft tree state.merkle_rootisnulluntil the first/commit; once a tree exists, it's the tip5-formatted root.notes_settled/hull_id— settle-graft progress counters.notes_settledincrements per successful/settle;hull_idis the namespace under which roots were registered.settlement_mode—"in-process","fakenet", or"dumbnet". Set at boot from the--settlementflag.gate— active verify-gate name."default-hash"when no graft declares[graft.gates]; otherwise the selection from the highest-priority graft. Chains render as"A&B".grafts— alphabetically sorted list of every graft composed into the running kernel.manifest_shas— per-graft sha256 of the raw manifest TOML. Same digestnockup graft injectbanners on each block, so a mismatch surfaces drift between the on-disk manifest and the composed kernel.
Verifying a gate swap via /status
/status snapshots the graft manifest dir at hull boot. After swapping a gate, restart the hull and check:
curl -H "Authorization: Bearer $HULL_API_KEY" http://localhost:3000/status | jq '{gate, grafts, manifest_shas}'{
"gate": "manifest-verify",
"grafts": ["mint-graft", "settle-graft"],
"manifest_shas": {
"mint-graft": "9a3c…",
"settle-graft": "f1b2…"
}
}The gate field is "default-hash" when no graft declares [graft.gates]; otherwise it's the selection from the highest-priority graft (in practice, settle-graft), with gate-chain = [...] selections rendered as "A&B". The manifest_shas map mirrors the digest nockup graft inject banners on each block, so any drift between the on-disk manifest and the composed kernel surfaces here.
Hull / kernel drift triage via /status
manifest_shas is the canonical surface for catching out-of-band drift between a running hull's out.jam and the on-disk graft library. The shas it returns are the same per-graft digests nockup graft inject --apply prints on each block — so the diff workflow is one curl, one jq, and one grep:
curl -H "Authorization: Bearer $HULL_API_KEY" http://localhost:3000/status \
| jq '.manifest_shas'
nockup graft inject hoon/app/app.hoon 2>&1 | grep sha256A mismatch points at one of three failure modes: a stale kernel (the source changed but the hull is still booted from an old out.jam), a manifest edit that wasn't re-injected (the inject banner shows the new sha but /status shows the old one), or a hull running an out.jam produced from a different lib tree than the one inject is reading now. Pair this check with vesl-test verify-jam — /status catches hull / kernel drift, verify-jam catches kernel / source drift.
Composing Custom Routes
Pass your routes to vesl_hull::serve_with_extra_routes (or vesl_hull::router_with_extra if you only need the assembled axum::Router). The hull merges them with its stock endpoints before applying the middleware stack, so auth, body limit, and rate limit cover every route uniformly:
use axum::{routing::post, Router};
use vesl_hull::SharedState;
async fn issue_badge(/* ... */) -> impl axum::response::IntoResponse {
// your domain handler
}
pub async fn run(state: SharedState, port: u16, bind: &str) -> anyhow::Result<()> {
let extra: Router<SharedState> = Router::new()
.route("/issue-badge", post(issue_badge));
vesl_hull::serve_with_extra_routes(state, port, bind, extra).await?;
Ok(())
}This is the seam for adding endpoints that drive your domain causes. The mounted Tower middleware stack wraps the merged Router, so your custom routes inherit every layer uniformly:
- API-key auth — bearer-token check against
HULL_API_KEY. The/healthexemption is wired explicitly inside the auth middleware. - Body-size cap (two-layer, 4 MiB) — an upfront
Body::size_hintprecheck rejects any request whose body advertises a known length above the cap (413 Payload Too Large). That covers wire requests with honestContent-Length(axum's H1/H2 parser propagates the header into the body's size_hint) and in-process bodies built fromVec<u8>/Bytes/String. Chunked or unknown-length bodies fall through to tower-http's streamingRequestBodyLimitLayer, which fires the moment the handler polls past the cap. A handler that ignores its body still gets the upfront 413 when the size is known. - Rate limit — 200 req / 60 s with a 256-deep buffer; overflow returns 429 via the
HandleErrorLayerwrapper.
To replace stock endpoints entirely (e.g. a domain-specific /commit shape), fork crates/vesl-hull/src/api/ rather than merging — Router::merge can't override existing route definitions, only add to them. Each stock handler is its own file under api/handlers/, so forking is "copy the one handler you want to replace + wire your version into a custom router."
Worker patterns and throughput
AppState lives behind a single Arc<Mutex<...>> so any custom route that holds the lock across a multi-step kernel poke serializes against every other endpoint. Sustained throughput tops out near 20 ops/s on a held-lock workload; a 250-deep concurrent burst against a worker-style /enqueue route takes ~12.7 seconds to drain.
If your custom route's hot path is "lock, poke kernel, write state, unlock," the mutex is the ceiling. Two shapes that lift it:
- mpsc to a dedicated worker task. The route sends work over an
mpsc::Sender; a single owner task holds the kernel handle and drains the channel. The route returns immediately with a job id, and a follow-up GET surfaces completion. Trades latency for throughput. - Read-mostly fast path. Routes that only need to peek (no kernel poke) can take an
RwLockread guard via a refactoredAppState, leaving the write path on the mutex. Tightly scoped — most hull state mutation goes through the kernel poke, which is single-threaded by construction.
Running multiple instances
Each serve process boots its own copy of out.jam into its own kernel. None of that kernel's state is shared between processes — every instance carries an independent state tree.
The kernel's note-settled set is the replay guard behind /settle: a second settle of an already-settled note_id is rejected by the kernel, which the handler maps to 409. That set lives in one kernel's state. Two instances behind a load balancer hold two independent settled sets — a note settled on instance A is unknown to instance B, so the same /settle request routed to B settles a second time and returns no 409. Registered roots from /commit and any per-graft counters diverge the same way.
In fakenet / dumbnet modes the settlement transaction also lands on-chain, so the chain — not the hull — is the cross-instance record of what settled there. The kernel's in-memory settled guard, and the 409 a client sees, stay per-instance regardless. local mode has no chain backstop at all.
Until hull state is externalized to a shared store, run a single instance, or front a fleet with sticky sessions (load-balancer affinity) so each client reaches the same kernel on every request. A fleet without one of those drops cross-instance replay rejection silently.
See Also
- Hull / Scaffold CLI: Demo and Serve — entry-level overview of the Demo + Serve dispatch.
- Build & Run — kernel compile and Demo-arm run flow.
- Settlement Modes — selecting
local/fakenet/dumbnetfor the booted hull. - Effect Catalog → settle-graft — kernel rejection shapes the
/commitand/settlehandlers map to 4xx. - vesl-nockup README — Serving over HTTP — scaffold-level overview.
crates/vesl-hull/— the lib backing the Serve arm.