starbind generates Starlark bindings for your Go code. You annotate a Go
service struct, run starbind generate, and get a Go file that exposes that
service's methods to Starlark scripts -- with scalar and protobuf arguments
converted for you, context.Context threaded through automatically, and a
runtime that compiles a script once and runs it concurrently.
It exists so you can hand a scripting surface to your users (policy, glue, automation) without hand-writing wrapper code for every method, and without tying your service types to the scripting layer.
Go package -> generator.Analyze -> generator.Emit -> runtime
(go/packages, go/types, (deterministic (compile once,
annotations, snake_case) go/format output) run concurrently)
Analyze reads your package with go/packages/go/types, picks the types and
functions to bind, and validates their signatures. Emit writes a formatted,
deterministic .gen.go file. The runtime package compiles a script into a
reusable program, instantiates it once, freezes its globals, and lets you call
exported functions from many goroutines.
go install git.tatikoma.dev/corpix/starbind/cmd/starbind@latestOr drive it from go generate without installing:
//go:generate go run git.tatikoma.dev/corpix/starbind/cmd/starbind generate --go-out bindings.gen.go --go-package bindings ./service/calcThe flake exposes the starbind executable as packages.${system}.starbind
and packages.${system}.default.
Run it directly:
nix run git+https://git.tatikoma.dev/corpix/starbind -- generate --helpInstall it into your profile:
nix profile install git+https://git.tatikoma.dev/corpix/starbindTo add starbind to your own flake, declare it as an input and use its package in your development shell or build inputs:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
starbind.url = "git+https://git.tatikoma.dev/corpix/starbind";
starbind.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { nixpkgs, starbind, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in
{
devShells.${system}.default = pkgs.mkShell {
packages = [
starbind.packages.${system}.default
];
};
};
}Mark the type with // starbind:bind dependency methods to expose a value from
StarbindDependencies and bind its methods. Give it (and its methods) a
Starlark name with // starbind:name. A leading context.Context is detected
automatically and is not visible from Starlark.
package service
import "context"
// starbind:bind dependency methods
// starbind:name calc
type Calculator struct{}
func NewCalculator() *Calculator { return &Calculator{} }
// starbind:name add
func (c *Calculator) Add(ctx context.Context, a int64, b int64) (int64, error) {
return a + b, nil
}
func (c *Calculator) Greet(ctx context.Context, name string) string {
return "hello " + name
}
// starbind:ignore
func (c *Calculator) Internal() {}Exported methods are bound by default. // starbind:ignore drops one.
starbind generate \
--go-out ./bindings/bindings.gen.go \
--go-package bindings \
./serviceThis writes bindings/bindings.gen.go containing a StarbindCalculator
wrapper, a StarbindDependencies struct, and the StarbindNewRegistry /
StarbindNewRuntime helpers.
You pass your live instances in through StarbindDependencies. Everything else
(wrappers, registry, runtime) is generated.
package main
import (
"context"
"fmt"
"your/module/bindings"
"your/module/service"
)
const script = `
load("calc.star", "calc")
def add(a, b):
return calc.add(a, b)
def greet(name):
return calc.greet(name)
`
func main() {
rt, err := bindings.StarbindNewRuntime(bindings.StarbindDependencies{
Calculator: service.NewCalculator(),
})
if err != nil {
panic(err)
}
compiled, err := rt.Compile("policy.star", script)
if err != nil {
panic(err)
}
ctx := context.Background()
mod, err := rt.Instantiate(ctx, compiled)
if err != nil {
panic(err)
}
var sum int64
if err := mod.Invoke(ctx, "add", &sum, int64(20), int64(22)); err != nil {
panic(err)
}
fmt.Println(sum) // 42
}Invoke converts your Go arguments into Starlark, calls the named function, and
decodes the result into the pointer you pass. Use Call instead if you want the
raw starlark.Value back. A compiled+instantiated module is safe to call from
many goroutines at once.
If your methods take or return protobuf messages, --protobuf auto recognizes
them by default. Each message type gets a script-side constructor bound by its
name, and the runtime copies messages across the Go/Starlark boundary by
marshaling them:
// starbind:bind dependency methods
// starbind:name echo
type EchoService struct{}
func (s *EchoService) Echo(ctx context.Context, in *wrapperspb.StringValue) (*wrapperspb.StringValue, error) {
return in, nil
}resp = echo.echo(StringValue(value = "hi"))
print(resp.value)Nested messages, repeated fields, and maps work with no extra effort -- they ride along in the copy. Copying keeps each execution isolated: scripts cannot mutate the Go message you passed in, freeze semantics stay predictable, and there are no data races on shared protobuf state.
Protobuf support is built on go.starlark.net/lib/proto. A type is treated as a
protobuf message when it has a ProtoReflect() method. Generated stubs include
protobuf fields by default; use --protobuf-stubs names for nominal classes only
or --protobuf-stubs none to omit protobuf classes from stubs.
Every binding is available through generated modules. StarbindNewModules
groups symbols into loadable modules so scripts can pull in exactly what they
need with load():
load("example/echo.star", "echo", "StringValue")
def run(value):
return echo.echo(StringValue(value = value)).valueA dependency binding and the protobuf message constructors its methods reference
land in the same module. The module path defaults to
<module-root>/<starlark-name>.star and can be overridden per type or function
with // starbind:module <path>. StarbindNewRuntime wires the module loader,
so load() works out of the box and an unknown module path is a clear error.
If you want flat predeclared globals instead, use
StarbindNewPredeclaredRegistry(deps) when constructing a runtime directly.
Scripts can also load() other Starlark scripts. Pass runtime.WithModuleSource
a resolver -- or use the built-in runtime.DirModuleSource("./policies") to load
.star files from a directory:
rt, err := bindings.StarbindNewRuntime(deps, runtime.WithModuleSource(runtime.DirModuleSource("./policies")))Loaded script modules are compiled, executed once, frozen, and cached; concurrent loads of the same path are deduplicated, load errors are cached, and import cycles are reported.
A program is not limited to one binding package. Each generated package exposes
its contribution as a runtime.Bindings (flat globals, loadable modules, and the
runtime-global options it needs) through StarbindNewBindings(deps). Combine
several with runtime.Compose (or runtime.ComposeWithOptions to also pass
runtime options):
rt, err := runtime.ComposeWithOptions([]runtime.Bindings{
domain.StarbindNewBindings(domain.StarbindDependencies{...}),
shared.StarbindNewBindings(shared.StarbindDependencies{...}),
}, runtime.WithMaxExecutionSteps(1_000_000))Compose merges the registries and modules with deterministic collision
detection: a duplicate global name or a duplicate module path across packages is
a compose-time error naming the conflict, never silent last-wins. The schema hash
is always recomputed over the combined symbol set, so the compile cache stays
correct -- no per-package baked constant leaks into a composed runtime.
StarbindNewRuntime is just Compose(StarbindNewBindings(deps)), so the
single-package path is unchanged. Registry.Merge and Modules.Merge are also
public if you need the merge without building a full runtime.
Generated outputs are selected by path. If no output flag is provided, starbind
writes zz_starbind.gen.go; otherwise it writes exactly the requested artifacts:
--go-out-> generated Go wrappers, registries, modules, and runtime helpers.--manifest-out-> a machine-readable description of every service, method, function, and module (names, parameter and return types, deprecations).--typefacts-out-> a genericstarlark-lsptype facts JSON file with type/member signatures and implementation locations.--stubs-out->builtins.pyplus<module>.starfiles. Pointstarlark-lsp --builtin-pathsatstubs/builtins.pyfor predeclared globals and--load-pathsatstubs/soload()statements resolve to generated API stubs.--docs-out-> human-readable reference documentation.
starbind generate \
--go-out bindings.gen.go \
--manifest-out schema.gen.json \
--typefacts-out typefacts.gen.json \
--stubs-out stubs \
--docs-out docs.gen.md \
./serviceType-level // starbind:bind takes explicit facets:
dependency: add a field toStarbindDependenciesand bind that value under the Starlark name.methods: expose selected methods as receiver-bound builtins.constructor: bind a top-level constructor under the Go type name.fields: expose selected struct fields as Starlark attributes.convert: allow this Go type to flow through generated method/function signatures as a wrapped Starlark value.
Plain domain structs commonly use constructor fields convert.
// starbind:constructor <name> does not select the constructor binding; it only
chooses the Go factory used by a type that already has the constructor facet.
// starbind:bind constructor fields convert
type Selector struct {
Name string `starbind:"name"`
Labels map[string]string `starbind:"labels"`
Tags []string `starbind:"tags,readonly"`
Endpoint *Endpoint `starbind:"endpoint"`
Secret string `starbind:"-"`
}Field tags name the field and set its access: starbind:"name",
starbind:"labels,readonly", starbind:"desc,writable", or starbind:"-" to
skip it. Fields are writable by default; // starbind:readonly on the type makes
all fields read-only unless a field tag uses writable. --struct-fields picks
which Go struct fields are exposed: exported (all exported fields, the
default), annotated (only tagged fields), or none.
--methods does the same for methods (exported default, or annotated).
Scripts construct a value by calling its type name and read or assign fields:
sel = Selector(name = "web", labels = {"region": "de"})
sel.name = "web-1"
print(sel.labels["region"])
print(sel.endpoint.host) # nested valueValues flow through service methods as arguments and results. Scalars, map, and
slice fields are copied across the boundary (assign the whole field to change a
map or slice); nested value fields are wrapped in turn. Assigning a read-only
field, or any field of a frozen value, is an error. Use // starbind:constructor <name> to build the base instance from a Go constructor instead of the zero value.
| Flag | Values | Default |
|---|---|---|
| package args | Go package patterns | . |
--select |
annotated, all |
annotated |
--type, --type-glob, --type-regexp |
type selectors, repeatable | empty |
--type-bind |
selected type binding facets: dependency, constructor, fields, methods, convert |
empty |
--func, --func-glob, --func-regexp |
function selectors, repeatable | empty |
--inject |
Go type to inject from execution context, repeatable | empty |
--go-out |
generated Go file path | zz_starbind.gen.go when no outputs are set |
--go-package |
generated Go package name | starbindbindings |
--manifest-out |
generated manifest JSON file path | empty |
--typefacts-out |
generated Starlark LSP type facts JSON file path | empty |
--stubs-out |
generated stubs directory | empty |
--docs-out |
generated docs Markdown file path | empty |
--type-prefix |
generated type prefix | Starbind |
--type-suffix |
generated type suffix | empty |
--module-root |
prefix for generated load() module paths |
empty |
--unsupported |
error, warn, skip |
mode-dependent |
--protobuf |
auto, off |
auto |
--protobuf-stubs |
fields, names, none |
fields |
--struct-fields |
exported, annotated, none |
exported |
--methods |
annotated, exported |
exported |
--ref-types |
referenced, explicit, none |
referenced |
--ref-type, --ref-type-glob |
referenced struct selectors, repeatable | empty |
--ref-type-exclude |
referenced struct exclude globs, repeatable | empty |
Generated wrapper types are named <type-prefix><GoTypeName><type-suffix>, so
the defaults give you StarbindCalculator.
annotated(default): bind only types marked with// starbind:bind <facets>and functions marked with bare// starbind:bind. This is the mode you usually want.all: bind every supported exported service type and package-level function. Members with unsupported signatures produce warnings by default.- Exact, glob, and regexp selectors bind only what they match. Type selectors
require
--type-bindto say which facets to generate. A missing or unsupported selector is an error.
--unsupported controls what happens when a member cannot be bound: error
(the default outside all), warn (the default in all), or skip.
Package-level functions can be bound too; they become top-level builtins in the
generated registry (see example/mixed).
Annotations honored today:
// starbind:bind <facets> mark a type for binding
// starbind:bind mark a function or method for binding
// starbind:ignore drop a type, method, or function
// starbind:name <name> set the Starlark name
// starbind:module <path> set the load() module path (type or function only)
// starbind:inject <param> hide a parameter and read it from execution context
// starbind:optional <param> make a trailing parameter optional (defaults to None)
// starbind:readonly make all fields read-only unless a field is writable
// starbind:constructor <name> use a Go factory for the constructor binding
// starbind:deprecated <text> emit a // Deprecated: comment on the binding
Field tags on value structs: starbind:"name", starbind:"name,readonly",
starbind:"name,writable", starbind:"-".
Names default to snake_case of the Go identifier
(Calculator -> calculator, ReconcileLinkSet -> reconcile_link_set).
Generated methods and functions accept positional or keyword arguments, keyed by
the snake_case parameter name (calc.add(a=1, b=2)).
Injected parameters are omitted from the Starlark signature and loaded from the
call context. Use --inject import/path.Type to hide matching parameter types
globally, or // starbind:inject name on a type, method, or function to hide a
named parameter. Type-level annotations apply to methods where the parameter is
present; method-level annotations add to type-level named injections for that
method. Method/function-level named injections must match a parameter on that
callable. Provide values at execution time with runtime.WithInjected[T](ctx, v).
Optional parameters may be omitted by the caller. Use // starbind:optional name
on a method or function; like starbind:inject, name is the Go parameter name.
An omitted optional argument is passed to Go as None, so only nullable types are
allowed (pointer, map, slice, any, starlark.Value, a proto message, or a
pointer to a value/text type) where None maps to a nil Go value. Optional
parameters must be trailing (no required parameter may follow one) and cannot be a
variadic or injected parameter.
Scalar conversions:
| Go | Starlark |
|---|---|
bool |
bool |
string |
string |
| signed integers | int |
| unsigned integers | int (range-checked) |
float32, float64 |
float |
[]byte |
bytes |
| scalar pointer | scalar or None |
| named scalar | underlying scalar |
Results:
| Go | Starlark |
|---|---|
error (nil) |
None |
error (non-nil) |
evaluation error |
T |
converted value |
(T, nil) |
converted value |
(nil, nil) |
None |
Callback parameters may use anonymous function types or named function types:
type Mapper func(context.Context, string) (string, error)
func (s *Store) MapValues(ctx context.Context, values []string, mapper Mapper) ([]string, error)
func (s *Store) Weight(ctx context.Context, values []string, weight func(string) int64) (int64, error)The script passes a normal Starlark function or lambda. Callback parameters and
results use the same supported conversions as ordinary bindings. A leading
context.Context in the callback type is supplied by Go and is not visible to
Starlark.
If the Go callback type returns error, Starlark call failures are returned
through that error. If the Go callback type has no error result, generated
code panics with runtime.CallbackError when the Starlark callable fails or its
return value cannot be converted. Starbind does not recover this panic at the
top level. That keeps no-error callbacks honest: there is no fake place to put
an error in a Go function type that did not ask for one.
This is especially important for stored callbacks. If Go keeps a callback and
calls it later, the panic reaches that later Go caller unless the host recovers
it. Prefer an error-returning callback type for stored callbacks and for any
callback where script failure is part of the normal control flow.
Not supported: channels, variadic methods, variadic callback types, callback
values in fields/results/containers, nested callback signatures, arbitrary
interfaces, unresolved generics, unsafe.Pointer, a misplaced
context.Context. These are rejected at generate time according to
--unsupported.
A Runtime, a CompiledModule, and a module's frozen globals are shared and
safe to reuse. Everything per-execution is not shared: each call gets its own
starlark.Thread, its own context.Context, and its own argument copies. The
runtime never serializes calls into your services -- if a wrapped service is
called concurrently, that service is responsible for its own thread safety,
transactions, and locking.
Context flows in through the call: inside a generated method,
runtime.CurrentContext(thread) returns the context you passed to Call /
Invoke. runtime.WithContext lets a derived context stay visible across
synchronous Starlark callbacks without spinning up a new thread.
Compile caches programs, so recompiling the same source against the same
bindings is cheap. By default (InitOnce) top-level code runs once at
Instantiate and its globals are frozen and shared; runtime.WithInitMode(runtime.InitPerCall)
re-runs top-level state for every call instead, at higher cost.
A value codec is runtime-global (it installs a per-thread descriptor pool and
value conversion). When you Compose several packages, all of them must agree on
a compatible codec: composing packages that require different codecs is a
compose-time error rather than a silent replacement. Packages that share one
codec (for example the protobuf codec) compose without conflict.
starbind is being built in iterations. The spec (starbind.spec) describes the
full intended design; this is what is actually implemented right now.
Working today:
- service struct wrappers (
starlark.Value+HasAttrs, receiver-bound method builtins) - value bindings: struct field access and assignment, field tags, readonly, nested
value fields,
map/slicefields, constructors, and--struct-fields/--methods annotated,all, exact, glob, and regexp selection- package-level function binding
- explicit output paths for Go, manifest, stubs, and docs
- configurable type prefix/suffix and module root
- implicit
context.Context - scalar conversion and
(T, error)results - typed callback parameters
- protobuf copy conversion, including nested, repeated, and map fields
- protobuf field-aware generated stubs
- generated
StarbindDependencies,StarbindNewRegistry,StarbindNewModules, andStarbindNewRuntime load()-able modules driven by--module-rootand// starbind:module- positional and keyword arguments
// starbind:deprecatedcomments- name-collision detection at generate time
- program compilation cache;
InitOnce(default) andInitPerCall - script-module loading via
runtime.WithModuleSourcewith load caching, dedup, cycle detection, and cached errors - compile once, freeze globals, reuse concurrently; one thread per execution
- context propagation across callbacks
This completes the spec's MVP (starbind.spec section 15) plus value bindings.
Not built yet (the spec describes these, but they error or no-op today):
- live element mutation of value
map/slicefields (assign the whole field instead) - multiple naming policies (snake_case is the only one)
If you point a flag or annotation at one of the unbuilt features, generation fails with a clear "not implemented" message rather than emitting silently wrong code.
just build # build the CLI
just test # go test ./...
just lint # go vet + golangci-lintRegenerate the example bindings after changing the generator:
go generate ./example/...Generated output is deterministic and gofumpt-stable, so regenerating without a
real change produces no diff. The example/ bindings also emit the manifest,
stub, and doc artifacts (schema.gen.json, stubs/, docs.gen.md) next to the
generated Go. The example/ tree is the best reference for what generated code
looks like: example/service + example/bindings (scalars),
example/protoservice + example/protobindings (protobuf), example/valueservice
example/valuebindings(value bindings), andexample/mixed(all-mode plus a bound function).