---
slug: 72-wasm
title: WebAssembly
description: Synsema como WebAssembly — el artefacto wasip1 para jobs confidenciales, TEEs y wasmtime, y el artefacto embebible para navegador, Node/Bun, Python, Go y runtimes edge, donde tu app le presta al programa http/kv/llm bajo el mismo modelo de capabilities deny-by-default.
example_ids: []
---

# WebAssembly

Desde v0.6.0 el intérprete también se distribuye como **WebAssembly**. El mismo
lenguaje, los mismos `.syn`, las mismas capabilities deny-by-default — lo que cambia es
*quién ejecuta el programa*.

## ¿Cuál necesito?

- **Para correr Synsema en una máquina o un servidor** — `synsema run`, `serve`, `test`,
  `init` — querés el **binario nativo**, no wasm: `curl -fsSL https://synsema.org/install.sh | sh`,
  o `npm i -g synsema` (el mismo binario, distribuido por npm). Ver [Quickstart](00-quickstart).
- **Para correr programas Synsema *dentro de tu propia app* escrita en JavaScript/TypeScript,
  Python o Go** — en el navegador, en Node, en un runtime edge — querés el **artefacto
  embebible**: el paquete npm `@synsema/wasm` (o el mismo `.wasm` con el glue de Python/Go).
  Tu app le entrega al intérprete un programa como texto y recibe su salida; tu app también
  decide qué puede tocar el programa (red, almacenamiento, un LLM). [Más abajo](#el-artefacto-embebible-agentes-dentro-de-apps-hechas-en-otros-lenguajes).
- **Para correr un `.syn` bajo un host WASI** — wasmtime, un runner de jobs en TEE, un
  coprocesador confidencial — querés el **artefacto wasip1**: un `.wasm` de línea de comandos
  que se comporta como `synsema run` sin instalar nada en el host. [Sección siguiente](#el-artefacto-wasip1-jobs-confidenciales-tees).

Los dos artefactos van adjuntos a cada release (`synsema-wasm-wasip1.wasm`,
`synsema-wasm-web.wasm`, cada uno con su `.sha256`); el embebible también está en npm.
Nadie instala Synsema en el host: el `.wasm` **es** la unidad desplegable, como lo es una
imagen de contenedor. Probalo sin instalar nada: el [playground](/play) del sitio corre el
intérprete en tu navegador.

## El artefacto wasip1: jobs confidenciales, TEEs

Bajá `synsema-wasm-wasip1.wasm` del [release](https://github.com/kitecosmic/synsema/releases/latest)
(verificá el `.sha256`) y corrélo con cualquier host WASI:

```sh
# correr un programa (wasmtime)
wasmtime run --dir . synsema-wasm.wasm programa.syn

# correr los bloques `test` de un archivo
wasmtime run --dir . synsema-wasm.wasm --test programa.syn

# leer el programa de stdin; config/secretos por env
wasmtime run --env ETH_KEY=... synsema-wasm.wasm -   < programa.syn

# techo del host — las MISMAS flags y el mismo parser que `synsema run`
wasmtime run --dir . synsema-wasm.wasm --sandbox programa.syn                   # techo = [stdout, time]
wasmtime run --dir . synsema-wasm.wasm --cap-set stdout,secret=ETH_* programa.syn
```

`synsema-wasm [--test] [--sandbox | --cap-set <lista>] [--version] <archivo.syn | ->`. El
techo del host (`--sandbox` ≡ `[stdout, time]`; `--cap-set` = `nombre` o `nombre=scope`,
separados por coma) es la misma defensa en profundidad que en `synsema run`: un `require`
por encima se deniega, auto-grants incluidos. Una **`--flag` desconocida es error (exit 2),
nunca se toma en silencio como la ruta del programa**. Códigos de salida: `0` ok, `1`
error de runtime / test fallido, `2` uso o programa ilegible.

¿Sin wasmtime a mano? Node trae WASI: `node examples/embed/node/run-wasip1.mjs
synsema-wasm.wasm programa.syn` corre el mismo artefacto (Node sigue marcando `node:wasi`
como experimental; corre el binario entero).

Un job en TEE (un coprocesador confidencial que corre WASM dentro de un enclave y
registra el resultado onchain) es entrada → cómputo puro → salida verificable. Eso es este
artefacto: leer la entrada, computar, hashear/firmar el resultado con la clave sellada
como `secret` bajo `require sign`, imprimir la salida. El manifiesto de capabilities es a
la vez la historia de auditoría.

```synsema
require secret("ETH_KEY")

let resumen be {"suma": sum([120, 180, 95])}
let cuerpo be json_encode(resumen)
let digest be decode(keccak256(cuerpo), "hex")
let addr be eth_address(secret("ETH_KEY"))
print(`resumen={cuerpo}`)
print(`keccak={digest}`)
print(`addr={addr}`)
```

## El artefacto embebible: agentes dentro de apps hechas en otros lenguajes

En JavaScript/TypeScript es una dependencia npm común y corriente — `package.json`,
bundler, tipos incluidos (`index.d.ts`); nada raro:

```sh
npm i @synsema/wasm
```

```js
import { Synsema } from "@synsema/wasm";

const syn = await Synsema.load(new URL("@synsema/wasm/synsema.wasm", import.meta.url));
await syn.ready();

const r = syn.run(`print(keccak256("hola"))`, { env: { KEY: "abc" }, ceiling: "sandbox" });
r.output;   // ["…"]  las líneas de print del programa
r.errors;   // []     los errores de parse/runtime son datos, nunca excepciones
r.audit;    // cada chequeo de capability, concedido o no — campos abajo
```

Cada entrada de `audit` es `{capability, granted, source, reason, origin}`. `reason` dice **por qué**:
una denegación es `No matching grant found` (el programa nunca lo declaró), `above host ceiling
(--sandbox/--cap-set)` (declarado, pero por encima de lo que presta el host) o `Explicitly denied by …`;
un grant es `Granted by <grant>`, uno ambiente que otorgó el runtime es `auto-granted by the runtime`, y
una lectura desde un bundle de `synsema build` es `bundled asset (part of the program)`. `origin` dice
**quién** puso la entrada: `"program"` (un `require` del programa o una llamada que hizo) o `"runtime"`
(un grant ambiente — `stdout`/`time`/`llm` en una corrida no-segura, `serve` desde `--port`). Los grants
ambiente que **tienen éxito** ahora también dejan rastro (`source: "ambient"`, `granted: true`), así el
audit muestra exactamente qué otorgó el runtime, no solo lo que rechazó. Así "este tenant quiso leer
`STRIPE_KEY`" es `origin == "program" && !granted`, sin re-parsear el fuente. (El `synsema run --audit
json` nativo streamea las mismas entradas más `ts`, `context` — qué CapabilitySet, p. ej.
`program`/`agent`/`request` — y `file`/`line`; y `run_program` agrega `above parent ceiling` / `above
parent profile` por lo que un hijo pidió más allá de su padre — ver [Observabilidad](63-observability).)
La misma distinción llega al texto del error: una llamada por encima del techo dice *declared but above
the host ceiling — the program cannot fix this; the host must widen the ceiling*, mientras que un
`require` faltante sigue diciendo qué línea agregar.

**Mover valor en el browser/edge.** `sign`, `spend`, `wallet` y `reveal` son fail-loud: sin línea de
audit, no hay operación. En nativo esa línea va a `~/.synsema/audit/<op>.log`; un host wasm no tiene
filesystem, así que la línea va al **`kv`** del host bajo el namespace `audit` (clave = `sign.log`,
`spend.log`, `wallet.log`, `reveal.log`, texto append-only, jamás material de clave). Ofrecé `kv` y esas
operaciones funcionan exactamente como en nativo — bajo el techo que vos fijás (`sign=ETH_KEY`,
`spend=USD`, `wallet=mnemonic*`); sin `kv` se niegan con *this host provides no audit sink*. Una
recursión desbocada es un error de runtime (`maximum recursion depth exceeded`), nunca un trap que
descarte la instancia.

`run` → `{ok, output, errors, audit, llm_tokens}`; `test` → `{passed, failed, lines}`;
`check` → `{ok, errors}` (parse + la regla de declaración de memoria — a lo sumo un `require memory`, con nombre válido; **no** detecta un `remember` sin `require memory`: eso se deniega en `run`, en `errors`/`audit`, igual que el `synsema check` nativo); `handle` (abajo);
`version`. `env` reemplaza al `.env`: `secret("KEY")`/`env("KEY")` resuelven de ahí.
`ceiling` acepta la sintaxis de `--cap-set` (`"stdout,secret=ETH_*"`) o `"sandbox"`.

Vite, Next, Bun y Node resuelven `new URL("@synsema/wasm/synsema.wasm", import.meta.url)`
(los bundlers copian el `.wasm` al build; Node lo lee del disco). Una página HTML suelta
sin bundler puede importar el paquete desde un CDN ESM como `https://esm.sh/@synsema/wasm`
— sirve para una demo, no para un proyecto (fijá versiones, no dependas de un CDN de
terceros en runtime).

El programa es el mismo `.syn` que correrías nativo — llega como **texto**
(`syn.run(fuente)`), sus líneas de `print` vuelven en `output`, y no corre ningún proceso:
sin stdout, sin archivos. El mismo `.wasm` funciona desde **Python** (`examples/embed/python`,
wasmtime-py) y **Go** (`examples/embed/go`, wazero, sin CGO): exporta una entrada
(`synsema_call`, JSON entra / JSON sale) e importa tres funciones del host, así que
cualquier runtime que cargue WebAssembly lo maneja con ~80 líneas de glue.

### Lo que tu app presta: `http`, `kv`, `llm`

El programa conserva su manifiesto; **tu app decide qué recibe de verdad**. Nada de lo que
el host presta se alcanza sin el `require` del programa, y el `ceiling` del embebedor
deniega por encima de lo que el host presta. Cada chequeo queda en `audit`.

```js
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)),
  },
  llm: (op, prompt) => ({ content: "…", tokens: 12 }),   // acá va tu SDK
  log: (line) => console.log(line),
};

syn.run(`require memory("agenda")\nremember("preference", "modo oscuro", ["ui"])`, { host, filename: "agenda.syn" });
syn.run(`require memory("agenda")\nprint(recall(search="oscuro")[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 "el clima")\nprint(llm_usage())`, { host });
syn.run(`require llm\nprint(reason about "x")`, { host, ceiling: "stdout" });   // denegado: el techo manda
```

- **`http(req)`** → `{status, headers, body}` o `{error}` — respalda `fetch`/`http_*` y el
  read-side RPC de blockchain (`eth_balance`, `solana_*`, `algorand_*`, `btc_*`). Se llama
  **después** del gate `net(host)`, con la misma canonización de URL que el binario nativo.
  `req` es `{method, url, headers, body, timeout}` (`body_base64` si es binario).
- **`kv.get / set / delete / list(ns, key)`** — respalda la memoria persistente del agente
  (`remember`/`recall`/`forget_memory`, reglas, progress) y `state_*`. La memoria vive bajo
  el namespace `memory:<nombre declarado>` (**el nombre declarado es la identidad**, como en
  el `.db` nativo); `state_*` bajo `state`. `memory_summary()` reporta `Backend: host-kv`;
  `recall` busca por substring/tags, como el store en memoria nativo.
- **`llm(op, prompt)`** → `{content, tokens}` — respalda `reason`/`decide`/`analyze`/`generate`;
  `llm_available()` pasa a true y `llm_usage()` suma los tokens que reportás.
- **`log(line)`** — los avisos del runtime (`bare require reveal …`); en un navegador no hay stderr.
- **`sleep(secs)`** (o `true` para la pausa por defecto) — `sleep()` y los polls de
  confirmación RPC. Un Worker bloquea con `Atomics.wait`; el main thread de un navegador no puede.

Sin el hook, el builtin falla con la verdad: `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`; las ops LLM caen a los placeholders offline del core.

### Hosts asíncronos (`fetch` del navegador, IndexedDB, SDKs de LLM)

El intérprete es síncrono. `runAsync`/`testAsync`/`handleAsync` lo corren en un Worker y
bloquean con `Atomics.wait` sobre un `SharedArrayBuffer` mientras tus Promises resuelven
en el main thread — las respuestas más grandes que el buffer viajan en chunks. Node/Bun/
Deno funcionan sin más; el navegador necesita cross-origin isolation
(`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp`).
Sin eso, usá la API síncrona con hooks síncronos.

```js
const r = await syn.runAsync(programa, {
  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();
```

### `serve` sin sockets: modo handler para edge

Cloudflare Workers, Fastly Compute, Fermyon Spin, Vercel Edge cargan un `.wasm` y llaman
un handler por request. `handle(source, request)` es ese handler: tu plataforma le pasa el
request y recibe la respuesta.

```js
const app = `require serve(8080)
serve on 8080
    auth with check_token
    errors with shape_error
    route "GET /hola/:nombre"
        give {"hola": params.nombre, "visitas": state_incr("visitas")}
    route "POST /items" requires auth
        give created({"por": 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: "/hola/ana?x=1", headers: { accept: "application/json" }, body: "" }, { host });
res.status; res.content_type; res.headers; res.body;   // res.log = las líneas de print del handler
```

El programa se prepara una vez por instancia (parse, top-level, tabla de rutas) y se reusa
entre requests — sólo cambia el request. Rutas por especificidad, `:param`/`*resto`,
`query`, `request` (`json`, `form`, `cookies`, `user`), `auth with` (token, o token +
request), `errors with`, 404/405 con `Allow`, `expect` → 400, `content()` negociado por
sufijo o `Accept`, `redirect`, `with_header`/`set_cookie`, paginación de colecciones y
`state_*` durable a través de tu `kv` funcionan como en el server nativo — incluidas las dos
reglas que lo mantienen como el mismo lenguaje: `require serve(puerto)` sigue siendo obligatorio
(el manifiesto, no el socket), y cada request corre sobre un **snapshot de los globales** (un
`set` sobre un global dentro de un handler no persiste al request siguiente; el estado
compartido va por `state_*`). **No están en
modo handler** (la plataforma los hace antes de llamarte): `stream` (SSE) y `proxy to`
responden 501, rate limits y `static` se ignoran (el mount deja un aviso en el log), los
bloques `host` (vhosts) y el `mount` de grupos de rutas exportados se rechazan con un error
claro, y TLS/ACME terminan en el host.

### Tiempo, azar, archivos, tamaño

`now()` sale del reloj del host (`Date.now()`, `time.time()`, `time.Now()`); `random()`,
`token()`, `mnemonic_generate` y cada nonce de firma salen de la entropía del host
(`crypto.getRandomValues`, `os.urandom`, `crypto/rand`) — la doctrina criptográfica no
cambia. No hay filesystem: `read_file` y compañía fallan diciéndolo. El artefacto pesa
~7,5 MB (2,6 MB gzip); CI impone un presupuesto de 5 MB gzip. Un error del programa es
dato (`errors[]`); un trap (un panic del intérprete) descarta la instancia y el glue la
recrea.

## El mismo lenguaje, un entorno que otorga menos

El perfil wasm **no es un dialecto**. Es el lenguaje completo en un entorno que otorga
sólo lo que el host presta — exactamente lo que el modelo deny-by-default ya expresa.
Incluido en los dos artefactos, byte a byte idéntico al binario nativo (CI diffea las
sondas bajo wasmtime y a través de la API de embebido bajo Node en cada push): el
lenguaje completo, tasks, tipos, `match`, `try/recover`, enums, módulos, templates; la
torre numérica y arrays; texto, regex, JSON, CSV, estadística; charts y export PNG/PDF;
hashing, HMAC, `secret`; todo el lado puro de blockchain (`eth_address`, ABI, EIP-191/712,
`tx_eip1559`, encoding Solana/Algorand, builder/PSBT de Bitcoin, `*_sign` gateados, HD
wallets); el lado puro de web-auth (password hashing, JWT, TOTP, `oidc_verify` con JWKS
inline); `sandbox`, `intent`, scoping de capabilities por tool, el techo del host; los
helpers de respuesta y el vocabulario `content()`; multi-agente (`agent`/`spawn`/`share`/
`observe`/`signal`/`wait_for`) in-process; `parallel_map`/`chunk` secuencial (mismo orden
y semántica fail-fast, sin pool de threads).

También en los dos: `args()` (vacío en el navegador; el argv después del archivo en wasip1) y —
nuevo en v0.6.14 — este mismo **perfil pure** es un switch también en el binario **nativo** (`synsema
run --profile pure`), así podés correr bajo el muro sin wasm en absoluto. Ver
[Sandbox § el perfil pure](22-sandbox).

**En ninguno de los dos artefactos** — los nombres existen y fallan con la verdad del entorno, nunca
con `Undefined variable`. El mensaje es *`<name>: not available in the pure profile — <why> (<hint>)`*,
idéntico a `synsema run --profile pure` (la pista pure nativa dice *drop --profile pure to run it
natively* en vez de *run the program with the native `synsema` binary*):

| Familia | Builtins | `<why>` |
|---|---|---|
| Filesystem, exec | `read_file`/`write_file`/`append_file`/`edit_file`/`list_dir`/`file_info`/`file_exists`/`grep`, `run` | `this run has no filesystem` / `… no child processes` (build de navegador; wasip1 tiene archivos vía `--dir`) |
| WebSocket, identidad TLS, hub, entrega de Web Push | `ws_*`, `mtls_identity`, `push_send` (v0.6.15+ — `push_vapid_keys` es puro y queda), `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` |
| Bases de datos | `db_open`/`db_close`, `sql`/`sql_exec`/`sql_batch`/`sql_tables`/`paged`, `mongo_*`, `redis_*` | `this run has no database drivers` (en edge, llegá a D1/Neon/Upstash por `http`) |
| Cron | `cron_*` | `this run has no scheduler threads` (el host agenda; el job se invoca) |
| Proceso (solo wasm) | `self_path`, `run_program` | `this run has no process` / `… no child processes` |
| Threads reales (solo wasm) | `spawn`, `parallel_map`, `bus_*`, `agents` | conservan su semántica, corren in-process / secuencial |

(`term_open` es la excepción: devuelve `nothing`, como una corrida nativa sin TTY, así el fallback a
`read_line` es el mismo programa. Bajo `--profile pure` nativo las familias de threads/procesos se
mantienen — un proceso nativo tiene threads — así que solo filesystem/exec/sockets/db/cron quedan
amuralladas, y `fetch`/`http_*` con `net` siguen llegando a la red.)

## Compilar los artefactos desde el código

Sólo hace falta para trabajar sobre el motor — los usuarios toman el `.wasm` del release o de npm.

```sh
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` tiene una feature `native` (activa por defecto) que gatea los módulos que
hablan con el SO; sin ella el perfil puro compila a wasm32. El CI del motor chequea ese
perfil, compila los dos artefactos, diffea las sondas puras contra el binario nativo y
maneja el embebible desde Node, Python y Go en cada push.
