Skip to content

CreateClassifier

Go · package github.com/muthuishere/toolnexus/golang · SPEC §8B · golang/classifier.go

func CreateClassifier(opts ClassifierOptions) (*Classifier, error)
func (c *Classifier) Evaluate(
ctx context.Context,
state any,
questions map[string]Question,
) (Decision, error)

A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. state is whatever the host already has (a string, a struct, a map, a slice); questions is a map from caller-chosen keys to question definitions. The keys are addressing, not content — they are never transmitted, so a key may be a tool, skill or agent name verbatim, and two evaluations differing only in their keys send identical bytes.

Go returns (T, error) everywhere the other ports throw, and Evaluate takes a context.Context — cancel it and the call returns ctx.Err() without retrying.

Reach for a classifier when the thing you need is a judgment, not an action: is this command risky, does this turn need the billing skill, is this text an injection attempt, how urgent is this ticket. Tool is the contract for an action; Classifier is the contract for a judgment. The concepts and the measurements live in Typed decisions; this page is the Go surface.

A Classifier is not a model you can hand to Client.Run — it has no messages, no tool calling and no streaming, and it never enters the client loop.

Setting Default
Style StyleSystemOne
BaseURL https://api.typesafe.ai/v1 (DefaultClassifierBaseURL)
Model jev-latest (DefaultClassifierModel) — a floating alias; pin it once thresholds are tuned
APIKeyEnv TYPESAFE_API_KEY (DefaultClassifierAPIKeyEnv) — the name of an env var, never a value
Timeout 10 s (DefaultClassifierTimeout), bounding one request
Retries 2
HTTPClient http.DefaultClient
type ClassifierOptions struct {
Style ClassifierStyle
BaseURL string
Model string
APIKeyEnv string
Headers map[string]string
Timeout time.Duration
HTTPClient *http.Client
Retries int
RetryableStatuses []int
OnError func(ErrorInfo) Tier
RequestParams map[string]any
BodyTransform func(body map[string]any) map[string]any
OnMetric func(MetricEvent)
Client *Client
Evaluate func(ctx context.Context, state any, questions map[string]Question) (Decision, error)
Decisions []RecordedDecision
}
Field Default What it does
Style StyleSystemOne Which backend answers: StyleSystemOne, StyleLLM, StyleCustom, StyleStatic.
BaseURL https://api.typesafe.ai/v1 API base. The request goes to <BaseURL>/systemone. OpenRouter (https://openrouter.ai/api/v1) serves the same wire.
Model jev-latest Model asked for. Decision.Model echoes what actually answered, which may be more specific.
APIKeyEnv TYPESAFE_API_KEY The name of the env var holding the credential, read at call time and never logged. Unlike §8’s APIKey, this option deliberately never takes a value.
Headers Extra request headers. Values expand ${ENV_VAR} at call time and are never logged.
Timeout 10 s Bounds one request — a classifier has no loop to bound.
HTTPClient http.DefaultClient Transport override, scoped to the classifier path only.
Retries 2 Retries on a transient failure. Backoff is RetryBaseMs * 2^attempt with no jitter; a Retry-After header wins over it.
RetryBaseMs 500 Base of that backoff, in ms. 0 ⇒ 500. The classifier’s half of the §8 ClientOptions.RetryBaseMs it mirrors — set it to 1 to take a retry test off the clock.
RetryableStatuses Extra HTTP statuses to treat as retryable. It adds only — it can never remove 429 and lose Retry-After handling with it.
OnError built-in Classifies one failed attempt into TierRetry or TierFail. Reuses §8’s ErrorInfo/Tier verbatim; there is no second retry policy and no suspend tier here.
RequestParams Extra top-level body keys, shallow-merged after the classifier builds its own body. A RequestParams key wins on collision.
BodyTransform Receives the assembled body after the merge and returns the body to send; returning nil leaves it unchanged. Order: base body → RequestParamsBodyTransform → marshal.
OnMetric The §8 metric sink. Emits MetricClassifierEvaluate per call and MetricClassifierWarning for degenerate criteria.
Client The §8 Client to emulate over. Required for StyleLLM.
Evaluate Your own function. Required for StyleCustom; every wire option is ignored.
Decisions The recorded corpus. StyleStatic only.

A style whose required option is missing is rejected by CreateClassifier, before any call is made — StyleLLM without Client, StyleCustom without Evaluate, an unknown style at all.

Style Where the answer comes from Calibrated
StyleSystemOne POST <BaseURL>/systemone — TypeSafe’s own API, or any gateway serving that wire true
StyleLLM One structured-output call on a §8 Client — the vendor-neutral exit when you have no System One credential false (self-reported numbers, not token probabilities)
StyleCustom Your Evaluate func whatever you return
StyleStatic A recorded corpus, matched on the canonical request and the state whatever was recorded

The trade-offs — and why static is a test contract rather than a convenience — are in Backends.

The classifier’s default retryable set is {408, 429, 500, 502, 503, 504, 529} — the §8 client set plus 408, which the classifier adds rather than inventing a second policy. Retry-After (delay-seconds) wins over the computed backoff. RetryableStatuses widens the set and never narrows it; OnError still runs per attempt and has the final say, so OnError returning TierFail overrides a status listed there. A cancelled ctx is never retried.

A 401/403 body is never echoed into an error, a log or a metric — a gateway happily reflects a bad Authorization header into its own error text.

1. The smallest useful call — one question, replayed offline

Section titled “1. The smallest useful call — one question, replayed offline”

StyleStatic needs no key and no network, so this is the whole surface in one file.

package main
import (
"context"
"fmt"
"log"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
state := "Ticket 4021: my card was charged twice and the second charge was not refunded."
questions := map[string]toolnexus.Question{
"wants_money_back": toolnexus.NoulQuestion{
Instructions: "Is the customer asking for money to be returned?",
},
}
recorded := `{"model":"typesafe/jev-1.13-20260917",
"answers":{"wants_money_back":{"type":"noul","noul":0.99}},
"usage":{"input_tokens":180,"output_tokens":8}}`
judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{
Style: toolnexus.StyleStatic,
Model: "typesafe/jev-1.13",
Decisions: []toolnexus.RecordedDecision{
{State: state, Questions: questions, Response: []byte(recorded)},
},
})
if err != nil {
log.Fatal(err)
}
d, err := judge.Evaluate(context.Background(), state, questions)
if err != nil {
log.Fatal(err)
}
want, err := d.Noul("wants_money_back")
if err != nil {
log.Fatal(err)
}
if want.Noul < 0.9 || !d.Calibrated {
log.Fatalf("unexpected decision: %+v", d)
}
fmt.Println("ok: wants_money_back =", want.Noul, "model:", d.Model)
}

2. The realistic case — your own backend, and the degenerate-criteria warning

Section titled “2. The realistic case — your own backend, and the degenerate-criteria warning”

StyleCustom hands the whole evaluation to you; every wire option is ignored. The client-side limits and the degenerate-criteria detection still run first, because they are properties of the questions, not of the transport.

package main
import (
"context"
"fmt"
"log"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
var warnings []string
judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{
Style: toolnexus.StyleCustom,
Evaluate: func(_ context.Context, _ any, _ map[string]toolnexus.Question) (toolnexus.Decision, error) {
return toolnexus.Decision{
Model: "house-rules-v1",
Answers: map[string]toolnexus.DecisionAnswer{
"desk": toolnexus.ChoiceAnswer{
Choice: "billing",
Probabilities: map[string]float64{"billing": 0.5, "technical": 0.5},
Confidence: 0.5,
},
},
Calibrated: false,
}, nil
},
OnMetric: func(ev toolnexus.MetricEvent) {
if ev.Event == toolnexus.MetricClassifierWarning {
warnings = append(warnings, ev.Question)
}
},
})
if err != nil {
log.Fatal(err)
}
// Every description is just its own option id — schema-valid, and it tells the
// model nothing. Detection, never repair: the request goes out byte-unchanged.
questions := map[string]toolnexus.Question{
"desk": toolnexus.ChoiceQuestion{
Instructions: "Which desk should own this ticket?",
Criteria: map[string]string{"billing": "billing", "technical": "technical"},
},
}
if _, err := judge.Evaluate(context.Background(), "charged twice", questions); err != nil {
log.Fatal(err)
}
// Once per question key per classifier, so a per-turn judge never floods the sink.
if _, err := judge.Evaluate(context.Background(), "charged twice", questions); err != nil {
log.Fatal(err)
}
if len(warnings) != 1 || warnings[0] != "desk" {
log.Fatalf("expected one warning naming the key, got %v", warnings)
}
fmt.Println("ok: warned once about question", warnings[0])
}

The warning travels in the event’s Warning field and Error stays empty — a consumer filtering the §8 sink on “has an error” must not count one. Why the library refuses to repair the criteria, and what a good encoding is worth in measured points, is in The encoding obligation.

3. The full surface — the systemone wire, against a local server

Section titled “3. The full surface — the systemone wire, against a local server”

BaseURL points anywhere that serves the wire, so a httptest server exercises the request shaping, the retry budget and the metric sink with no network and no credential.

package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"time"
"context"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
var seen map[string]any
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.Header().Set("Retry-After", "0") // delay-seconds wins over the backoff
w.WriteHeader(http.StatusTooManyRequests)
return
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &seen)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"model":"jev-1.13","calibrated":true,
"answers":{"risk":{"type":"score","score":1.21,
"legend":{"0":"harmless","1":"reversible","2":"destructive"},
"probabilities":{"0":0.10,"1":0.59,"2":0.31},"confidence":0.71}},
"usage":{"input_tokens":210,"output_tokens":24}}`))
}))
defer srv.Close()
judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{
Style: toolnexus.StyleSystemOne,
BaseURL: srv.URL,
Model: "jev-latest",
APIKeyEnv: "TYPESAFE_API_KEY", // the NAME of an env var, never the value
Timeout: 5 * time.Second,
Retries: 2,
RetryableStatuses: []int{520}, // adds only; 429 stays retryable
RequestParams: map[string]any{"tenant": "acme"},
BodyTransform: func(b map[string]any) map[string]any {
b["trace"] = "docs-example"
return b
},
OnError: func(info toolnexus.ErrorInfo) toolnexus.Tier {
if info.Retryable {
return toolnexus.TierRetry
}
return toolnexus.TierFail
},
OnMetric: func(ev toolnexus.MetricEvent) {
if ev.Event == toolnexus.MetricClassifierEvaluate {
fmt.Println("metric:", ev.Event, ev.Status)
}
},
})
if err != nil {
log.Fatal(err)
}
questions := map[string]toolnexus.Question{
"risk": toolnexus.ScoreQuestion{
Instructions: "How destructive is this shell command?",
Criteria: []string{"harmless", "reversible", "destructive"},
},
}
d, err := judge.Evaluate(context.Background(), "rm -rf ./build", questions)
if err != nil {
log.Fatal(err)
}
if calls != 2 {
log.Fatalf("expected the 429 to be retried once, got %d calls", calls)
}
if seen["tenant"] != "acme" || seen["trace"] != "docs-example" {
log.Fatalf("body shaping did not land: %v", seen)
}
risk, err := d.Score("risk")
if err != nil {
log.Fatal(err)
}
fmt.Printf("ok: risk=%v calibrated=%v after %d attempts\n", risk.Score, d.Calibrated, calls)
}
  • NoulQuestion — The three question types, the criteria each one needs, and the limits enforced client-side before the request.
  • Decision — One answer per question under the caller’s own keys, read through typed accessors that fail loudly rather than hand back a zero.
  • Typed decisions — what a judgment is, and when it beats both an if and a chat turn.
  • Backendssystemone, llm, custom, static, and what each one costs.
  • The encoding obligation — why option descriptions are the whole ball game.
  • Cookbook: typed decisions — a working judge in every language.