# Steel: an embeddable Scheme interpreter in Rust for scripting Rust programs

> Steel is a bytecode Scheme dialect with a standalone REPL and a Rust embedding API. It fits projects that want a Lisp scripting layer inside a Rust binary; it is not a general-purpose language runtime and its pre-1.0 API can still move.

**mattwparas/steel** — An embedded scheme interpreter in Rust

- Repository: https://github.com/mattwparas/steel
- Stars: 2,599 · Forks: 140
- Language: Rust
- License: Apache-2.0
- Published: 2026-09-28 · Updated: 2026-09-28 · Language: en
- Canonical page: https://hysenlabs.com/projects/mattwparas-steel

## What Steel solves, and who it is aimed at

Rust binaries have no convenient way to let users change behaviour without a rebuild. Steel is aimed at that gap. The README describes it as "an embeddable scheme interpreter, with a standalone interpreter/REPL included as well", and it is written as a bytecode virtual machine rather than a tree-walking evaluator. So the intended user is a Rust developer who wants a scripting layer: game logic, configuration that needs conditionals, plugin hooks, or a rules language that non-Rust authors can edit.

Two design choices follow from that audience. The first is modules, using `require` and `provide`, which the README compares to Racket. The second is "easy integration with native Rust functions and structs, either through embedding or via FFI". Both matter more for an embedded language than raw evaluation speed. A scripting layer is only useful if the host can hand values across the boundary and get values back without a serialization step.

The repository layout supports the same reading. There is a `crates/` directory holding the engine, the REPL and the derive macros separately from the CLI at the top level, and an `examples/` directory whose file names are all about host integration: `register_functions.rs`, `register_types.rs`, `register_async_function.rs`, `interior_mutability.rs`. If you are looking for a standalone Scheme to write programs in, Steel will work, but the repository's centre of gravity is the embedding case.

## Bytecode VM, contracts and immutable collections

The README says the language is "implemented as a bytecode virtual machine". That is the whole architecture statement it gives, so treat any deeper claim about compilation stages as unverified. What the README does document is the feature surface: R5RS support, `syntax-rules` and `syntax-case` macros, higher order contracts, and built-in immutable lists, vectors, hashmaps and hashsets.

Contracts are the least common feature in this list. In Racket, a contract is a runtime check attached to a function boundary, and Steel borrows that idea, including the "higher order" case where a contract wraps a function that itself takes or returns functions. For an embedder this is a real mechanism: it gives you a way to assert that a script supplied the right shape of callback before the Rust side calls into it. The README does not show the contract syntax, so you will need the book for that.

The macro story is split. `syntax-rules` gives you the pattern-based hygienic macros from R5RS and R7RS, while `syntax-case` is the procedural form. The README is explicit about the gap: Steel is "mostly compliant with R5RS, only missing `let-syntax` support", and R7RS support is "underway". `let-syntax` is the local macro binding form, so code that scopes a macro to one body will not run as written. That is the kind of missing form that surfaces late, when a Scheme file you ported from elsewhere fails on one special form rather than on the general language.

The `cogs/` directory is where the standard library lives, and the Dockerfile installs it by running `cargo run -- install.scm` from inside that directory. The standard library is therefore not baked into the interpreter binary; it is installed alongside it and resolved at runtime.

## Installing Steel and getting a REPL running

The README's entry point is the GitHub repository, which contains the CLI interpreter. There is also an online playground at the project's GitHub Pages site if you want to try the language before installing anything. For a local REPL, the README says to make sure Rust is installed first, then clone the repo and run:

```bash
cargo run
```

The README states this launches a REPL instance. Because `default-run = "steel"` is set in Cargo.toml, `cargo run` resolves to the `steel` binary rather than to a workspace member.

If you want the full toolchain rather than just the interpreter, the README gives a single xtask command:

```bash
cargo xtask install
```

According to the README, that installs the `steel` interpreter, the `forge` package manager, the `cargo-steel-lib` dylib installer, the Steel language server, and the standard library under `cogs`.

Package location is controlled by one environment variable. The README states that Steel follows XDG when present and otherwise assumes `$HOME/.steel` if `STEEL_HOME` is not already set. The Dockerfile shows the same variable used to redirect the whole install tree:

```dockerfile
ENV STEEL_HOME="/lib/steel"
```

On Nix, the README gives a Home Manager snippet adding `pkgs.steel` to `home.packages`. That is the whole install surface the README documents; it does not cover Windows, and it does not describe how `forge` resolves or pins package versions.

## Embedding Steel in a Rust binary: what the API does not promise

The README carries a warning directly above the feature list: "The API is relatively stable, however it may change at any time while pre 1.0. Care will be taken to keep things backwards compatible where possible." Read that as a statement about intent, not a guarantee. The workspace version in Cargo.toml is 0.8.3, and the two most recent releases listed are v0.8.1 and v0.8.2 from February 2026, so the project is still in the 0.x range where a minor bump can carry a breaking change.

A second constraint is feature selection. The top-level Cargo.toml pulls `steel-core` with a fixed feature list: `dylibs`, `markdown`, `stacker`, `sync`, `rooted-instructions`, `imbl`, `jit2` and `biased`. The comment in that file notes that the workspace feature does not propagate to crates depending on the workspace, and that testing with `sync` requires adding the flag directly. So if your host is multi-threaded and you want the `sync` path, you are editing feature flags in your own crate rather than inheriting them. That is a build configuration detail, but it is the kind of detail that decides whether an embedding attempt survives its first afternoon.

The examples directory is the practical starting point. `examples/register_functions.rs` and `examples/register_types.rs` are named for exactly the two operations an embedder needs first, and `examples/register_async_function.rs` covers the async case. The README does not document these APIs in prose, so the examples are effectively the specification.

## Where Steel is the wrong tool

Steel will not serve as a general-purpose R7RS runtime today. The README states R7RS support is underway and that `let-syntax` is missing from the R5RS surface. If your scripts come from an existing Scheme codebase, that single missing form can block a whole file, and there is no compatibility shim mentioned.

There is also no sandboxing in the documented surface. The README lists modules, macros, contracts and immutable collections, and the Dockerfile sets `STEEL_HOME`; nothing in either describes memory limits, execution timeouts or a restricted module set for untrusted code. If your plan is to accept scripts from users and run them in-process, the documentation gives you no mechanism to bound what those scripts do. That is not a gap you can patch from the outside once the interpreter shares your address space.

Finally, the API stability warning cuts both ways. A pre-1.0 embedding API is a reasonable bet if you vendor a pinned version and upgrade deliberately. It is a poor bet if you expect to track the master branch and have your host code keep compiling. The README's phrasing, "care will be taken to keep things backwards compatible where possible", leaves the exceptions undefined.

## Steel against a general-purpose embedded Lisp

The closest comparison in the README itself is Racket, and it is a comparison of features rather than of runtime. Steel's modules use `require` and `provide` "much like Racket", and its macro system covers `syntax-rules` and `syntax-case`. The difference is what you are embedding. Racket is a full language distribution with its own runtime, and using it from Rust means crossing a process or FFI boundary. Steel is a Rust crate whose whole purpose is to live inside your binary, so native Rust functions and structs are exposed directly through embedding rather than through a foreign interface.

That is the real trade. You give up a mature, complete Scheme implementation with a large package ecosystem, and you get a scripting layer that shares your process, your types and your build. The `forge` package manager exists, and `cogs` holds the standard library, but the README does not describe how large that ecosystem is or how packages are versioned, so do not assume Racket-scale libraries are available.

The other axis is the engine. A bytecode VM with a `jit2` feature in the workspace dependency list suggests attention to execution cost, but the README makes no performance claim, and none should be inferred from the feature name alone. If evaluation speed is your deciding factor, measure it against your own workload rather than against the feature list.

## Maintenance, licensing and upgrade cost

The repository is not archived and the last push was on 2026-09-24, which is recent. The release line is slower than the commit line: v0.8.2 landed on 2026-02-22 and v0.8.1 on 2026-02-12, with the workspace version now at 0.8.3. So commits continue between releases, and a user tracking releases will lag the master branch by months.

Licensing is dual. The README says Steel is licensed under either Apache License 2.0 or the MIT license, at your option, and the repository carries `LICENSE-APACHE` and `LICENSE-MIT`. The Cargo.toml `license` field reads `MIT OR Apache-2.0`, which is the same arrangement. The README also states that contributions are dual licensed the same way unless stated otherwise. If you need a single-licence answer for a compliance review, the choice is yours to make; this is a description of what the files say, not legal advice.

Upgrade cost is the API warning plus the feature flags. Because the workspace pins `steel-core` with an explicit feature list, a dependency bump can change which engine features you compile against, and the Cargo.toml comment already flags that workspace features do not propagate. Budget for reading the diff on each minor version rather than assuming a patch release is inert.

## Conclusion

Adopt Steel if you are writing a Rust application and want user-editable Scheme scripts, macros and immutable data structures running inside the same process, and you accept that the README warns the API may change at any time while pre 1.0. Do not adopt it as a drop-in R7RS runtime, as a `let-syntax` implementation, or as a way to run untrusted scripts, because the README documents no sandboxing or resource limits. Before committing, check two things yourself: whether `cargo run` gives you a REPL on your toolchain, and whether the pinned `steel-core` feature set in Cargo.toml matches the features your application needs. The repository's last push was on 2026-09-24, so the code is current, but the release line is v0.8.2 from 2026-02-22 and the README still carries the pre-1.0 warning.

## FAQ

### What is the Steel interpreter written in?

Steel is implemented in Rust and described in the README as a bytecode virtual machine. The repository separates the engine, REPL and derive macros into crates under `crates/`, with the CLI at the top level.

### How do I install Steel?

The README says to install Rust, clone the repository and run `cargo run` for a REPL, or `cargo xtask install` to install the interpreter, the `forge` package manager, the `cargo-steel-lib` dylib installer, the language server and the standard library. Nix users can add `pkgs.steel` to their Home Manager packages.

### Where does Steel store installed packages?

The README states that Steel follows XDG when present and otherwise assumes `$HOME/.steel`, unless the `STEEL_HOME` environment variable is already set. The Dockerfile uses `STEEL_HOME` to point the install tree at `/lib/steel`.

### Is Steel R7RS compliant?

Not yet. The README says Steel is mostly compliant with R5RS and is only missing `let-syntax` support, while R7RS support is underway.

### What licence is Steel released under?

The README states Steel is licensed under either the Apache License 2.0 or the MIT license, at your option, and the repository includes `LICENSE-APACHE` and `LICENSE-MIT`. The Cargo.toml `license` field records the same `MIT OR Apache-2.0` arrangement.

## Sources

- [Issues](https://github.com/mattwparas/steel/issues)
- [License: Apache-2.0](https://github.com/mattwparas/steel/blob/master/LICENSE)
- [mattwparas/steel on GitHub](https://github.com/mattwparas/steel)
- [README](https://github.com/mattwparas/steel/blob/master/README.md)
- [Releases](https://github.com/mattwparas/steel/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/mattwparas-steel
