Seamless Notes

The Host Is an Explicit Boundary

How Seamless turns host interop into typed contracts instead of leaking Wasm ABI details into application code.

Earlier posts looked at boundaries within Seamless itself: regions contain mutation, expanded code goes through ordinary checking, and failures remain explicit. The next boundary is literal: the one between Seamless code compiled to WebAssembly and the host that gives it access to the outside world.

WebAssembly can calculate, allocate, and manipulate its own memory, but it cannot open a file, write to a terminal, make an HTTP request, start a timer, or update the DOM by itself. Those operations belong to the host: Node, a browser, Wasmtime, or some other embedding environment.

At the raw Wasm layer, a function that looks like this in application code:

read_text : String -> Result(String, Host.FS.Error)

cannot be directly represented at the Wasm boundary.

A Wasm import signature exposes only low-level Wasm types. It has no idea of a Seamless String, struct, union, array, or Result. Those values need an explicit boundary representation. Strings and bytes, for example, are currently copied into linear memory and passed to a raw import as a pointer and length.

Host-owned resources also need stable identity and cleanup, while host providers introduce their own failure modes, including JavaScript exceptions and malformed return values.

A Contract Is a Contract Is a Contract

The standard filesystem module exercises most of the model in a small amount of code. It defines ordinary data for open modes and failures:

module Host.FS

import Kernel.Result

@export
union OpenMode {
  Read
  Replace
  Append
  CreateNew
}

module Error {
  @export
  union Kind {
    NotFound
    PermissionDenied
    AlreadyExists
    InvalidPath
    InvalidUtf8
    IsDirectory
    NotDirectory
    Unsupported
    Other
  }

  @export
  struct {
    kind: Kind
    message: String
  }
}

Then it declares the host-owned file type and the functions supplied by the embedding host:

@export
@resource close
externtype File(r)

@region r
@type (File(r)) -> Unit
extern close

@export
@region r
@type (String, OpenMode) -> Result(File(r), Error)
extern open

@export
@region r
@type (File(r)) -> Result(Array(Byte), Error)
extern read_all

@export
@region r
@type (File(r), Array(Byte)) -> Result(Unit, Error)
extern write_all

extern says that the function is implemented by the host rather than by a Seamless body. The normal @type annotation is still the source of truth for its function type.

externtype introduces an opaque type whose value is owned by the host. Seamless can receive a File(r) from the host and pass it back, but it cannot inspect the host’s file descriptor, object, or internal state.

From that interface definition, the compiler records a host-boundary contract containing:

  • the module and function names that must be provided
  • the ordinary parameter and result types
  • the codecs needed to move those values across the boundary
  • the opaque identity of external types such as File(r)

The backend and runner use the same contract to generate the actual Wasm imports, host adapters, manifest, and provider-facing types.

Providers are trusted components of the embedding environment. Contract violations are not represented as ordinary Seamless failures and may abort execution.

A future direction is a safer interop layer in which host contract violations can be contained and reported instead of crashing the instance. Such a layer would sit above the current trusted-provider boundary for applications that need stronger isolation from embedding code.

Plain Data Stays Plain

OpenMode, Error.Kind, and Error are the same unions and structs used everywhere else in the language.

On the TypeScript side, those values arrive in natural host-facing shapes. The exact details are still subject to further iteration, but currently:

  • Seamless structs become typed JavaScript objects
  • unions get generated constructors and discriminated variants
  • Array(Byte) becomes Uint8Array
  • other supported arrays appear as ReadonlyArray<T> in generated TypeScript interfaces
  • Boolean becomes boolean, while Int32 and Double become number
  • Int64 becomes bigint
  • arbitrary-precision Seamless Int also becomes bigint for providers, but crosses the boundary through an encoded transport representation
  • String, Keyword, and Symbol arrive as strings

Lifetimes Do Not Stop at the Boundary

The r in externtype File(r) connects interop to Seamless’s region system.

Opening a file acquires a resource owned by the current region. The @resource close annotation tells the compiler which function releases it. The file cannot escape the region because its type mentions the region identity.

That makes a high-level helper possible entirely in library code:

@export
@type (String) -> Result(Array(Byte), Error)
def read_bytes(path) {
  region {
    case open(path, OpenMode.Read) {
      Ok(file) -> read_all(file)
      Err(error) -> Result.Err(error)
    }
  }
}

The same idea applies to timers and asynchronous operations.

Talk Is Cheap, Show Me the Code

The relevant part of the generated Host.FS provider interface is roughly:

export default interface Provider<File extends object = object> {
  close(file: File): void;
  open(path: string, mode: OpenMode): Result<File, Error>;
  read_all(file: File): Result<Uint8Array, Error>;
  write_all(file: File, bytes: Uint8Array): Result<void, Error>;
}

Because the toolchain generates the JavaScript and TypeScript types and constructors that mirror the boundary interface, a provider implementation can look like this:

import { openSync /* etc */ } from "node:fs";
import type { OpenMode } from "../generated/Std/Host/FS/OpenMode.js";

export type File = { readonly fd: number };

const createNodeFile = (fd: number): File => ({ fd });

const openFlags = (mode: OpenMode): "r" | "w" | "a" | "wx" => {
  switch (mode.variant) {
    case "Read":
      return "r";
    case "Replace":
      return "w";
    case "Append":
      return "a";
    case "CreateNew":
      return "wx";
  }
};

export function open(path: string, openMode: OpenMode) {
  try {
    return Result.Ok(createNodeFile(openSync(path, openFlags(openMode))));
  } catch (error) {
    return Result.Err(fsError(error));
  }
}

// etc

The provider function is not itself the Wasm import. The generated manifest describes how the runner connects the raw import to the provider-facing function:

{
  "module": "Std.Host.FS",
  "sourceName": "Std.Host.FS.open",
  "providerName": "open",
  "importName": "open",
  "params": ["string", { "tag": "union", "name": "Std.Host.FS.OpenMode" }],
  "result": { "tag": "union", "name": "Kernel.Result" }
}

At instantiation, the runner uses that contract to build an adapter roughly like this (the generated helper imports are omitted):

imports["Std.Host.FS"].open = (pathOffset, pathLength, modeHandle) =>
  results.capture(() =>
    provider.open(decodeUtf8(memory, pathOffset, pathLength), params.take(modeHandle))
  );

Wasm therefore passes a UTF-8 address and length plus a temporary handle, while the provider receives a string and an OpenMode. In the other direction, the adapter validates and captures the returned Result; generated imports let Wasm read its variant and payload, including the opaque host-owned File reference, and reconstruct the Seamless value.

If validation fails, the adapter aborts the Wasm call with a host contract error rather than turning it into an ordinary Result.Err. The embedder should treat that instance as poisoned and clean up its remaining host resources.

It’s Wasmtime

The boundary API is not a direct wrapper around any specific platform.

A Node runner can satisfy Host.FS with node:fs. A Wasmtime runner can satisfy the same contract with Rust and WASI. A browser target can omit Host.FS when the application does not require it, provide a virtual filesystem when it does, or expose a different set of host modules entirely.

Standalone core builds make that dependency concrete. They emit the Wasm module and a versioned manifest describing required host contracts and codecs.

A proof-of-concept Wasmtime runner already generates corresponding Rust definitions from the manifest and successfully runs the same in-language test suite as the Node runner.

The generated Rust provider trait mirrors the TypeScript interface used by the Node runner:

pub trait Provider {
  type File;

  fn close(&mut self, file: Self::File);
  fn open(&mut self, path: String, open_mode: OpenMode) -> std::result::Result<Self::File, Error>;
  fn read_all(&mut self, file: Self::File) -> std::result::Result<Vec<u8>, Error>;
  fn write_all(&mut self, file: Self::File, bytes: Vec<u8>) -> std::result::Result<(), Error>;
}

A provider implementation can then use ordinary Rust APIs:

impl std_host_fs::Provider for FsProvider {
    type File = FsFile;

    fn open(
        &mut self,
        path: String,
        open_mode: std_host_fs::OpenMode,
    ) -> std::result::Result<Self::File, std_host_fs::Error> {
        let mut options = OpenOptions::new();
        match open_mode {
            std_host_fs::OpenMode::Read => {
                options.read(true);
            }
            std_host_fs::OpenMode::Replace => {
                options.write(true).create(true).truncate(true);
            }
            std_host_fs::OpenMode::Append => {
                options.append(true).create(true);
            }
            std_host_fs::OpenMode::CreateNew => {
                options.write(true).create_new(true);
            }
        }
        options.open(path).map(FsFile::new).map_err(io_error)
    }

    // etc.
}

std_host_fs::Error and std_host_fs::OpenMode are generated from the boundary contract, just like the corresponding TypeScript definitions used by the Node runner:

#[derive(Clone, Debug, PartialEq)]
pub enum OpenMode {
  Read,
  Replace,
  Append,
  CreateNew,
}

#[derive(Clone, Debug, PartialEq)]
pub struct Error {
  pub kind: ErrorKind,
  pub message: String,
}

Where possible, Seamless types map directly to their Rust counterparts: Result maps to std::result::Result, for example, while Maybe maps to Option.

Conclusion

For a Wasm-first application language, a well-defined host boundary is part of the architecture.

Seamless source declares what a host operation means. Providers implement that contract in the environment they understand. The compiler and runner mediate and validate the awkward ABI between them.

That keeps pointer arithmetic, temporary handles, Wasm imports, and host-language representations out of application code while preserving the things that should remain visible: types, failure, ownership, and lifetime.

We cannot make the outside world pure, total, or representation-compatible by declaration.

What we can do is make the boundary explicit. The host is still a different world. But it doesn’t have to be an untyped one.