typesafe-go

September 18, 2026 · View on GitHub

A Go client for the TypeSafe System One API. Zero dependencies outside the standard library.

System One answers questions about content with a typed judgment and a probability instead of generating text you then have to parse.

go get github.com/2389-research/typesafe-go

Quick start

package main

import (
	"context"
	"fmt"

	"github.com/2389-research/typesafe-go"
)

type department string

const (
	billing   department = "billing"
	technical department = "technical"
	sales     department = "sales"
)

func main() {
	client, err := typesafe.New() // reads TYPESAFE_API_KEY
	if err != nil {
		panic(err)
	}

	ticket := "Help! My payouts have been failing for 3 days."

	urgent := typesafe.Noul("is_urgent", "Does this convey urgency?")
	team := typesafe.Choice[department]("department", "Which team should handle this?",
		typesafe.Opts[department]{
			billing:   "Payments, invoicing, refunds",
			technical: "Bugs, outages, integrations",
			sales:     "Pricing, upgrades, new accounts",
		})

	res, err := client.Ask(context.Background(), ticket, urgent, team)
	if err != nil {
		panic(err)
	}

	p, _ := urgent.From(res)        // 0.92
	assignment, _ := team.From(res) // Value: technical, Confidence: 0.82

	fmt.Println(p, assignment.Value, assignment.Probabilities)
}

Run the full example: go run ./examples/triage

Question handles

A question is a value you keep. It carries its own id and its own answer type, so you read the answer back through the question that asked it:

urgent := typesafe.Noul("is_urgent", "Does this convey urgency?")
res, _ := client.Ask(ctx, ticket, urgent)
p, err := urgent.From(res)

The id appears once. The compiler checks the answer's shape: a Score or Choice handle's From returns its own answer type, so a Noul handle cannot hand you a ChoiceAnswer[T], or vice versa — that part is a compile error, not a zero value at runtime.

The compiler cannot check the id. Two handles can share an id and disagree about the type behind it, and that read compiles; it fails at read time with ErrWrongType rather than decoding a zero value.

From also checks that the answer body carries every documented payload field. A truncated response — one missing noul, or a Choice with no choice — reports ErrIncompleteAnswer instead of reading as a confident zero. A genuine zero ("noul":0, "confidence":0) still decodes.

The three primitives

PrimitiveQuestionAnswer
Noulyes or nofloat64 — the probability of yes
Choiceone option from a set you defineChoiceAnswer[T] — winner, full distribution, confidence
Scorea position on ordered levelsScoreAnswer — weighted value, legend, distribution, confidence

A Noul has no confidence field. Its probability is the answer: 0.5 means the model is genuinely torn.

Instructions and descriptions take a string, or a map or slice when a sentence is not enough:

typesafe.Noul("refund", map[string]any{
	"question": "Is this a refund request?",
	"exclude":  []any{"chargebacks", "partial credits"},
})

Probabilities are the point

switch {
case urgency > 0.8 && frustration.Value > 1.5:
	page(onCall)
case urgency > 0.5:
	queue(sameDay)
default:
	queue(normal)
}

A 0.95 and a 0.51 both become "yes" after a threshold. Route on the number.

Configuration

client, err := typesafe.New(
	typesafe.WithAPIKey(key),
	typesafe.WithModel("jev-1.13.0"),
	typesafe.WithTimeout(20*time.Second),
	typesafe.WithRetry(policy),
)
VariableDefaultOption
TYPESAFE_API_KEYnone — requiredWithAPIKey
TYPESAFE_BASE_URLhttps://api.typesafe.aiWithBaseURL
TYPESAFE_DEFAULT_MODELjev-latestWithModel

Also WithHTTPClient for your own transport.

Transient failures retry without you asking: two retries, 500ms doubling to 5s with 25% jitter, honoring Retry-After, inside a 30-second budget. DefaultRetryPolicy() returns that; WithRetry replaces it.

Errors

res, err := client.Ask(ctx, ticket, urgent)
switch {
case errors.Is(err, typesafe.ErrRateLimited):
	// already retried; you are over your limit
case errors.Is(err, typesafe.ErrUnprocessable):
	// the request was malformed
case err != nil:
	var apiErr *typesafe.Error
	if errors.As(err, &apiErr) {
		log.Printf("status %d: %s", apiErr.StatusCode, apiErr.Body)
	}
}

Error keeps the status code and the raw body. TypeSafe does not document the shape of its error bodies, so the SDK preserves them rather than guessing.

Keep the key on the server

The API key authenticates your account and carries your billing. It belongs in a server-side process, never in a browser bundle, a mobile app, or anything a user can read.

Tests

./scripts/check                              # fmt, vet, lint, race tests
TYPESAFE_API_KEY=sk-... ./scripts/check      # adds live API tests

Live tests are behind the e2e build tag and skip without a key. They cost roughly a thousandth of a cent per call. Even with a key set, ./scripts/check excludes TestLiveChoiceOptionOrderDoesNotMoveTheDistribution: it spends six billed calls checking whether the order of a Choice's options moves the answer distribution. Opts[T] is a Go map and encoding/json sorts map keys, so this SDK sends options alphabetically whatever order you wrote them in. If that turns out to matter, Opts[T] has to become an ordered type. The probe has not been run yet. Run it on its own:

TYPESAFE_API_KEY=sk-... go test -tags=e2e ./... -run TestLiveChoiceOptionOrder -v

License

MIT — see LICENSE. Copyright 2389 Research, Inc.