Skip to content

Code Generator

The code generator (Skein.CodeGen.CoreErlang) translates the Skein AST into Core Erlang using Erlang’s :cerl module, then compiles to BEAM bytecode via :compile.forms/2.

Location: apps/skein_compiler/lib/skein/codegen/core_erlang.ex — the largest compiler stage alongside the parser

Core Erlang is a simplified, explicit intermediate representation for the BEAM VM. It’s what Elixir, LFE, Gleam, and now Skein compile to. Key properties:

  • All variables are single-assignment
  • All functions are explicitly named with arity
  • Pattern matching is lowered to case expressions
  • No syntactic sugar — everything is explicit
  • The :cerl module provides an API for constructing Core Erlang AST nodes programmatically

Instead of generating Core Erlang text, the code generator builds AST nodes using :cerl:

# Create a module
:cerl.c_module(name, exports, attributes, definitions)
# Create a function
:cerl.c_fun(params, body)
# Create a case expression
:cerl.c_case(subject, clauses)
# Create a let binding
:cerl.c_let(vars, value, body)
# Create a function call (to another module)
:cerl.c_call(module, function, args)
# Create a local function application
:cerl.c_apply(function, args)
# Create literals
:cerl.abstract(42) # integer
:cerl.abstract("hello") # binary string
:cerl.abstract(true) # boolean atom
# Create variables
:cerl.c_var(:MyVar)
# Create a list
:cerl.make_list(elements)

A Skein module compiles to a Core Erlang module with:

  1. The module name atom (Elixir.Skein.User.<Name>)
  2. An export list of all functions + __info__/1
  3. Function definitions
  4. An __info__/1 function for Elixir compatibility
def generate(%AST.Module{} = ast) do
module_atom = String.to_atom("Elixir.Skein.User.#{ast.name}")
fns = Enum.filter(ast.declarations, &match?(%AST.Fn{}, &1))
exports = Enum.map(fns, fn f ->
:cerl.c_fname(String.to_atom(f.name), length(f.params))
end)
defs = Enum.map(fns, fn f ->
fname = :cerl.c_fname(String.to_atom(f.name), length(f.params))
fun = generate_fn(f)
{fname, fun}
end)
# Add __info__/1
info_fname = :cerl.c_fname(:__info__, 1)
info_fun = generate_info_fn(module_atom, fns)
mod = :cerl.c_module(
:cerl.c_atom(module_atom),
[info_fname | exports],
[],
[{info_fname, info_fun} | defs]
)
case :compile.forms(mod, [:from_core, :binary, :return_errors]) do
{:ok, _, beam_binary} -> {:ok, [{module_atom, beam_binary}]}
{:ok, _, beam_binary, _warnings} -> {:ok, [{module_atom, beam_binary}]}
{:error, errors, _warnings} -> {:error, format_errors(errors)}
end
end

generate/1 returns a list of named binaries — {:ok, [{module, binary()}]} — because one source file can produce several BEAM modules: a module with nested agents yields the module itself (Skein.User.Foo) followed by one entry per agent (Skein.Agent.Foo.Bar). Nested agents are generated with the module’s capabilities and type declarations in scope, so llm.json[T] schema resolution works inside agent handlers. The compiler driver loads every entry and returns the first (primary) module.

Each Skein function becomes a Core Erlang function:

fn add(a: Int, b: Int) -> Int {
a + b
}

Generates:

'add'/2 = fun (A, B) ->
call 'erlang':'+'(A, B)

The generator:

  1. Creates parameter variables (snake_case -> CamelCase)
  2. Builds a scope map from parameter names to Core Erlang variables
  3. Recursively generates the body expression
  4. Wraps in :cerl.c_fun/2
SkeinCore Erlang via :cerl
42:cerl.abstract(42)
3.14:cerl.abstract(3.14)
true:cerl.abstract(true)
false:cerl.abstract(false)
"hello":cerl.abstract("hello")

"Hello, ${name}!" compiles to an iolist passed to erlang:iolist_to_binary/1:

# Build the iolist: ["Hello, ", Name, "!"]
parts = [
:cerl.abstract("Hello, "),
:cerl.c_var(:Name),
:cerl.abstract("!")
]
iolist = :cerl.make_list(parts)
# Wrap in erlang:iolist_to_binary/1
:cerl.c_call(
:cerl.c_atom(:erlang),
:cerl.c_atom(:iolist_to_binary),
[iolist]
)

This approach was chosen over binary construction (<<>>) because:

  • It handles mixed literal and dynamic segments naturally
  • The :cerl.abstract/1 function produces proper binary literals
  • iolist_to_binary is efficient and well-understood on the BEAM

Binary ops compile to erlang BIF calls (division is special-cased; see the footnote below):

# a + b
:cerl.c_call(:cerl.c_atom(:erlang), :cerl.c_atom(:+), [left, right])

Operator mapping:

SkeinErlang BIF
+:+
-:-
*:*
/:div / :/ (runtime dispatch)*
==:==
!=:"/="
<:<
>:>
<=:"=<"
>=:>=
&&:and
||:or

* Division does not compile to a single BIF call. The generator binds both operands, then emits a case on erlang:is_float/1 of either operand: if either is a float, it calls erlang:'/' (float division); otherwise it calls erlang:div (integer division). So 7 / 2 is 3 while 7.0 / 2 is 3.5.

let x = expr
body

Compiles to:

:cerl.c_let(
[:cerl.c_var(:X)], # bound variable
generate_expr(value), # value expression
generate_expr(body) # body where variable is in scope
)
match subject {
true -> "yes"
false -> "no"
}

Compiles to a Core Erlang case:

:cerl.c_case(
generate_expr(subject),
[
:cerl.c_clause([:cerl.abstract(true)], :cerl.abstract("yes")),
:cerl.c_clause([:cerl.abstract(false)], :cerl.abstract("no"))
]
)

Local function calls use :cerl.c_apply/2:

:cerl.c_apply(
:cerl.c_fname(String.to_atom(name), arity),
args
)

Multi-expression blocks use chained let bindings. Each expression except the last is bound to a unique temporary variable:

{
let x = a + b
let y = x * 2
y
}

The last expression becomes the body of the innermost let.

Variant constructors lower to the same shapes the pattern side matches:

Skein expressionCore Erlang value
Status.Active, bare Active, Status.Active():active (bare atom)
Status.Banned("spam"), bare Banned("spam"){:banned, "spam"}
Ok(x) / Err(e){:ok, X} / {:error, E}
SearchError.from(e){:search_error, E}

Zero-field variants are bare atoms, not 1-tuples, so constructions round-trip through match arms (Active -> ... compiles to the atom pattern :active). Variant names are snake_cased (ChargeSucceeded -> :charge_succeeded).

The analyzer validates constructions before codegen runs: unknown variants (E0010, with a closest-name fix_code), wrong arity, wrong argument types, and a data variant referenced without arguments (E0020, with a Enum.Variant(fields) skeleton) are all structured compile errors — never core_lint crashes.

Skein snake_case variables are converted to Core Erlang CamelCase:

defp var_name(name) when is_binary(name) do
name
|> String.split("_")
|> Enum.map(&String.capitalize/1)
|> Enum.join()
|> String.to_atom()
end
SkeinCore Erlang
xX
nameName
my_varMyVar
ticket_idTicketId

The generator needs unique variables for intermediate results. These are created with a process-local counter:

defp gen_var do
counter = Process.get(:skein_var_counter, 0)
Process.put(:skein_var_counter, counter + 1)
String.to_atom("_skein_#{counter}")
end

The final step compiles the Core Erlang AST to BEAM bytecode:

:compile.forms(core_module, [:from_core, :binary, :return_errors])

The :from_core flag tells the compiler the input is Core Erlang (not Erlang source). The :binary flag returns the bytecode as a binary rather than writing to disk. The :return_errors flag returns errors as data rather than printing to stderr.

When the code generator encounters a call to a known stdlib module like String.upcase(s), it generates a remote call to the corresponding runtime module:

-- Skein source:
String.upcase(name)
-- Compiles to (Core Erlang):
call 'Elixir.Skein.Runtime.Stdlib.String':'upcase'(Name)

The mapping from Skein module names to Elixir runtime modules:

Skein ModuleRuntime Module
StringSkein.Runtime.Stdlib.String
IntSkein.Runtime.Stdlib.Int
FloatSkein.Runtime.Stdlib.Float
ListSkein.Runtime.Stdlib.List
MapSkein.Runtime.Stdlib.Map
SetSkein.Runtime.Stdlib.Set
OptionSkein.Runtime.Stdlib.Option
ResultSkein.Runtime.Stdlib.Result
UuidSkein.Runtime.Stdlib.Uuid
InstantSkein.Runtime.Stdlib.Instant
DurationSkein.Runtime.Stdlib.Duration

No capabilities are required for stdlib calls. See the Standard Library reference for the full API.

When the code generator encounters an effect call like http.get(url), it generates a remote call to the runtime instead of a local apply:

-- Skein source:
http.get(url)
-- Compiles to (Core Erlang):
call 'Elixir.Skein.Runtime.Http':'get'(Url, Capabilities)

The generator recognizes effect calls by pattern-matching on Call{target: FieldAccess{subject: Identifier{name}, field}} where name is a known effect namespace.

Every effect call appends the module’s compiled capabilities list as the final argument. This is built at compile time from the module’s capability declarations and enables runtime enforcement:

-- Skein source (module has: capability http.out("api.example.com"))
http.get(url)
-- Compiles to:
call 'Elixir.Skein.Runtime.Http':'get'(
Url,
[#{kind => "http.out", params => ["api.example.com"]}]
)

Effect namespace to runtime module mapping

Section titled “Effect namespace to runtime module mapping”
NamespaceRuntime ModuleOperations
httpSkein.Runtime.Httpget, post, put, patch, delete
store.<table>Skein.Runtime.Storeget, put, delete, query
memorySkein.Runtime.Memoryput, get, delete, list
llmSkein.Runtime.Llmchat, json, stream, embed
toolSkein.Runtime.Toolcall, list, schema
topicSkein.Runtime.Topicpublish
queueSkein.Runtime.Queuepublish
traceSkein.Runtime.Traceannotate
eventSkein.Runtime.EventStorelog
processSkein.Runtime.Processspawn
timerSkein.Runtime.Timerafter, interval, cancel

HTTP calls follow the generic pattern — the namespace maps to Skein.Runtime.Http and the method becomes the function name:

http.post("https://api.example.com/data", body)
-- Compiles to:
call 'Elixir.Skein.Runtime.Http':'post'(Url, Body, Capabilities)

Store effects have a three-level namespace: store.<table>.<operation>. The code generator extracts the table name and passes it as the first argument:

store.users.get(id)
-- Compiles to:
call 'Elixir.Skein.Runtime.Store':'get'("users", Id, Capabilities)

For typed tables (C5), put carries two extra arguments immediately before the capabilities: the table’s derived JSON Schema, so the runtime schema-checks every write (defense in depth behind the analyzer’s record typing, and a guard for dynamic data that reached the record through Json seams), and the record type’s @primary field name (#340), so rows are keyed by the declared primary rather than a hard-coded id — call 'Elixir.Skein.Runtime.Store':'put'("users", Record, Schema, Primary, Capabilities).

Memory effects extract the namespace from the memory.kv(namespace) capability declaration and pass it as the first argument:

-- Module has: capability memory.kv("sessions")
memory.put("user", user_id)
-- Compiles to:
call 'Elixir.Skein.Runtime.Memory':'put'("sessions", "user", UserId, Capabilities)

LLM calls have special handling for each method:

llm.chat — standard text response:

llm.chat("claude-opus-4-8", "System prompt", input)
-- Compiles to:
call 'Elixir.Skein.Runtime.Llm':'chat'("claude-opus-4-8", "System prompt", Input, Capabilities)

llm.json[T] — schema-constrained JSON. The type parameter is compiled into a JSON Schema literal using SchemaGen:

llm.json[Decision]("claude-opus-4-8", "Decide action", input)
-- Compiles to:
call 'Elixir.Skein.Runtime.Llm':'json'("claude-opus-4-8", "Decide action", Input, Schema, Capabilities)

Where Schema is the JSON Schema derived from the Decision type definition at compile time.

llm.stream — streaming response:

llm.stream("claude-opus-4-8", "Generate report", data)
-- Compiles to:
call 'Elixir.Skein.Runtime.Llm':'stream'("claude-opus-4-8", "Generate report", Data, NoOpCallback, Capabilities)

llm.embed — embedding vector:

llm.embed("voyage-3-large", input)
-- Compiles to:
call 'Elixir.Skein.Runtime.Llm':'embed'("voyage-3-large", Input, Capabilities)

Tool calls lower tool identifier references to string literals:

tool.call(Stripe.CreateRefund, { amount: 100 })
-- Compiles to:
call 'Elixir.Skein.Runtime.Tool':'call'("Stripe.CreateRefund", Args, Capabilities)

Handler response helpers compile to tagged tuples (not runtime module calls):

respond.json(200, data) -- {:respond_json, 200, data}
respond.text(200, body) -- {:respond_text, 200, body}
respond.html(200, page) -- {:respond_html, 200, page}

The idempotent(key) construct compiles to a runtime check:

idempotent("process-order-123")
-- Compiles to:
call 'Elixir.Skein.Runtime.Idempotent':'check!'("process-order-123")

Any effect namespace listed in the runtime modules map that doesn’t have special handling follows the generic pattern:

namespace.method(arg1, arg2)
-- Compiles to:
call 'Elixir.Skein.Runtime.<Module>':'method'(Arg1, Arg2, Capabilities)

This covers http.*, trace.annotate, queue.publish, topic.publish, uuid.new, and instant.now. The namespace-to-module map is derived from the authoritative effect-ABI registry (Skein.EffectABI, #296) — the same registry that drives the analyzer’s effect tables and the spec §6 signature drift test.

The scoped effects — process.spawn, timer.after, timer.interval, timer.cancel, and event.log — thread the declared capability scope label (the pool/group/stream name from the capability declaration, spec §3.2) into the runtime call as the first argument:

-- Module has: capability process.spawn("workers")
process.spawn(task)
-- Compiles to:
call 'Elixir.Skein.Runtime.Process':'spawn'("workers", Task, Capabilities)

A parameterless capability declaration leaves the label nil (unscoped — the runtime checks presence only). The first declared capability of the kind wins; nested agents list their own capabilities before the module’s, so an agent-level label overrides the module’s.

Handler declarations compile to regular functions with metadata. Each handler produces:

  1. An index-based handler function (__handler_0__/1, __handler_1__/1, …) that takes a request map and returns a response
  2. An entry in __handlers__/0 naming the source, method, route pattern, and handler function
-- Skein source:
handler http GET "/users/:id" (req) -> {
req.params.id
}
-- Generates:
-- 1. A function __handler_0__/1
-- 2. Metadata in __handlers__/0:
-- [%{source: :http, method: :get, route: "/users/:id", handler: :__handler_0__}]

Route parameters are not listed in the metadata — the router extracts them from the route pattern at dispatch time. method is a lowercase atom (nil for queue/topic/schedule handlers).

Every compiled module includes a __capabilities__/0 function that returns the module’s declared capabilities as a list of maps:

Skein.User.MyService.__capabilities__()
#=> [%{kind: "http.out", params: ["api.example.com"]}]

A module with no capabilities returns an empty list. Each capability map has:

  • :kind — the capability type (e.g., "http.out")
  • :params — list of string parameters (e.g., host allowlist)

The code generator’s property-based tests verify invariants including:

  • Integer addition computes correctly for random inputs
  • Integer subtraction computes correctly for random inputs
  • Integer multiplication computes correctly for random inputs
  • Comparison > produces correct booleans
  • Equality == produces correct booleans
  • String interpolation round-trips alphanumeric input
  • Let bindings preserve computed values
  • Match on > 0 correctly classifies positive vs non-positive
  • Plain string literals return exact strings