In this page
SDK Rust
The Rust SDK is for writing native GreyCat libraries: a shared object that the
greycat runtime loads, so a native fn declared in GCL runs Rust code.
It is not a client SDK. If you want to call a running GreyCat server over HTTP, use the js, python or java SDKs instead.
Setup
The SDK ships as a GreyCat library, so you pull it the same way as any other:
declare it in project.gcl and run greycat install.
@library("std", "0.0.0");
@library("rust", "0.0.0");
greycat install
That unpacks the crates into lib/rust/, next to the headers and archive in
lib/std/. Cargo then reaches the SDK by path:
[dependencies]
greycat = { path = "lib/rust/greycat" }
[build-dependencies]
greycat-build = { path = "lib/rust/greycat-build" }
lib/rust/ also carries the SDK’s full reference documentation, written to be
read by a person or loaded by an AI agent: start at lib/rust/skills/SKILL.md.
Declaring both is what keeps the two halves in step. The SDK reads C structures
from the runtime by memory offset, so an SDK and a runtime from different
versions would misread each other. Pinning both to the same version in the same
file makes that visible, and greycat install refuses a version that was never
published.
A minimal library
Six files. The example declares one native fn and implements it in Rust.
project.gcl
@library("std", "0.0.0");
@library("rust", "0.0.0");
@library("hello");
fn main() {
println(greet("world"));
}
The third pragma is the one people forget. std and rust are dependencies;
@library("hello") is what makes the runtime look for lib/hello/hello.gclib
and load it. Without it the compiler never sees the module and fails with
unresolved function: greet.
lib/hello/hello.gcl
The contract. This is where the functions your Rust code implements are declared.
native fn greet(name: String): String;
Cargo.toml
[package]
name = "hello"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
greycat = { path = "lib/rust/greycat" }
[build-dependencies]
greycat-build = { path = "lib/rust/greycat-build" }
[profile.release]
opt-level = 3
strip = true
crate-type = ["cdylib"] is required. Without it Cargo builds an rlib and there
is no shared object to install.
Note what the release profile does not set. panic = "abort" and lto are
both unsafe here; Building for release explains why.
build.rs
fn main() {
greycat_build::configure();
}
Every library needs this file. configure() emits the link settings for the
target platform: on macOS the gc_* symbols have to be marked as resolved at
load time, on Windows the host’s import library has to be linked, and on Linux
the export list is narrowed to the single entry point.
It cannot live in the SDK itself. Cargo does not pass a dependency’s link
settings on to the crate that depends on it, so they have to be emitted by your
own build script. Skip this file and the library builds on Linux but fails to
link on macOS and Windows with undefined gc_* symbols.
Generated bindings adds a second line here.
src/lib.rs
use greycat::prelude::*;
#[native_fn]
fn greet(name: &str) -> String {
format!("hello, {name}")
}
struct Hello;
impl Library for Hello {
const NAME: &'static str = "hello";
fn link(program: Program<'_>) -> greycat::Result<()> {
let module = program.resolve_module("hello")?;
program.link_module_fn(module, "greet", greet::NATIVE)?;
Ok(())
}
}
export_library!(hello, Hello);
build.sh
The runtime looks for lib/<name>/<name>.gclib, so the build output is copied
there:
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
cargo build
cp target/debug/libhello.so lib/hello/hello.gclib
On macOS the artifact is libhello.dylib, on Windows hello.dll; the
destination name is always <name>.gclib.
Run it
./build.sh
greycat run
hello, world
How it fits together
#[native_fn] leaves your function as ordinary Rust - still callable and unit
testable - and emits greet::NATIVE beside it, the extern "C" entry point the
runtime calls. That shim:
- reads each argument positionally and converts it,
- converts the return value back, reporting an
Erras a GreyCat error, - catches panics. This matters: an
extern "C"function that unwinds aborts the process, so without it one panicking call would take down the whole server.
export_library! emits gc_lib_hello__link, the symbol the loader resolves
after dlopen. The name must match Library::NAME and the .gclib file name;
a mismatch is a compile error rather than a load failure.
Types
Arguments and return values convert automatically:
| GreyCat | Rust |
|---|---|
bool char int |
bool char i64 |
float |
f64 |
String |
&str reading, String writing |
time duration geo |
Time Duration Geo |
node<T> and friends |
NodeRef |
Array<T> Map<K, V> |
Array Map |
| a user type | ObjectRef, or a generated view |
| anything nullable | Option<T> |
A function that needs the runtime itself - to allocate a String, build an
object, or log - takes a &Machine as its first parameter:
#[native_fn]
fn shout<'gc>(m: &Machine<'gc>, text: &str) -> greycat::Result<Object<'gc>> {
m.create_string(&text.to_uppercase())
}
Errors and panics
A native fn can fail with any error type that implements Display, so a
library is free to use its own:
#[native_fn]
fn fetch(url: &str) -> Result<String, MyError> {
Ok(ureq::get(url).call()?.body_mut().read_to_string()?)
}
The Err becomes a GreyCat error that GCL can catch:
try {
fetch("https://example.com");
} catch (e) {
println("failed: ${e.message}");
}
A panic is caught and reported the same way, so a bug in one function fails that call rather than the process. That guarantee depends on the release profile, so read the next section before publishing anything.
Building for release
cargo build --release
cp target/release/libhello.so lib/hello/hello.gclib
Two settings must stay out of [profile.release].
panic = "abort" removes the safety net described above. Catching a panic
needs unwinding, and an abort cannot be caught: the panic raises SIGABRT, the
runtime’s handler prints a backtrace and exits, and every request in flight on
every worker dies with it. One misbehaving native call takes down the server.
The SDK rejects this setting at compile time rather than let it ship.
lto, in either "thin" or "fat" form, merges the SDK’s bundled unwinder
into your crate, after which the linker can no longer keep its internal symbols
out of the library’s export table. The library still works; it just exports
symbols it has no business exporting. Leave lto unset.
What a released library may import
A .gclib should import only what the operating system guarantees. On Linux,
Rust’s unwinder normally lives in libgcc_s.so.1, which the greycat binary
does not use and a minimal container image need not carry; the SDK links its own
copy instead, so your library does not depend on it. This is automatic.
Check before publishing:
readelf -d lib/hello/hello.gclib | grep NEEDED # linux
otool -L lib/hello/hello.gclib # macos
Linux should list libc, ld-linux and whatever your own dependencies need,
and not libgcc_s.so.1. macOS should list libSystem and nothing else. If
libgcc_s appears, something in the profile has displaced the SDK’s unwinder.
One build per platform
A .gclib is native code, so a published library needs one build per platform
its users run. greycat install downloads the one matching the host:
| GreyCat target | Rust target |
|---|---|
x64-linux |
x86_64-unknown-linux-gnu |
arm64-linux |
aarch64-unknown-linux-gnu |
x64-apple |
x86_64-apple-darwin |
arm64-apple |
aarch64-apple-darwin |
x64-windows |
x86_64-pc-windows-gnullvm |
Because the SDK reads the runtime’s structures by memory offset, publish against the same version your users pin, and rebuild when it moves.
Custom types
Declare the type in GCL, then build instances from Rust:
type Point { x: int; y: int; label: String?; }
native fn make_point(x: int, y: int): Point;
#[native_fn]
fn make_point<'gc>(m: &Machine<'gc>, x: i64, y: i64) -> greycat::Result<Object<'gc>> {
let point = m.create_object(point_type)?;
point.set_field("x", Value::Int(x), m)?;
point.set_field("y", Value::Int(y), m)?;
Ok(point)
}
Reading works the same way, on a value you were handed:
let x = point.get_field("x", m)?;
There is no cast. An object already knows its type, so checking it is a question, not a conversion:
if obj.is_instance_of(point_type, m) { ... }
Generated bindings
Writing field names as strings gets tedious and is not checked. greycat codegen rust reads your compiled program and writes src/gc.rs, giving every declared
type a typed view whose field offsets are compile-time constants.
Add it to build.rs rather than running it by hand, so the bindings cannot go
stale:
fn main() {
greycat_build::codegen();
greycat_build::configure();
}
codegen() re-runs whenever any .gcl in the project changes, so editing a
declaration in GCL is enough. It uses the project’s own bin/greycat - the one
greycat install pinned to the version in project.gcl - rather than whichever
greycat happens to be on PATH, which matters because the SDK reads the
runtime’s structures by memory offset.
mod gc;
#[native_fn]
fn make_point<'gc>(m: &Machine<'gc>, x: i64, y: i64) -> greycat::Result<Object<'gc>> {
let owned = gc::hello::Point::create(m)?;
let point = unsafe { gc::hello::Point::from_ref_unchecked(owned.as_ref()) };
point.set_x(x, m)?;
point.set_y(y, m)?;
Ok(owned)
}
#[native_fn]
fn describe<'gc>(m: &Machine<'gc>, p: gc::hello::Point<'gc>) -> greycat::Result<String> {
let x: i64 = p.x(m)?; // int -> i64
let label: Option<GcStr<'_>> = p.label(m)?; // String? -> Option<GcStr>
Ok(format!("{x} {label:?}"))
}
It also generates the linking, so Library::link becomes one line:
fn link(program: Program<'_>) -> greycat::Result<()> {
gc::link(program)
}
That is worth more than the typing it saves. The generated code names your Rust
items directly, so a native fn you declared but never implemented is a
compile error:
error[E0433]: cannot find `forgotten` in `hello`
--> src/gc.rs:64:60
|
64 | __prg.link_module_fn(__mod, "forgotten", crate::hello::forgotten::NATIVE)?;
| ^^^^^^^^^
Without it, an unimplemented native fn compiles fine and fails only when GCL
calls it.
Module names must match
For that to work, the Rust module tree mirrors GreyCat’s:
| GreyCat | Rust |
|---|---|
module m (from m.gcl) |
crate::m |
native fn f in module m |
crate::m::f |
So lib/hello/hello.gcl pairs with src/hello.rs, and a library with several
.gcl files has one Rust module each.
#[native_fn] has to be applied at module scope, not inside a function body.
A native fn declared inside a type is the exception, and does not follow this
rule at all. It uses a generated trait instead.
Native methods on a type
Declare the functions in the type, with or without a receiver:
type SchemaBuilder {
text: String;
native static fn value_count(rows: int, columns: int): int;
native fn add_column(definition: String);
native fn print();
}
Codegen emits a SchemaBuilderNative trait beside the SchemaBuilder view, with each
signature taken from the GCL declaration, and binds shims that dispatch to it.
You implement the trait:
use crate::gc::parquet::{SchemaBuilder, SchemaBuilderNative};
impl<'gc> SchemaBuilderNative<'gc> for SchemaBuilder<'gc> {
fn add_column(&self, m: &Machine<'gc>, definition: &'gc str) -> greycat::Result<()> {
let joined = format!("{}{}", self.text(m)?.to_str()?, definition);
self.set_text(&joined, m)
}
fn print(&self, m: &Machine<'gc>) -> greycat::Result<()> {
println!("{}", self.text(m)?.to_str()?);
Ok(())
}
fn value_count(_m: &Machine<'gc>, rows: i64, columns: i64) -> greycat::Result<i64> {
Ok(rows * columns)
}
}
The impl can live anywhere in the crate, because nothing refers to it by path - which is why the module rule does not apply here. What the trait buys instead is stronger: GCL dictates the signature, so arity, parameter types, the return type and static-versus-instance are all checked by the Rust compiler.
- a missing implementation is
the trait bound `SchemaBuilder<'_>: SchemaBuilderNative<'_>` is not satisfied - a wrong parameter type is
expected i64, found i32, reported on your method &selfwhere GCL declaredstaticismethod `value_count` has a `&self` declaration in the impl, but not in the trait
Three things follow from being a trait rather than an attribute macro:
- Every method takes
&Machine<'gc>, statics included. A trait cannot make a parameter conditional the way#[native_fn]does, and codegen cannot know in advance whether a body will need one. - The return is always
greycat::Result<T>, withTfrom the GCL return type and()when there is none. That is what makes the return-type check possible, and it costs the freedom#[native_fn]has to fail with anyDisplaytype: convert with?and aFromimpl, or withError::external. - A
Stringparameter arrives as&'gc strrather thanGcStr, and a returned object is anObject<'gc>rather than a view, since a view borrows from something that already holds a reference.
#[native_fn] is unchanged, and remains what a module-level native fn uses.
Project layout
my-library/
├── project.gcl @library std, rust, and your own
├── Cargo.toml cdylib + the path dependencies
├── build.rs greycat_build::configure()
├── build.sh build, then copy into lib/<name>/
├── src/
│ ├── lib.rs Library impl and export_library!
│ ├── hello.rs mirrors lib/hello/hello.gcl
│ └── gc.rs generated; do not edit
└── lib/
├── std/ installed
├── rust/ installed: the SDK crates
└── hello/
├── hello.gcl your native fn declarations
└── hello.gclib built by build.sh
lib/ is install and build output, so it belongs in .gitignore apart from your
own .gcl files:
/target
/lib/std
/lib/rust
/lib/hello/hello.gclib
Going further
This page covers writing and shipping a library. The SDK ships its own reference
documentation under lib/rust/skills/, installed alongside the crates:
| File | Covers |
|---|---|
SKILL.md |
link modes, ownership, common pitfalls |
reference/build.md |
the release profile and per-target artifacts, in detail |
reference/codegen.md |
greycat codegen rust and the generated bindings |
reference/state.md |
holding a Rust value inside a GreyCat object |
reference/types.md |
values, casting, collections |
reference/host.md |
crypto, calling back into GCL, the scheduler |
reference/program.md |
reading the compiled program |
If you work with an AI agent, add @./lib/rust/skills/SKILL.md to the project’s
AGENTS.md next to the GreyCat skill at @./lib/std/skills/SKILL.md.