Go client
Run a prepared workbook inside your own Go process. The model is a data file; the engine is a shared WebAssembly module. No server call, no subprocess, no Excel.
This page is the Local Runtime Client. It runs the model in your process, on a bundle you downloaded: no network, no token, no per-run metering. The other family — the Hosted HTTP SDKs — calls xplo's servers over HTTP and never downloads model code. Neither silently falls back to the other. Pick by where the calculation runs.
It is the same evaluator that powers hosted execution and the vmrun CLI, so the same model and inputs give the same values in every WebAssembly host. See How closely the engines agree, below, for the one place that is not the whole truth.
It runs on wazero, a pure-Go WebAssembly runtime: no cgo, no native library. Cross-compilation is unaffected, and CGO_ENABLED=0 static builds keep working. The only dependencies it adds are wazero and wazero's own indirect golang.org/x/sys.
Run it in one command
Every downloaded bundle carries a ready-to-run Go program and a vendored copy of this client, so the fastest path to your own number needs no module resolution at all.
- On your workbook page, open Ways to run it → Local Runtime Clients → Download the bundle, and unzip it.
- From the bundle folder:
cd examples/go && go run .
It prints one line per output — real names, real numbers, from this model:
lookup_total = 27
total = 4
examples/go/run.go was generated from the bundle's own manifest.json and interface.json, so its inputs and outputs are already your cells. Its go.mod replaces the client to the vendored ./xplo directory, so the only download is wazero. Edit the values at the top and re-run. examples/README.md lists the equivalent one-liner for the CLI, Node, Python, and Java.
Downloading a bundle or the client source is part of the Local Runtime tier (Team plan and up).
When to use it
| You want to | Use |
|---|---|
| Evaluate a model inside a Go service or batch job | This client |
| Run a model without managing any artifacts | Runtime API or the Hosted HTTP SDKs |
| Explore a model from a terminal | The bundle's bin/vmrun CLI |
The client is versioned against the engine contract, not against any workbook. Import it once and load as many models as you like — a new workbook is a new data file, not a new package.
Install
go get github.com/Pythia-Software/explo/clients/go
Not published for public
go getyet. The module lives in a private repository with no released tag, sogo getfails at the checksum database. Until it is public and tagged, point areplaceat a copy on disk — the three paths below all resolve the same code, and the coordinate above is what the command will be once released.
1. Use the copy inside your bundle. examples/go/xplo/ is the complete module. From your own project:
go mod edit -replace github.com/Pythia-Software/explo/clients/go=/path/to/model-bundle/examples/go/xplo
go mod tidy
2. Download the client source from the API — the path for a team without Go module proxy access:
curl -fsSL -H "Authorization: Bearer $XPLO_TOKEN" \
"https://xplo.pythia.software/api/v2/clients/go" -o xplo-client-go.zip
unzip -q xplo-client-go.zip # unpacks ./go
go mod edit -replace github.com/Pythia-Software/explo/clients/go="$PWD/go"
go mod tidy
The token is an xplo_ API token, the same one the Runtime API uses. Create or manage it in API Tokens.
3. Point at a checkout of the xplo repository, if you have one: go mod edit -replace github.com/Pythia-Software/explo/clients/go=/path/to/explo/clients/go.
A replace path is resolved relative to your own go.mod, not to the repository, so use an absolute path (or a correct ../ one). No go mod edit -require step is needed: go mod tidy supplies the pseudo-version itself, and would delete a hand-written require line anyway.
Quickstart
Point the client at the unzipped bundle folder:
package main
import (
"context"
"fmt"
"log"
xplo "github.com/Pythia-Software/explo/clients/go"
)
func main() {
ctx := context.Background()
model, closeRuntime, err := xplo.LoadBundle(ctx, "./model-bundle")
if err != nil {
log.Fatal(err)
}
defer func() { _ = closeRuntime(ctx) }()
out, err := model.Run(
map[string]xplo.Value{"loan_amount": 200_000.0},
[]string{"monthly_payment"},
)
if err != nil {
log.Fatal(err)
}
fmt.Println(out["monthly_payment"].Value)
}
Names exist only once labels are confirmed on the workbook, and Naming + Labelling is optional. If model.NamedInputs() is empty, this bundle has no names yet — use RunRaw with addresses (model.RunRaw(map[string]xplo.Value{"Sheet1!B1": 5.0}, []string{"Sheet1!B4"})). You can keep using cell references indefinitely, or add labels in the app and download the bundle again.
Every call has a context-taking twin (RunContext, RunRawContext, PrepareContext, ManifestContext, SimInfoContext) for cancellation and deadlines.
What is in a bundle, and slim bundles
A full bundle contains plan.xvm (your model), model.wasm (the shared engine), interface.json (your names), manifest.json (the typed contract), an examples/ directory, and a bin/vmrun binary for the command line.
A slim bundle carries only the model and its metadata — no model.wasm, no bin/vmrun (≈20 KB instead of ≈5.7 MB). It still runs, because the interpreter is identical for every model and the client caches it once per machine. LoadBundle resolves the engine in this order:
XPLO_RUNTIME_WASM— an explicit path to avmruntime.wasm.- The machine cache —
XPLO_RUNTIME_CACHE, or the OS user-cache directory plus/xplo/runtime, keyed by theruntime_hashrecorded inbundle.json. - The bundle's own
model.wasm, which is then copied into the cache for next time.
The cache is shared across languages — the file names are identical — so loading one full bundle from any language warms it for all of them. A slim bundle with a cold cache fails loudly rather than confusingly:
bundle at ./model-bundle ships no interpreter (slim bundle) and none is cached:
set XPLO_RUNTIME_WASM to a vmruntime.wasm, or load a full bundle once (it caches
the shared runtime automatically)
If the resolved engine is not the one the bundle was built against, the load fails with an interpreter hash mismatch rather than running a mismatched pair.
Names and cell addresses
Names come from the labels you set on the workbook, and are the stable way to call a model — a cell can move without breaking your code.
Aliases work, and name matching is case-insensitive. Cell addresses are always available as an escape hatch, and any key containing ! is treated as one — addresses are matched exactly, including the sheet name, so sheet1!b1 is not Sheet1!B1:
model.Run(map[string]xplo.Value{"Loan!B1": 200_000.0}, []string{"Loan!B8"})
model.RunRaw(map[string]xplo.Value{"Loan!B1": 200_000.0}, []string{"Loan!B8"})
Mix them when a cell has no name yet:
model.Run(
map[string]xplo.Value{"loan_amount": 200_000.0},
[]string{"monthly_payment"},
xplo.RunOptions{
RawInputs: map[string]xplo.Value{"Loan!C7": 0.02},
RawOutputs: []string{"Loan!D9"},
},
)
Passing nil outputs returns every terminal output; an empty non-nil slice selects nothing. AllNamedOutputs and AllNamedInputs are fields on RunOptions, not arguments:
out, err := model.Run(
map[string]xplo.Value{"loan_amount": 200_000.0},
nil,
xplo.RunOptions{AllNamedOutputs: true},
)
NamedInputs() and NamedOutputs() list canonical names only. To see aliases, review status, and long labels, read model.InterfaceLabels().
Values
xplo.Value accepts float64, bool, string, and nil.
Numbers must be float64. An int is rejected with guidance rather than silently coerced, because Excel numbers are 64-bit floats and Go makes you choose:
map[string]xplo.Value{"loan_amount": 200_000.0} // ✓
map[string]xplo.Value{"loan_amount": 200_000} // ✗ error, not a silent conversion
A nil value means leave that cell at its compiled baseline — it is not a blank-cell override, and it is indistinguishable from omitting the key, so build your map from present fields rather than mapping an absent one to nil. Pass "" if you want an empty string in the cell.
Text works, which is what a text-keyed VLOOKUP or MATCH needs:
model.RunRaw(map[string]xplo.Value{"Sheet1!B1": "Yes"}, []string{"Sheet1!D1"})
Each result is a CellResult with Value and Err. Err is an Excel error string such as "#DIV/0!", or nil:
cell := out["monthly_payment"]
if cell.IsError() {
log.Printf("Excel error: %s", *cell.Err)
}
Monte-Carlo sweeps
To vary inputs across many trials, prepare a sweep once and run it repeatedly. The engine recomputes only the cells between the inputs you vary and the outputs you watch, so cost scales with that path rather than with the workbook:
sweep, err := model.Prepare(
[]string{"loan_amount", "rate"},
[]string{"monthly_payment"},
)
out, err := sweep.Run([][]float64{
{200_000, 0.05},
{250_000, 0.06},
{300_000, 0.07},
})
payments, err := out.Column("monthly_payment") // one value per trial
value, err := out.At(0, "monthly_payment") // one trial, one output
For large sweeps, RunFlat takes a pre-flattened scenario-major slice and skips building rows:
const trials = 100_000
values := make([]float64, 0, trials*2)
for i := 0; i < trials; i++ {
values = append(values, sampleAmount(), sampleRate())
}
out, err := sweep.RunFlat(values, trials)
Sweeps are numeric — every value is a float64 in both directions. Text overrides are not yet supported here; use Run for those.
The batch wire format carries f64s and nothing else, so a trial whose watched output is an Excel error comes back as NaN. Column and At refuse to hand you that NaN — they return an error naming the failing trials, because one NaN would silently poison a mean over 100,000 of them. ColumnRaw/AtRaw return the NaNs if you want them, out.Errors() lists every failing (scenario, output) pair, and out.HasErrors() is the cheap check. Re-run that one scenario through model.Run to get the Excel error string.
Sweeps are also deterministic. Workbooks whose results depend on RAND or RANDBETWEEN resampling per trial are not supported by this path.
Loading several models
Each model gets its own WebAssembly instance, so models are independent and safe to hold at the same time. Compile the engine once to avoid repeating that work:
wasmBytes, err := os.ReadFile("./model-bundle/model.wasm") // a slim bundle has none — see above
rt, err := xplo.NewRuntime(ctx, wasmBytes)
defer func() { _ = rt.Close(ctx) }()
pricing, err := rt.LoadBundle(ctx, "./pricing-bundle")
risk, err := rt.LoadBundle(ctx, "./risk-bundle")
To load a blob you already hold, parse the interface yourself — rt.Load takes a *xplo.SemanticInterface, and nil loads the model with cell addresses only:
var iface xplo.SemanticInterface
if err := json.Unmarshal(interfaceJSON, &iface); err != nil {
log.Fatal(err)
}
model, err := rt.Load(ctx, planXvmBytes, &iface)
Models from one Runtime are fully isolated, and a fresh instance per model is required, not incidental — the program and the string interner are module-global. A Model is safe for concurrent use: calls against one instance are serialized internally, because its linear memory is one shared arena. That means concurrency buys nothing within a model — for parallel throughput, load one Model per goroutine from a shared Runtime, since instantiating is cheap and only the compile is not. Closing the Runtime releases every model created from it.
NewRuntimeWithOptions / LoadBundleWithOptions take xplo.RuntimeOptions{Interruptible: true}, which lets a per-call context stop an evaluation mid-run instead of only between ABI calls. It is off by default because it costs throughput (measured 2.3 ms → 5.9 ms per run on a 722-output workbook), and an interrupted model is spent.
When things fail
The client prefers a clear error over a plausible number:
| Situation | What happens |
|---|---|
| Unknown name | Error, naming the key and suggesting RawInputs |
| Retired or unreviewed name | Error, saying which |
| Two names pointing at the same cell | Error rather than letting one silently win |
| A cell the engine cannot evaluate | Error — never a zero that looks like an answer |
interface.json that does not match the model |
Fails to load, rather than mislabelling cells |
| A slim bundle with no cached engine | Error, naming XPLO_RUNTIME_WASM |
An int where a number is expected |
Error with guidance, not a silent conversion |
Duplicate-target errors are deterministic despite Go's random map iteration order, so the same call reports the same message every time.
On a bundle with no confirmed labels, a name is not "unknown" — it is treated as a cell address, and you get no input/overridable cell at address loan_amount from the engine instead. Check model.NamedInputs() first.
Running natively (the native target)
By default this client runs your model in process on vmruntime.wasm. A full bundle also ships bin/vmrun, and you can run the model on that native binary instead — the same VM engine, a different execution target:
model, closeModel, err := xplo.LoadBundleWithTargetOptions(ctx, dir,
xplo.BundleLoadOptions{Target: xplo.TargetNative})
// Run, RunRaw, Prepare/Sweep and every result reader work exactly as before.
WASM stays the default, and the two targets never fall back to one another — you choose explicitly, because they can differ in the last bits (see the next section). Set BundleLoadOptions.NativeBinary to point at a specific vmrun (handy in development, or to run a slim bundle, which ships no binary and otherwise fails the native target with an actionable message). Two things to know before you switch:
- It is a downloaded executable. The native target runs
bin/vmrunfrom the bundle, which is not sandboxed the way WebAssembly is — use only bundles from a source you trust. - It spawns a process per call. Each scalar run re-reads
plan.xvm, so a loop of single runs can be slower end-to-end than in-process WASM even though native arithmetic is faster; a Monte-Carlo sweep pays that cost once for the whole batch.
How closely the engines agree
Every WebAssembly host runs the identical vmruntime.wasm: this client, the other three Local Runtime Clients, the browser calculator, and the bundle's own loader.mjs return bit-identical f64 values for the same model and inputs. That part of "the same everywhere" is exact, and it is checked mechanically for every client on every bundle.
The native vmrun CLI (and the hosted service, which runs the same binary) is a separate compilation of the same source, so it can differ in the last bits. On a 722-output finance workbook, 687 outputs matched the native oracle bit-for-bit, 34 differed by 1–2 ULP — the documented libm transcendental gap — and one cell differed materially: a near-total cancellation where native keeps a residue of 1.8189894035458565e-12 and WebAssembly returns exactly 0. That last one is an open engine bug, not a client bug, and it is the same in all four languages.
So: reconcile a client number against another WebAssembly surface and expect the bits to match; reconcile it against the native CLI or the hosted API and expect agreement to a few ULP, with cancellation-heavy cells as the known exception.
API reference
| Member | Description |
|---|---|
xplo.LoadBundle(ctx, dir) |
One-shot: compile, load, and return a closer |
xplo.LoadBundleWithOptions(ctx, dir, opts) |
The same, with RuntimeOptions |
xplo.LoadBundleWithTargetOptions(ctx, dir, BundleLoadOptions{Target, NativeBinary}) |
Load on the wasm (default) or native vmrun target |
xplo.NewRuntime(ctx, wasm) / NewRuntimeWithOptions(...) |
Compile the engine once |
rt.Load(ctx, blob, *SemanticInterface) / rt.LoadBundle(ctx, dir) |
Instantiate a model (nil interface = addresses only) |
rt.Close(ctx) |
Release the runtime and its models |
model.Run(inputs, outputs, opts...) |
Run using names, with address fallbacks |
model.RunRaw(inputs, outputs) |
Run using cell addresses only |
xplo.RunOptions{RawInputs, RawOutputs, AllNamedOutputs, AllNamedInputs} |
Mixed and bulk selectors |
model.Manifest() |
Typed input/output contract |
model.NamedInputs() / model.NamedOutputs() |
Canonical names callable today |
model.InterfaceLabels() / model.Revision |
Aliases, labels, review status, revision (a field) |
model.SimInfo() |
Cells a sweep may vary or watch |
model.Prepare(inputs, outputs) |
Build a reusable sweep |
sweep.Run(scenarios) / sweep.RunFlat(values, n) |
Run trials |
result.Column(output) / result.At(trial, output) |
Read sweep results; error on an errored trial |
result.ColumnRaw(...) / result.AtRaw(...) |
The same values with errored trials as NaN |
result.Errors() / result.HasErrors() |
Trials whose output was an Excel error |
xplo.EnvRuntimeWasm / xplo.EnvRuntimeCache |
The engine-resolution environment variables |
Every call above that touches the VM has a …Context(ctx, …) twin.
See also
- Local Runtime Clients overview — choose a language and run the generated bundle example
- TypeScript client · Python client · Java client — same model, same names, same bits
- Hosted HTTP SDKs — the other family: run on xplo's servers, no artifacts
- Runtime API — the HTTP contract underneath, including the client-source download
- Naming + Labelling — optional aliases for cell addresses