Synsemadocsv0.6.xENES

Operate

WebAssembly

Since v0.6.0 the interpreter also ships as WebAssembly. Same language, same .syn files, same deny-by-default capabilities — what changes is who runs the program.

Which one do I need?§

you want the native binary, not wasm: curl -fsSL https://synsema.com/install.sh | sh, or npm i -g synsema (the same binary, shipped through npm). See Quickstart.

or Go — in the browser, in Node, in an edge runtime — you want the embeddable artifact**: the npm package @synsema/wasm (or the same .wasm with the Python/Go glue). Your app hands the interpreter a program as text and gets its output back; your app also decides what the program may touch (network, storage, an LLM). Below.

coprocessor — you want the wasip1 artifact: a command-line .wasm that behaves like synsema run without installing anything on the host. Next section.

Both artifacts are attached to every release (synsema-wasm-wasip1.wasm, synsema-wasm-web.wasm, each with a .sha256); the embeddable one is also on npm. Nobody installs Synsema on the host: the .wasm is the deployable unit, the way a container image is. Try it without installing anything: the docs site's playground runs the interpreter in your browser.

The wasip1 artifact: confidential jobs, TEEs§

Download synsema-wasm-wasip1.wasm from the release (verify the .sha256) and run it with any WASI host:

# run a program (wasmtime)
wasmtime run --dir . synsema-wasm.wasm program.syn

# run a file's `test` blocks
wasmtime run --dir . synsema-wasm.wasm --test program.syn

# read the program from stdin; pass config/secrets as env
wasmtime run --env ETH_KEY=... synsema-wasm.wasm -   < program.syn

# host ceiling — the SAME flags and parser as `synsema run`
wasmtime run --dir . synsema-wasm.wasm --sandbox program.syn                    # ceiling = [stdout, time]
wasmtime run --dir . synsema-wasm.wasm --cap-set stdout,secret=ETH_* program.syn

synsema-wasm [--test] [--sandbox | --cap-set <list>] [--version] <file.syn | ->. The host ceiling (--sandbox ≡ [stdout, time]; --cap-set = name or name=scope, comma-separated) is the same defense-in-depth as in synsema run: a require above it is denied, auto-grants included. An unknown --flag is an error (exit 2), never silently taken as the program path. Exit codes: 0 ok, 1 runtime error / failing test, 2 usage or unreadable program.

No wasmtime at hand? Node ships WASI: node examples/embed/node/run-wasip1.mjs synsema-wasm.wasm program.syn runs the same artifact (Node still flags node:wasi as experimental; it runs the whole binary).

A TEE job (a confidential coprocessor that runs WASM inside an enclave and records the result onchain) is input → pure compute → verifiable output. That is this artifact: read the input, compute, hash/sign the result with the key sealed as a secret under require sign, print the output. The capability manifest doubles as the audit story.

require secret("ETH_KEY")

let summary be {"total": sum([120, 180, 95])}
let body be json_encode(summary)
let digest be decode(keccak256(body), "hex")
let addr be evm_address(secret("ETH_KEY"))
print(`summary={body}`)
print(`keccak={digest}`)
print(`addr={addr}`)

The embeddable artifact: agents inside apps written in other languages§

In JavaScript/TypeScript it is an ordinary npm dependency — package.json, bundler, types included (index.d.ts); nothing unusual:

npm i @synsema/wasm
import { Synsema } from "@synsema/wasm";

// Node (a script, Next.js routes and Server Actions): the synsema.wasm inside the package
const syn = await Synsema.loadNode();
// Browser: serve synsema.wasm with your static files and load it by URL
//   const syn = await Synsema.load("/synsema.wasm");
await syn.ready();

const r = syn.run(`print(keccak256("hola"))`, { env: { KEY: "abc" }, ceiling: "sandbox" });
r.output;   // ["…"]  the program's print lines
r.errors;   // []     parse/runtime errors are data, never exceptions
r.audit;    // every capability check, granted or not — see the fields below

Each audit entry is {capability, granted, source, reason, origin}. reason says why — a denial is No matching grant found (the program never declared it), above host ceiling (--sandbox/--cap-set) (declared, but above what the host lends) or Explicitly denied by …; a grant is Granted by <grant>, an ambient one the runtime provided is auto-granted by the runtime, and a read from a synsema build bundle is bundled asset (part of the program). origin says who put the entry there: "program" (a require of the program or a call it made) or "runtime" (an ambient grant — stdout/time/llm in a non-secure run, serve from --port). Ambient grants that succeed now leave a trace too (source: "ambient", granted: true), so the audit shows exactly what the runtime handed out, not only what it rejected. So "this tenant tried to read STRIPE_KEY" is origin == "program" && !granted, with no need to re-parse the source. (The native synsema run --audit json streams the same entries plus ts, context — which CapabilitySet, e.g. program/agent/request — and file/line; and run_program adds above parent ceiling / above parent profile for what a child asked beyond its parent — see Observability.) The same distinction reaches the error text: a call over the ceiling says declared but above the host ceiling — the program cannot fix this; the host must widen the ceiling, while a missing require still says which line to add.

Moving value in the browser/edge. sign, spend, wallet and reveal are fail-loud: no audit line, no operation. Natively that line goes to ~/.synsema/audit/<op>.log; a wasm host has no filesystem, so the line goes to the host's kv under the audit namespace (key = sign.log, spend.log, wallet.log, reveal.log, append-only text, never key material). Offer kv and those operations work exactly as natively — under the ceiling you set (sign=ETH_KEY, spend=USD, wallet=mnemonic); offer no kv and they refuse with this host provides no audit sink*. The engine reads the log before it appends the line (v0.6.46): if that read fails, the operation is refused and the log is left as it was. A runaway recursion is a runtime error (maximum recursion depth exceeded), never a trap that discards the instance.

run → {ok, output, errors, audit, llm_tokens, steps, exit} (steps = statements executed, v0.6.20+; exit = the code of exit(code), otherwise 0 or 1; with labels also declassify and private_seen, and with result the result field — see Untrusted code); test → {passed, failed, lines}; check → {ok, errors} (parse + the memory-declaration rule — at most one require memory, with a valid name; it does not flag a remember without require memory: that is denied at run, in errors/audit, exactly like native synsema check); handle (below); version. env replaces the .env: secret("KEY")/env("KEY") resolve from it. ceiling takes the same syntax as --cap-set ("stdout,secret=ETH_*") or "sandbox".

Loading the module. In Node, Synsema.loadNode() reads the synsema.wasm that ships in the package (next to index.mjs, or from node_modules/@synsema/wasm when a bundler moved the code, as Next.js does in routes and Server Actions); if it finds neither, it throws an error that lists the paths it tried. Everywhere else, Synsema.load(src) takes the module in one of these forms: a WebAssembly.Module (for platforms that hand you one when you import the .wasm), its bytes (Uint8Array/ArrayBuffer), a Response, or a URL to fetch. In the browser, copy node_modules/@synsema/wasm/synsema.wasm to the folder your site serves as static files and call Synsema.load("/synsema.wasm"). Under Node, a string that does not start with http:/https: is a file path, and a file: URL is read from disk too. Do not write new URL("@synsema/wasm/synsema.wasm", import.meta.url): it resolves relative to the current module, not to the package. A single HTML page with no bundler can import the package from an ESM CDN such as https://esm.sh/@synsema/wasm — fine for a demo, not for a project (pin your versions, don't depend on a third-party CDN at runtime).

The program is the same .syn you would run natively — it arrives as text (syn.run(source)), its print lines come back in output, and nothing runs as a process: no stdout, no files. The same .wasm works from Python (examples/embed/python, wasmtime-py) and Go (examples/embed/go, wazero, no CGO): it exports one entry (synsema_call, JSON in / JSON out) and imports three host functions, so any runtime that loads WebAssembly can drive it with ~80 lines of glue.

What your app lends: http, kv, llm§

The program keeps its manifest; your app decides what it actually gets. Nothing the host lends is reachable without the program's require, and the embedder's ceiling denies above what the host lends. Every check lands in audit.

const store = new Map();
const host = {
  http: (req) => ({ status: 200, headers: [["content-type", "application/json"]], body: "{}" }),
  kv: {
    get: (ns, k) => store.get(ns + "/" + k) ?? null,
    set: (ns, k, v) => store.set(ns + "/" + k, v),
    delete: (ns, k) => store.delete(ns + "/" + k),
    list: (ns) => [...store.keys()].filter((x) => x.startsWith(ns + "/")).map((x) => x.slice(ns.length + 1)),
    // compare-and-swap: write v only if the current value is exactly `expected` (null = missing)
    cas: (ns, k, expected, v) => {
      if ((store.get(ns + "/" + k) ?? null) !== expected) return false;
      store.set(ns + "/" + k, v);
      return true;
    },
  },
  llm: (op, prompt, info) => ({ content: "…", tokens: 12 }),   // plug your SDK here; info.principals (v0.6.46)
  log: (line) => console.log(line),
};

syn.run(`require memory("agenda")\nremember("preference", "dark mode", ["ui"])`, { host, filename: "agenda.syn" });
syn.run(`require memory("agenda")\nprint(recall(search="dark")[0]["content"])`, { host, filename: "agenda.syn" });
syn.run(`require net("api.example")\nprint(fetch("https://api.example/ping")["status"])`, { host });
syn.run(`require llm\nprint(reason about "the weather")\nprint(llm_usage())`, { host });
syn.run(`require llm\nprint(reason about "x")`, { host, ceiling: "stdout" });   // denied: the ceiling wins

blockchain read-side RPC (evm_balance, solana_, algorand_, btc_*). Called after the net(host) gate, with the same URL canonicalization as the native binary. req is {method, url, headers, body, timeout} (body_base64 when binary) and, since v0.6.46, redirect: "error" when the request carries a secret. In wasm the engine checks the host name, not the address: keeping a program off private addresses is the hook's job — see The http hook.

(remember/recall/forget_memory, rules, progress) and state_. Memory lives under the namespace memory:<declared name> (the declared name is the identity, as in the native .db); state_ under state. memory_summary() reports Backend: host-kv; recall searches by substring/tags, as the in-memory store does natively. get returns the stored text or null, list an array of key names; since v0.6.46 anything else, or a hook that throws, is an error of the builtin, not "the key is not there" — see What a hook must answer.

only if the stored text is exactly expected (null = the key does not exist) and return true or false. Values are the JSON text the engine stores. It makes state_merge and state_cas atomic even when several instances share your store; your store must do the compare and the write as one step (a transaction, a conditional put). A cas that returns anything but a boolean (a missing return is undefined) fails the call with kv.cas must return true or false, got ….

reason/decide/analyze/generate; llm_available() becomes true and llm_usage() sums the tokens you report. info.principals (v0.6.46) lists the owners of the private data the prompt carries — [] with public data; private data only gets here when the ceiling allows it (llm=<principal>@embedder, below). A hook that throws makes that call fail with its message (v0.6.46; before, an empty answer).

polls. A Worker blocks with Atomics.wait; a browser main thread cannot.

Without a hook, the builtin fails with the truth: fetch: … this host provides no http transport (wasm profile) — the embedder can offer one through the http host hook, or run the program with the native synsema binary; memory "agenda" is declared but this host provides no durable storage; LLM ops fall back to the core's offline placeholders.

state_merge and state_cas in wasm. They exist here too (state_merge(key, map) merges the map into the stored one and returns the result; state_cas(key, expected, new) writes only if the current value equals expected, a missing key being nothing, and returns whether it wrote). How atomic they are depends on what you lend:

retries the compare-and-swap when another writer got in first);

state_merge: the host's kv does not offer cas (kv.cas), so this update would not be atomic across instances; offer kv.cas, or use state_set if one writer is enough;

call at a time). It lasts across the handle requests of that instance; each run starts with an empty state.

What a hook must answer (engine v0.6.46+)§

Until v0.6.45 a kv that failed, or that answered something unexpected, read as "the key is not there", and the next write replaced what your store held. Since v0.6.46 each of these is an error of the builtin that asked, with the reason, and nothing is written over your data:

What your hook doesWhat the program gets
kv.get throws (or its Promise rejects, in the async API)state_get: host kv.get failed: kv down — the hook's message; a thrown value without one gives its text (… failed: 42)
kv.get returns a number, a boolean or an object (a value already parsed)state_get: host kv.get failed: kv.get must return a string or null, got a number (a boolean, an object)
the stored text is not JSON (cut half-way, written by something else)state_incr: the stored value of 'c' is not JSON (expected value at line 1 column 1); the same in state_get, state_all, state_merge and state_cas, and the stored text stays as it was (before, state_incr over it gave 1)
kv.list throwsstate_all: host kv.list failed: <the message>
kv.list returns another shape: Cloudflare KV's {keys: [{name}]}, a list of {name}, a list with something that is not text, undefinedstate_all: host kv.list failed: kv.list must return a list of strings, got an object (got a list with other values, got nothing); before, state_all() gave {}
the load of a declared memory fails, or the stored memory or progress is not readablethe memory is not opened: every memory builtin of that run fails with memory "agenda" could not be loaded (the host's kv.get failed: …); nothing was loaded, so nothing is written over it (or (the stored memory is not readable (…))). Before, it opened empty and the first remember replaced it
reading the audit log of reveal, sign, wallet or spend failsthe operation is refused and the log stays as it was: reveal("K") failed: cannot write the audit log (reading the audit log failed: …). Refusing to reveal without an audit trail. Before, the log was replaced by one line and the operation went on
llm throwsthat call fails with the hook's message (reason: provider 503); a throw without a message gives reason: the host's llm hook threw. When the prompt carried private data the error is redacted like any error it causes (private(a)). Before, the program got an empty answer with ok: true

What did not change: kv.get returning null or undefined is "the key is not there"; a kv without list gives state_all() an empty map (offer list if a program reads the whole state); an llm hook that returns nothing gives an empty answer; and an http hook that throws or answers {error} gives the program the map {error: <message>}, which it reads like any transport error.

Migrating — breaking for a host whose kv.get returns numbers or objects, or whose kv.list returns another shape. Store the text the engine hands to set and give back that same text (it is JSON: do not parse it, do not store it as an object), and make list return the key names as an array of strings. A store whose list answers {keys: [{name}]}, as Cloudflare KV does, needs one map (async hooks: use runAsync/handleAsync):

// `store` answers get(key) → text | null, put(key, text), delete(key), list({prefix}) → {keys: [{name}]}
const kv = {
  get: (ns, k) => store.get(`${ns}/${k}`),
  set: (ns, k, v) => store.put(`${ns}/${k}`, v),
  delete: (ns, k) => store.delete(`${ns}/${k}`),
  list: async (ns) => (await store.list({ prefix: `${ns}/` })).keys.map((x) => x.name.slice(ns.length + 1)),
};

Embedders in Rust (HostProvider in synsema-stdlib, the trait the wasm glue implements): kv_get now returns Option<Result<Option<String>, String>> (None = no kv offered, Some(Ok(None)) = the key is not there, Some(Err(msg)) = the read failed) and kv_list Option<Result<Vec<String>, String>>; llm takes a third argument, principals: &[String], and returns Option<Result<HostLlmResult, String>> (Err = the call fails with that text); and HostHttpRequest has forbid_redirects: bool (the redirect: "error" below).

Over the raw ABI (the Python and Go glue, or your own): the host answers kv_get with {"value": <text or null>} or {"error": "<message>"}, and kv_list with {"keys": [<texts>]} or {"error": …} (an answer without keys is now the error got nothing). The llm payload is {op, prompt, principals} and the answer a text, {"content", "tokens"} or {"error": …} (an empty or null error gives the host's llm hook threw). The http payload carries "redirect": "error" when the request has a secret.

The http hook: redirects and private addresses (engine v0.6.46+)§

Natively the engine opens the connection itself. In wasm your hook does, so two protections of the native client only exist if the hook keeps them.

redirect: "error". A request that carries a secret — in the URL, the query, the body, a form or any header — reaches the hook with req.redirect === "error"; one without a secret has no redirect field (a request with a secret does not leave the mark set for the next one). Honor it: a redirect would carry the key to a host the program never named (a cross-origin redirect in fetch drops Authorization, but not X-Api-Key or any other header), and the native runtime does not follow redirects either. With fetch it is one option, and fetch then fails on a 3xx:

async http(req) {
  try {
    const res = await fetch(req.url, { method: req.method, headers: req.headers, body: req.body ?? undefined, redirect: req.redirect ?? "follow" });
    return { status: res.status, headers: res.headers, body: await res.text() };
  } catch (e) {
    return { error: `${e.message}${e.cause ? ": " + e.cause.message : ""}` };   // "fetch failed: unexpected redirect"
  }
}

Private addresses. Natively, since v0.6.46 a net grant with a wildcard (require net, net(""), net(".x.com"), a ceiling net) reaches only public addresses: the client resolves the name once, checks the address and connects to that one. In wasm the engine never sees the address — it checks the name against the grant and hands the URL to your hook, so a program with require net reaches your hook with http://169.254.169.254/… or http://127.0.0.1.nip.io/. If the programs are not yours, the hook has to resolve the name, check the address it is going to connect to against these ranges, and connect to that address (not resolve again):

The hook does not know whether the program named the host (require net("db"), which natively does reach a private address), so if your programs need a private destination, name it in the hook. In Node, lookup on node:http/node:https resolves, checks and connects in one step:

import { lookup } from "node:dns";
import { BlockList, isIP } from "node:net";
import http from "node:http";
import https from "node:https";

// The ranges a wildcard `net` grant must not reach (the list in `Host.http`).
const blocked = new BlockList();
for (const [a, bits] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16],
  ["172.16.0.0", 12], ["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
  ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]]) blocked.addSubnet(a, bits, "ipv4");
// NAT64 (64:ff9b::/96) is blocked whole; to let public ones through, check its last 32 bits as an IPv4.
for (const [a, bits] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8], ["2001:db8::", 32], ["64:ff9b::", 96]])
  blocked.addSubnet(a, bits, "ipv6");
// Private destinations your own programs need: the hook cannot see `require net("db")`, so name them here.
const allowed = new Set(["10.0.0.5"]);
// BlockList checks an IPv4 inside an IPv6 (::ffff:a.b.c.d) against the IPv4 ranges.
const isBlocked = (ip) => !allowed.has(ip) && blocked.check(ip, isIP(ip) === 6 ? "ipv6" : "ipv4");

// Resolve once, check every answer, and connect to the checked address (no second lookup).
function safeLookup(name, opts, cb) {
  lookup(name, { ...opts, all: true }, (err, addrs) => {
    if (err) return cb(err);
    const bad = addrs.find((a) => isBlocked(a.address));
    if (bad) return cb(new Error(`${name} resolved to ${bad.address}, a private address`));
    opts.all ? cb(null, addrs) : cb(null, addrs[0].address, addrs[0].family);
  });
}

export function httpHook(req) {
  return new Promise((resolve) => {
    const u = new URL(req.url);
    const literal = u.hostname.replace(/^\[|\]$/g, "");
    if (isIP(literal) && isBlocked(literal)) return resolve({ error: `${literal} is a private address` });
    const lib = u.protocol === "https:" ? https : http;
    const opts = { method: req.method, headers: Object.fromEntries(req.headers), lookup: safeLookup, timeout: req.timeout * 1000 };
    const r = lib.request(u, opts, (res) => {
      // `redirect: "error"`: the request carries a secret; a 3xx is not followed (node:http never follows).
      if (req.redirect === "error" && res.statusCode >= 300 && res.statusCode < 400) {
        res.resume();
        return resolve({ error: `redirect refused: the request carries a secret (${res.statusCode})` });
      }
      const chunks = [];
      res.on("data", (c) => chunks.push(c));
      res.on("end", () => resolve({ status: res.statusCode, headers: Object.entries(res.headers).map(([k, v]) => [k, String(v)]), body: Buffer.concat(chunks).toString("utf8") }));
    });
    r.on("timeout", () => r.destroy(new Error("timeout")));
    r.on("error", (e) => resolve({ error: e.message }));
    const body = req.body_base64 != null ? Buffer.from(req.body_base64, "base64") : req.body;
    if (body != null) r.write(body);
    r.end();
  });
}

With it, syn.runAsync(program, { host: { http: httpHook } }) gives a program with require net 200 for https://example.com/, and {error} for the rest: 127.0.0.1.nip.io resolved to 127.0.0.1, a private address, 169.254.169.254 is a private address, ::ffff:a00:6 is a private address ([::ffff:10.0.0.6] as the URL parser writes it). What the hook answers is what the program reads in r["error"]. In a browser the hook cannot see the address (fetch resolves the name), so a page relies on the network around it; honor redirect there too.

Async hosts (browser fetch, IndexedDB, LLM SDKs)§

The interpreter is synchronous. runAsync/testAsync/handleAsync run it in a Worker and block on Atomics.wait over a SharedArrayBuffer while your Promises resolve on the main thread — responses larger than the buffer travel in chunks. Node/Bun/Deno work out of the box; browsers need cross-origin isolation (Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp). Without it, use the sync API with sync hooks.

const r = await syn.runAsync(program, {
  host: { async http(req) { const res = await fetch(req.url, { method: req.method }); return { status: res.status, headers: res.headers, body: await res.text() }; } },
});
await syn.close();

The async calls take the same options as run (env, ceiling, host, labels, result, input, maxSteps, maxMemoryMb, trusted, trustedCeiling) plus timeoutMs, which only the async API has. They wait in one queue and each goes to the first free Worker; Synsema.load(src, { workers: n }) (or loadNode({ workers: n })) gives the instance up to n Workers (default 1), so a burst of calls runs n at a time. close() stops the Workers and is final: what is waiting in the queue fails with synsema: closed (close() is final; load a new instance), and so does any later async call; the sync API keeps working. A Worker that does not start within startMs (an option of load/loadNode, default 30 000 ms, between 1 and 2147483647) is stopped.

Untrusted code: labels, limits, concurrency§

Code a model wrote is the usual reason to embed Synsema in a server. These options make that safe to run there:

const syn = await Synsema.loadNode({ workers: 4 });

const r = await syn.runAsync(code, {
  host,
  labels: true,                                                 // information-flow labels on
  input: { var: "customer", value: row, sources: { card: ["customer"] } },
  result: "out",                                                // r.result = the final value of `out`
  timeoutMs: 2000,                                              // async only
  maxSteps: 5_000_000,                                          // both modes
  maxMemoryMb: 64,                                              // both modes
});

Labels. labels: true turns on the information-flow labels for that call (what synsema run --labels does natively). input binds a global variable before the run: var is its name, value any JSON value, and each key of the value listed in sources reaches the program as private(value, principals) (here customer.card is private to customer, customer.name is public). sources needs labels: true; without it the call fails with input: sources need labels: true …. A private value, or a branch decided by one, is refused at every hook of the host, exactly as the native binary refuses it at its sinks:

hook.

The call ends with ok: false and label_violation: fetch(argument 0) is private to customer, the sink accepts (public); declassify(…) … before the hook runs, so your hook never sees the value. print redacts a private value (private(customer)) instead of failing. Three more things change with labels:

took depended on the data), and private_seen is true;

there were would tell which way the branch went);

label_violation, as natively.

result: "out" returns the final value of the global out in r.result, with its labels: a private part comes back as {"$private": ["customer"], "value": …}, so your code decides what to do with it. declassify lists each declassify the run made. result also works without labels (then it is plain JSON).

Limits. maxSteps (interpreter steps, the deterministic ones steps() counts) and maxMemoryMb (the bytes the run reserves) work in both the sync and the async API, and in handle they apply to each request. Past either one the run ends with ok: false and an error that names the limit — step limit reached: the program ran more than 10000 steps (maxSteps), memory limit reached: the run reserved more than maxMemoryMb (in handle, a 500) — never a trap. Both must be positive integers: 0 is an error (max_steps must be a positive integer), not "no limit". No call inherits the limits of the previous one: set them on every call. maxMemoryMb counts bytes requested from the start of the run, not what the instance already held; the module's own memory can grow to about twice that and is not given back. The cost in the wasm build: counting allocations is ~3–4 % on allocation-heavy code without limits; with limits on, 1–3 % when allocating and 9–14 % on a tight compute loop. The native binary compiles none of this. With labels: true, a run that hits maxMemoryMb reports steps: null and private_seen: true (v0.6.46) whatever it touched: whether it ran out of memory can depend on a private value, so treat that result as sensitive, like any run with private_seen. The cut run returns no output and an empty audit.

Deadline (async only). timeoutMs counts from when the call starts running, not while it waits in the queue. Past it, the Promise rejects with an error that says the call did not finish in that many ms, and its Worker is replaced, so the server's thread never blocks. A host hook already running (a fetch) is not cut; its result is discarded. 0 means no deadline; otherwise a finite number of milliseconds up to 2147483647 (Infinity is an error). A host hook with no deadline of its own that never answers keeps its Worker busy unless the call has a timeoutMs.

Failures. A Worker that dies fails the call it was running (it is not retried: it may have had effects) and is replaced. A host hook that returns something that is not JSON (a BigInt, a circular object) or throws a value without a message counts as that hook failing in that call (for http, fetch returns its {error} map), in both APIs — not a hung Worker or a trapped module. handle keeps a separate prepared app per labels setting and per set of host hooks, so a request with other hooks or labels never reuses an app prepared for different ones.

Ceilings for labelled runs, trusted tools, sealed receipts (engine v0.6.46+)§

declassify under a ceiling. With labels: true and a ceiling, declassify is a capability the ceiling bounds: only the principals it names can be published.

const code = `let c be private({"name": "Ana", "card": "4111"}, "customers")
print(declassify(c.name, "the name is public"))`;
syn.run(code, { labels: true });                                          // no ceiling: allowed, as before
syn.run(code, { labels: true, ceiling: "stdout" });                       // refused: the ceiling names no principal
syn.run(code, { labels: true, ceiling: "stdout,declassify=customers" });  // allowed
syn.run(code, { labels: true, ceiling: "stdout,declassify=cust*" });      // allowed: globs work
syn.run(code, { labels: true, ceiling: "sandbox" });                      // refused: "sandbox" never grants it

The refusal is `label_violation: declassify is not granted for what this value is private to (customers). With a ceiling the host names what a program may publish (--cap-set declassify=<principal>), and inside a sandbox only a sandbox under that delegates declassify allows it; it names the declared label, try/recover cannot catch it, and the run ends with ok: false. A bare declassify in the ceiling allows any principal. Without labels it is not checked (declassify is the identity), and without a ceiling nothing changes. Breaking: a host that runs labelled code under a ceiling and expects it to publish has to add declassify=<principal> to that ceiling. In the program, require declassify("customers") names what it publishes, and a bare require declassify logs (through your log hook) warning: bare require declassify permits publishing data private to ANY principal; scope it with require declassify("principal") (the principal the call removes)`.

Private data to your llm hook: llm=<principal>@embedder. With labels, a private value in reason/analyze/decide/generate reaches the llm hook only when the program declares whose data it sends and the ceiling allows that principal for the embedder:

const seen = [];
const host = { llm: (op, prompt, info) => { seen.push(info.principals); return "an answer"; } };
const code = `require llm("customers")
let note be private("the customer's note", "customers")
print(label_of(reason note))`;
syn.run(code, { host, labels: true, ceiling: "stdout,llm=customers@embedder" });  // ok: prints [private(customers)]
// seen → [["customers"]]: info.principals = the owners of the private data in the prompt ([] for public data)

llm=customers@* allows any provider, on purpose. llm=customers@local (a model inside a native process), an item without a provider (llm=customers, llm), no ceiling at all, or a bare require llm in the program, all refuse it before the hook runs: `label_violation: reason would send data private to (customers) to the model; the host allows that per principal and provider in its ceiling (--cap-set llm=<principal>@<provider host>, @local for a model in the process, @embedder in wasm) and the program declares require llm("<principal>"). …. When it is allowed, the prompt carries the data itself, the answer comes back private with the label of what went in, and audit gets an entry llm("customers@embedder") with source llm-data. Calls with public data keep working under any of these ceilings, with info.principals empty. judge works the same way with require judge("customers") and judge=customers@embedder (refused: label_violation: judge would send data private to (customers) to the model; …`). What this does not give: your provider sees the data (allow only a provider you trust), and what the model answers is private but not trusted.

judge is gated in wasm. A judge block under a ceiling without judge fails like natively: Capability not granted: judge — declared but above the host ceiling (--sandbox/--cap-set). The program cannot fix this; the host must widen the ceiling. Before v0.6.46 it ran anyway.

Trusted tools: trusted and trustedCeiling. Your app can lend a module of its own that code it does not trust can only call. trusted: { name: source } makes it importable as use "trusted:name" as t; each exported task runs with its own require, bounded by trustedCeiling (the grammar of ceiling) when you give one, and nothing from the caller — not its grants, not its ceiling:

const trusted = { tools: `export task first_name(c)
    require declassify("customers")
    give declassify(c.name, "the customer's own name")
` };
const code = `use "trusted:tools" as t
let c be private({"name": "Ana", "card": "4111"}, "customers")
print(t.first_name(c))`;
syn.run(code, { labels: true, ceiling: "stdout", trusted, trustedCeiling: "declassify=customers" });  // prints Ana

The same program calling declassify itself under ceiling: "stdout" is refused. Without trustedCeiling a trusted task declassifies like a program without a ceiling (any principal), so give it one; a trustedCeiling that does not name the principal refuses it with the same label_violation, and one without net refuses its require net(…) (declared but above the host ceiling). Errors: an import with no such module, `no trusted module named 't': the host loads it (synsema run --trusted t=./file.syn, or trusted: { t: source } in wasm); a program cannot load one itself; a task or a lambda among the arguments, trusted:t.f: a trusted tool takes data, not code — an argument holds a task, which would run with the tool's capabilities; pass the values it needs instead; a value that is not text, trusted.t: expected the module source as text; a parse error in the module, trusted:t:1:9: …; a bad trustedCeiling, trusted_ceiling: --cap-set: unknown capability 'nett'. Known: …. A trusted module sees the builtins and its own names, never the program's (a program that defines its own upper does not change what the tool runs), and set t.x to … changes only the program's copy. It works in run, test, handle and their *Async forms. Everything else about trusted tools — what they cannot do yet, the native --trusted` — is on Labels.

Sealed receipts: auditor. The engine takes "auditor": "<the auditor's X25519 public key, base64url>" (from synsema receipt keygen) and optionally "sealed_bytes": n (the envelope size, 4096 to 16777216, default 65536) in a run request, and returns the sealed receipt in receipt, before ok. With an auditor, an audited operation in a private branch (reveal, sign, wallet operations) runs, and its audit line goes into the envelope, not to your kv; spend is still refused there. In @synsema/wasm v0.6.46, run()/runAsync() do not pass auditor on (it is not in RunOptions, and the result has no receipt); until the package does, call the module through the low-level API, whose call takes the engine's request as is:

import { Synsema, instantiateSync, dispatchHostSync, offersOf } from "@synsema/wasm";
const syn = await Synsema.loadNode();
const host = { kv: { get: (ns, k) => null, set: (ns, k, v) => {} } };
const inst = instantiateSync(syn.module, (kind, payload) => dispatchHostSync(host, kind, payload));
const r = inst.call({ op: "run", source: code, filename: "app.syn", labels: true, auditor: AUDITOR_PUBLIC_KEY, host: offersOf(host) });
r.receipt;   // the receipt object: write it as one JSON line for `synsema receipt verify` / `open`

synsema receipt verify receipt.json checks it and synsema receipt open receipt.json --key @auditor.key opens it, natively. A bad key is auditor: the auditor key is not base64url: …, a size out of range sealed_bytes must be between 4096 and 16777216, got 100. Only run returns a receipt (test and handle do not). The receipt itself — its fields, what it hides and what it does not — is on Labels.

serve without sockets: edge handler mode§

Cloudflare Workers, Fastly Compute, Fermyon Spin, Vercel Edge all load a .wasm and call a handler per request. handle(source, request) is that handler: your platform passes the request, gets a response.

const app = `require serve(8080)
serve on 8080
    auth with check_token
    errors with shape_error
    route "GET /hello/:name"
        give {"hi": params.name, "visits": state_incr("visits")}
    route "POST /items" requires auth
        give created({"by": request.user.id, "body": request.json})
    route "GET /doc/:id"
        give content(page([heading(1, "Doc"), prose(params.id)], {"title": "Doc"}))`;

const res = syn.handle(app, { method: "GET", path: "/hello/ana?x=1", headers: { accept: "application/json" }, body: "" }, { host });
res.status; res.content_type; res.headers; res.body;   // res.log = the handler's print lines

The program is prepared once per instance (parse, top-level, route table) and reused across requests — only the request changes. Routes by specificity, :param/rest, query, request (json, form, cookies, user), auth with (token, or token + request), errors with, 404/405 with Allow, expect → 400, content() negotiated by suffix or Accept, redirect, with_header/set_cookie, collection pagination, and state_ durable through your kv all work as in the native server — including the two rules that keep it the same language: require serve(port) is still mandatory (the manifest, not the socket), and every request runs on a snapshot of the globals (a set on a global inside a handler does not persist to the next request; shared state goes through state_*). The lineage is per request too (v0.6.46): receipt() inside a handler lists in credentialSubject.inputs only what that request read — what the top-level read while the app was prepared (an LLM call, a fetch) is no longer in the first request's receipt. Not in handler mode (the platform does them before calling you): stream (SSE) and proxy to answer 501, rate limits and static mounts are ignored (the mount logs a warning), host blocks (vhosts) and mount of exported route groups are rejected with a clear error, and TLS/ACME terminate at the host.

Time, randomness, files, size§

now() comes from the host clock (Date.now(), time.time(), time.Now()). Randomness comes from the host's entropy (crypto.getRandomValues, os.urandom, crypto/rand), asked once per call: each call takes 32 bytes from the host, which seed that run's own ChaCha generator, and random(), random_bytes(n), token() and RSA blinding draw from it without asking the host again — so how much randomness a run used (a private n, say) does not show to the host. With labels, now() returns the clock read at the start of the run and at each interaction with the host (a hook call), so when and how often the program read the clock does not show either: two now() with only computation between them return the same time. The cryptographic doctrine does not change. There is no filesystem: read_file and friends fail saying so. The artifact is ~7.5 MB (2.6 MB gzip), CI enforces a 5 MB gzip budget. A program error is data (errors[]). A trap (an interpreter panic) discards the instance: the sync API throws synsema: the module trapped (…); the instance was recreated and recreates the instance by itself, so the next call works without another await syn.ready() (if the platform does not allow creating it synchronously, the next call asks for ready()); in the async API the Worker recreates its own instance and the call's Promise rejects with the same message.

The same language, an environment that grants less§

The wasm profile is not a dialect. It is the full language in an environment that grants only what the host lends — exactly what the deny-by-default model already expresses. Included in both artifacts, byte-identical to the native binary (CI diffs the probes under wasmtime and through the embed API under Node on every push): the full language, tasks, types, match, try/recover, enums, modules, templates; the numeric tower and arrays; text, regex, JSON, CSV, stats; charts and PNG/PDF export; hashing, HMAC, secret; the whole pure blockchain side (evm_address, ABI, EIP-191/712, evm_tx/evm_tx_create, Solana/Algorand encoding, Bitcoin builder/PSBT, gated *_sign, HD wallets); web-auth pure side (password hashing, JWT, TOTP, oidc_verify with an inline JWKS); sandbox, intent, per-tool capability scoping, the host ceiling; the response helpers and the content() vocabulary; multi-agent (agent/spawn/share/observe/signal/ wait_for) in-process; parallel_map/chunk sequential (same order and fail-fast semantics, no thread pool).

Also in both: args() (empty in the browser; the argv after the file in wasip1) and — new in v0.6.14 — this same pure profile is a switch on the native binary too (synsema run --profile pure), so you can run under the wall without wasm at all. See Sandboxing § the pure profile.

Two differences come with v0.6.29. Parquet (parquet_read, parquet_write) is not in the wasm build. And while keeps its iteration cap there (1,000,000), which the native engine dropped in v0.6.29: a wasm host has no timeout, thread or signal to stop a loop that never ends, so without the cap it would hang.

Not in either artifact — the names exist and fail with the truth of the environment, never with Undefined variable. The message is <name>: not available in the pure profile — <why> (<hint>), identical to synsema run --profile pure (the native pure hint says drop --profile pure to run it natively instead of run the program with the native synsema binary):

FamilyBuiltins<why>
Filesystem, execread_file/write_file/append_file/edit_file/list_dir/file_info/file_exists/grep, runthis run has no filesystem / … no child processes (browser build; wasip1 has files via --dir)
WebSocket, TLS identity, hub, Web Push deliveryws_, mtls_identity, push_send (v0.6.15+ — push_vapid_keys is pure and stays), select/proc_/watch/term_this run has no raw network sockets (WebSocket/TLS identity need an event loop and a process) / … no I/O hub
Databasesdb_open/db_close, sql/sql_exec/sql_batch/sql_tables/paged, mongo_, redis_this run has no database drivers (in edge, reach D1/Neon/Upstash over http)
Croncron_*this run has no scheduler threads (the host schedules; a job is invoked)
Process (wasm only)self_path, run_programthis run has no process / … no child processes
Real threads (wasm only)spawn, parallel_map, bus_*, agentskeep their semantics, run in-process / sequentially

(term_open is the exception: it returns nothing, like a native run without a TTY, so the read_line fallback is the same program. Under native --profile pure the thread/process families stay — a native process has threads — so only filesystem/exec/sockets/db/cron are walled off, and fetch/http_* with net still reach the network.)

Guests — Synsema inside another host's ABI§

Some hosts do not load a WASI command nor provide the synsema_host imports: they instantiate a module once and call their own exports with pointers into its memory. For those, the engine stays as it is and a thin adapter lives in packages/guests/<host>/ — the host's exports mapped onto synsema_call, with your .syn embedded so one .wasm is exactly one app. The first one is Vela (Horizen's confidential coprocessor): packages/guests/vela builds a wasm32-wasip1 module exposing allocate/deallocate/load_module/deploy/deposit/process_request/trusted_request; your program defines deploy(ctx), deposit(ctx), process(ctx) (and optionally deanonymize/trusted/load_module), each taking one map and returning one map — with payload_hex in for ABI payloads from a trigger contract, <field>_hex out for exact bytes, subtypes as 0x… or short labels; fuel is what you declare or the deterministic steps(); the program runs under the ceiling stdout,declassify=app (no time, no randomness; since v0.6.46 declassify is bounded by the ceiling and app is the guest's only principal) because Vela signs the state root. SYNSEMA_VELA_APP=my_app.syn cargo build --profile wasm embeds yours; the release ships synsema-vela-guest.wasm with the example ledger. The whole picture — the contract, three example apps (one wire-compatible with Horizen's novaw wallet), a client in Synsema for the other side of the enclave (register, deploy, encrypted requests, reports, events, facilitator), trigger contracts, ERC-20 and the local starter kit recipe, all verified against the real stack — is on the Vela page. The pattern for the next host is "copy, rename, map the exports".

Building the artifacts from source§

Only needed to hack on the engine itself — users take the .wasm from the release or npm.

rustup target add wasm32-wasip1 wasm32-unknown-unknown
cargo build --manifest-path engine/Cargo.toml -p synsema-wasm --target wasm32-wasip1 --profile wasm
cargo build --manifest-path engine/Cargo.toml -p synsema-wasm-web --target wasm32-unknown-unknown --profile wasm
# engine/target/wasm32-wasip1/wasm/synsema-wasm.wasm (~6.7 MB)
# engine/target/wasm32-unknown-unknown/wasm/synsema_wasm_web.wasm (~7.5 MB, 2.6 MB gzip)

synsema-stdlib has a native feature (on by default) gating the OS-facing modules; without it the pure profile compiles to wasm32. The engine CI checks that profile, builds both artifacts, diffs the pure probes against the native binary, and drives the embeddable one from Node, Python and Go on every push.