Hosted HTTP SDKs

The Hosted HTTP SDKs call a prepared xplo workbook from Go, TypeScript, Python, or Rust. They wrap the Runtime API, use the programmatic names configured in Naming + Labelling, and accept canonical cell addresses directly. Naming is optional: every SDK can run a workbook that has no labels at all.

Two families, one product. These SDKs run the calculation on xplo's servers: every call is a network request with a token, and every run is metered. The other family — the Local Runtime Clients (Python, TypeScript, Go, Java) — runs the model in your own process on a downloaded bundle, with no network and no metering. Neither silently falls back to the other. Pick by where the calculation runs.

Hosted HTTP SDKs versus Local Runtime Clients

Hosted HTTP SDK (this page) Local Runtime Client
Where the calculation runs xplo-managed Runtime API Your process
Needs network + token Yes, every run No, after the download
Metered per run Yes No
Model artifact Stays on our servers A bundle you download
Main type XploHttpClient (xplohttp.Client in Go) Model / loadBundle
Languages Go, TypeScript, Python, Rust Python, TypeScript, Go, Java

The two lists are not the same: there is no Java Hosted HTTP SDK (call the Runtime API directly from the JVM — fetch the cell interface, POST a run), and there is no Rust Local Runtime Client. Every Hosted HTTP SDK name contains http; a Local Runtime Client name never does. An HTTP SDK never downloads or executes model code, starts vmrun, or falls back to local execution; a Local Runtime Client never sends workbook inputs to xplo.

Install

# Go
go get github.com/Pythia-Software/explo/clients/http/go

# TypeScript (Yarn)
yarn add @xplo/http-client

# Python
python -m pip install xplo-http-client

# Rust
cargo add xplo-http-client

Not published yet. None of these packages is on a public registry (the Go module proxy, npm, PyPI, crates.io), so the commands above do not resolve today. The coordinates are what the commands will be once released. Until then, use one of the two paths below.

1. Call the Runtime API directly. These SDKs are thin: an authenticated POST /actions/{id}/run, a GET /actions/{id}/cell-interface to pin the revision, and typed result structs. Anything curl or your language's HTTP client can do, they do — the Runtime API guide has the complete wire contract, including the semantic envelope these SDKs send. This is the only path that needs nothing from us at all, and it is the answer for Java and every other language.

2. Build from a checkout of the xplo repository, if you have one. The sources live under clients/http/. Every path below is relative to the consuming project, so use an absolute path into your checkout — a bare ./clients/http/... resolves inside your project and will not exist:

# Python
pip install /path/to/explo/clients/http/python

# TypeScript — build first (dist/ is generated, not committed), then add by path
(cd /path/to/explo/clients/http/typescript && yarn install && yarn build)
yarn add file:/path/to/explo/clients/http/typescript

# Go — from your module root
go mod edit -replace github.com/Pythia-Software/explo/clients/http/go=/path/to/explo/clients/http/go
go mod tidy

# Rust — in Cargo.toml (a path relative to this Cargo.toml, or absolute)
#   xplo-http-client = { path = "/path/to/explo/clients/http/rust" }

Note the asymmetry with the Local Runtime Clients: those can be downloaded as a source zip from GET /api/v2/clients/{lang} and are vendored inside every model bundle. The Hosted HTTP SDKs are not — that endpoint serves the local runtime family only.

All four SDKs are versioned independently from downloaded workbook bundles and from the Local Runtime Clients. The TypeScript package is ESM-only — the consuming file must be .mjs or the project package.json must set "type": "module".

The model handle and named interface

Every SDK follows the same lifecycle. Create or manage the credential for step 1 in API Tokens.

  1. Construct an HTTP client with an xplo_ API token.
  2. Load a model handle for a completed action ID. This fetches the current cell interface, including its programmatic keys, labels, units, aliases, and revision.
  3. Run the model with named inputs and outputs. The request includes the loaded interface revision.
  4. If someone changes the interface between steps 2 and 3, the server returns 409 CONFLICT. Refresh the handle, inspect the new contract, and rebuild the request deliberately.

This prevents a key from silently changing meaning during a deployment. The server resolves each key to its canonical workbook-qualified cell inside the same immutable revision used for the run. Renamed aliases continue to work when the interface marks them as resolvable, and the response includes a deprecation warning and canonical key.

Omitting the output argument means "all current named outputs." Passing an explicit empty list means "no named outputs." Inputs are currently numeric; booleans use 1/0, and dates use Excel serial values.

An action with no confirmed labels has no named interface. A named run against it succeeds with an empty result ({} named outputs, no warning) and still consumes a metered run — it is not an error. Check that the handle's interface has labels before running, or use the raw address path below and GET /actions/{id}/vm/manifest to discover legal addresses. The SDKs do not wrap that manifest endpoint; fetch it over plain HTTP.

refresh() does not mutate the handle in any of the four SDKs — it returns a new one at the current revision. Always reassign: model = model.refresh(). Dropping the result leaves you pinned to the stale revision and the next run 409s again.

Python

import os
from xplo_http import XploHttpClient, XploHttpError

client = XploHttpClient(
    token=os.environ["XPLO_TOKEN"],
    # Optional; a host-only URL also works and receives /api/v2 automatically.
    base_url="https://xplo.pythia.software/api/v2",
)
model = client.model("act-...")

inputs = {"loan_amount": 325_000, "interest_rate": 0.06}
try:
    result = model.run(inputs, ["monthly_payment"])
except XploHttpError as error:
    if error.status != 409:
        raise
    # Labels changed under us: refresh the handle (it returns a new one), review
    # the new interface, then rebuild and rerun deliberately.
    model = model.refresh()
    result = model.run(inputs, ["monthly_payment"])
print(result.named["monthly_payment"].value)

Canonical address escape hatch:

raw = model.run_raw(
    {"Loan!B1": 325_000, "Loan!B2": 0.06},
    ["Loan!B8"],
)
print(raw.outputs["Loan!B8"].value)

TypeScript

import { XploHttpClient, XploHttpError } from '@xplo/http-client';

const client = new XploHttpClient({
  token: process.env.XPLO_TOKEN!,
  baseUrl: 'https://xplo.pythia.software/api/v2',
});
let model = await client.model('act-...');

const inputs = { loan_amount: 325_000, interest_rate: 0.06 };
let result;
try {
  result = await model.run(inputs, ['monthly_payment']);
} catch (error) {
  if (!(error instanceof XploHttpError) || error.status !== 409) throw error;
  // Labels changed under us: refresh the handle (it returns a new one), review
  // the new interface, then rebuild and rerun deliberately.
  model = await model.refresh();
  result = await model.run(inputs, ['monthly_payment']);
}
console.log(result.outputs.named.monthly_payment.value);

Canonical address escape hatch:

const raw = await model.runRaw(
  { 'Loan!B1': 325_000, 'Loan!B2': 0.06 },
  ['Loan!B8'],
);
console.log(raw.outputs['Loan!B8'].value);

The TypeScript package uses the host's WHATWG fetch; Node 18 and later provide it natively.

Go

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"

    xplohttp "github.com/Pythia-Software/explo/clients/http/go"
)

func main() {
    ctx := context.Background()
    client, err := xplohttp.NewClient(
        os.Getenv("XPLO_TOKEN"),
        xplohttp.WithBaseURL("https://xplo.pythia.software/api/v2"),
        xplohttp.WithHTTPClient(&http.Client{Timeout: 20 * time.Second}),
    )
    if err != nil {
        log.Fatal(err)
    }
    model, err := client.Model(ctx, "act-...")
    if err != nil {
        log.Fatal(err)
    }
    result, err := model.Run(ctx, xplohttp.NamedInputs{
        "loan_amount": 325000,
        "interest_rate": 0.06,
    }, "monthly_payment")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(result.Outputs.Named["monthly_payment"].Value)
}

WithBaseURL and WithHTTPClient are the two client options; the second is how you set a deadline, since the SDK has no timeout knob of its own.

Canonical address escape hatch:

raw, err := model.RunRaw(ctx, xplohttp.RawInputs{
    "Loan!B1": 325000,
    "Loan!B2": 0.06,
}, "Loan!B8")

Run is a convenience over RunNamed, which carries the full selector surface (below). Note the asymmetry: Run(ctx, inputs) with no output names means all named outputs, so selecting none requires RunNamed(ctx, xplohttp.NamedRunRequest{Outputs: &xplohttp.NamedOutputs{}}).

Use errors.As with a var apiError *xplohttp.HTTPError target to inspect StatusCode, Code, and RetryAfter. Refresh returns a new handle, so assign it back: model, err = model.Refresh(ctx).

Rust

The Rust SDK currently exposes a blocking client:

use std::collections::BTreeMap;
use xplo_http::{Error, XploHttpClient};

fn main() -> Result<(), Error> {
    let client = XploHttpClient::new(std::env::var("XPLO_TOKEN").unwrap())?;
    let model = client.model("act-...")?;
    let result = model.run(
        BTreeMap::from([
            ("loan_amount".to_owned(), 325_000.0),
            ("interest_rate".to_owned(), 0.06),
        ]),
        Some(vec!["monthly_payment".to_owned()]),
    )?;
    println!("{}", result.outputs.named["monthly_payment"].value);
    Ok(())
}

Canonical address escape hatch:

let raw = model.run_raw(
    BTreeMap::from([
        ("Loan!B1".to_owned(), 325_000.0),
        ("Loan!B2".to_owned(), 0.06),
    ]),
    Some(vec!["Loan!B8".to_owned()]),
)?;

Match Error::Api(error) to inspect status, code, and retry_after. refresh() returns a new handle, so bind it back: let model = model.refresh()?;.

Result shapes differ by one level

The four SDKs send the same request and expose the same information, but Python flattens the named-run response while the other three keep the wire's outputs envelope. This is the one place a port from one language to another does not transliterate:

Language Named outputs Raw locator outputs Address run
Python result.named["k"].value result.cells raw.outputs["Loan!B8"].value
TypeScript result.outputs.named.k.value result.outputs.cells raw.outputs['Loan!B8'].value
Go result.Outputs.Named["k"].Value result.Outputs.Cells raw.Outputs["Loan!B8"].Value
Rust result.outputs.named["k"].value result.outputs.cells raw.outputs["Loan!B8"].value

Mixed named and raw selectors

All four SDKs let one run combine names and structured raw cell locators. Use this when most application-facing values have stable names but a temporary or diagnostic cell does not. A locator contains workbook_id, sheet, and A1 address; multi-workbook models require the workbook ID. The switch that echoes every current named input back is in the same place.

Each SDK spells it differently, so here is the same run in all four:

from xplo_http import CellInput, CellLocator

result = model.run(
    {"loan_amount": 325_000},
    ["monthly_payment"],
    raw_inputs=[CellInput(cell=CellLocator(sheet="Loan", address="C7"), value=0.02)],
    raw_outputs=[CellLocator(sheet="Loan", address="D9")],
    all_named_inputs=True,
)
print(result.named["monthly_payment"].value, [c.value for c in result.cells])
const result = await model.run({ loan_amount: 325_000 }, ['monthly_payment'], {
  rawInputs: [{ cell: { sheet: 'Loan', address: 'C7' }, value: 0.02 }],
  rawOutputs: [{ sheet: 'Loan', address: 'D9' }],
  allNamedInputs: true,
});
console.log(result.outputs.named.monthly_payment.value, result.outputs.cells);
outputs := xplohttp.NamedOutputs{"monthly_payment"}
result, err := model.RunNamed(ctx, xplohttp.NamedRunRequest{
    Inputs:  xplohttp.NamedInputs{"loan_amount": 325000},
    Outputs: &outputs, // nil = all named outputs; &NamedOutputs{} = none
    RawInputs: []xplohttp.CellInput{{
        Cell:  xplohttp.CellLocator{Sheet: "Loan", Address: "C7"},
        Value: 0.02,
    }},
    RawOutputs:     []xplohttp.CellLocator{{Sheet: "Loan", Address: "D9"}},
    AllNamedInputs: true,
})
use xplo_http::{CellInput, CellLocator, NamedRunOptions};

let result = model.run_with_options(
    BTreeMap::from([("loan_amount".to_owned(), 325_000.0)]),
    NamedRunOptions {
        outputs: Some(vec!["monthly_payment".to_owned()]),
        raw_inputs: vec![CellInput {
            cell: CellLocator {
                workbook_id: None,
                sheet: "Loan".to_owned(),
                address: "C7".to_owned(),
            },
            value: 0.02,
        }],
        raw_outputs: vec![CellLocator {
            workbook_id: None,
            sheet: "Loan".to_owned(),
            address: "D9".to_owned(),
        }],
        all_named_inputs: true,
    },
)?;

Named and raw selectors that target the same input are rejected. Selected outputs may share a physical target — the runtime computes the cell once and projects it under each requested name/locator.

run_raw/RunRaw is not revision-pinned. It deliberately omits expected_interface_revision, because it addresses physical cells rather than names, so it never returns 409 for an interface change. The safety the model handle buys you does not extend to it: if the workbook layout moves, a raw run silently targets different cells. Prefer names for anything long-lived.

Errors, timeouts, and retries

The SDKs preserve the Runtime API's status, error code/message, and Retry-After value. Their default client deadline is 35 seconds, slightly longer than the Runtime API's current 30-second execution bound. Configure a shorter deadline when your application requires one — in Go that means WithHTTPClient(&http.Client{Timeout: …}); the other three take the deadline as a client option.

409 has more than one cause, and the SDKs surface a single conflict code for all of them: a stale expected_interface_revision, a model artifact that changed while the request was being prepared, and a model that is not ready. Only the first is fixed by refresh() + rerun. Because every attempt is a new metered run, branch on the error message rather than auto-refreshing on any 409.

No SDK automatically retries POST /run. If your application chooses to retry a transient 500 or 503, use bounded exponential backoff and make the additional execution explicit in your usage accounting. Fix 400 and 409 responses before retrying an unchanged request.

See also