A Go client for the TypeSafe System One API and its model, Jev.
Jev does not write text. You send a state (a string, or JSON such as a ticket, a log, or the current state of a program) and one or more named questions. The call returns a typed answer and a probability for every question. Adding questions to the same call does not add a round trip. Since Jev 1.14 the state can also hold a picture, and a box question says where in it one thing is.
This module is not affiliated with TypeSafe AI. The official clients are the JavaScript SDK and the Python SDK. The HTTP contract is the System One API.
Go 1.27 or newer. The only dependency is golang.org/x/time/rate.
go get github.com/kataras/jevPlease star this open source project to attract more developers so that together we can improve it even more!
| Question | You provide | You get back |
|---|---|---|
| Noul | A yes/no question | Probability of yes, from 0 to 1 |
| Choice | Named options, up to 255 | The picked option, a probability for each option, and a confidence |
| Score | An ordered list of levels, up to 10 | A position on that list (it can fall between levels), a probability for each level, and a confidence |
| Box | What to find, and in which image | One bounding box, as fractions of the image, and a confidence |
Question names are yours. They come back as the answer keys. They are not part of the prompt.
jev-latest is the default model, and that alias moves. Jev 1.14.0 came out on 2026-10-14; jev-latest still points at jev-1.13.0 and moves about a week after a release, while jev-preview already points at jev-1.14.0. The constants jev.ModelLatest, jev.ModelPreview, jev.ModelJev114, and jev.ModelJev113 hold those names. Set WithModel(jev.ModelJev114) when a later run has to hit the same model. Box questions need Jev 1.14, so pass WithModel(jev.ModelPreview) for them until jev-latest catches up. The model field of every response is the versioned id that answered. See aliases.
client, err := jev.New() // reads TYPESAFE_API_KEY
resp, err := client.SystemOne(ctx, jev.Request{
State: "I was charged twice. Please fix this ASAP.",
Questions: jev.Questions{
"billing": jev.Noul{Instructions: "Is this ticket about billing?"},
"team": jev.Choice{
Instructions: "Which team should handle this?",
Criteria: map[string]any{
"billing": "Payments, invoices, refunds",
"technical": nil, // null means the label has no description
},
},
"urgency": jev.Score{
Instructions: "How urgent is this ticket?",
Criteria: []string{"can wait", "this week", "today"},
},
},
})
billing, _ := resp.Noul("billing")
team, _ := resp.Choice("team")
urgency, _ := resp.Score("urgency")SystemOneAs decodes the same JSON into a struct. It is a generic method, which is why this module requires Go 1.27. JSON names are case-sensitive.
type ticket struct {
Model string `json:"model"`
Answers struct {
Billing struct {
Noul float64 `json:"noul"`
} `json:"billing"`
} `json:"answers"`
}
out, err := client.SystemOneAs[ticket](ctx, req)A full program lives in examples/quickstart.
Noul, Choice, Score, and Box each send one question named answer through SystemOne. Classify is Choice with the question and the options as separate arguments. Rate is Score with the levels as a list. Box takes the instructions alone or a jev.Box. The As methods call SystemOneAs. In that JSON the question name is answer.
yes, err := client.Noul(ctx, ticket, "Is this about billing?")
team, err := client.Classify(ctx, ticket, "Which team?", map[string]any{
"billing": "Payments, invoices, refunds",
"technical": nil,
})
urgency, err := client.Rate(ctx, ticket, "How urgent?", []string{"can wait", "this week", "today"})
box, err := client.Box(ctx, map[string]any{"photo": photo}, "the coffee mug in `photo`", jev.WithModel(jev.ModelPreview))Several questions about the same state still belong in one SystemOne call.
List the models on the account with ListModels. The list includes the jev-latest and jev-preview aliases.
models, err := client.ListModels(ctx)Jev 1.13 and 1.14 both look at PNG and JPEG images. Wrap the file with ImageFile, ImageBytes, or ImageReader and put the *Image anywhere a string can go: a value in the state, a question's instructions, a Choice option, a struct field, or a slice element. The model reads a reference string in its place, such as img-3f9a1c2b7d4e6f80-kitchen, and the bytes travel with the request. Box finds one thing in an image. It needs Jev 1.14, so pass WithModel(jev.ModelPreview) until jev-latest moves there.
photo, err := jev.ImageFile("kitchen.jpg")
client, err := jev.New(jev.WithModel(jev.ModelPreview))
resp, err := client.SystemOne(ctx, jev.Request{
State: map[string]any{"photo": photo},
Questions: jev.Questions{
"mug_visible": jev.Noul{Instructions: "Is there a coffee mug in `photo`?"},
"mug": jev.Box{Instructions: "the coffee mug in `photo`"},
},
})
mug, ok := resp.Box("mug")
fmt.Println(mug.Confidence, mug.Rect(width, height))BoxAnswer holds Top, Bottom, Left, and Right as fractions from 0 to 1, measured from the top-left corner, and a Confidence. Rect(width, height) turns the edges into a stdlib image.Rectangle for cropping. A box always comes back, even when the thing is not in the picture, so ask a Noul in the same call when it might be missing. Box has no criteria field.
To mention an image inside a sentence, build the text with TextOf. The image part becomes its reference:
jev.Noul{Instructions: jev.TextOf("Is the dog in `photo` the same dog as in this picture: ", poster, "?")}The client reads the file signature and accepts PNG and JPEG only; convert GIF, WebP, and HEIC first. One image may be 16 MiB and the whole request 32 MiB, and both are checked before sending. The API also asks for 280 to 1568 pixels per side, which the client does not check. The same image used twice is uploaded once.
The header TypeSafe-Danger-Zone-Images: true is added whenever a request carries an image or a box question; without it the API answers 400. Three limits: a nil *Image encodes as JSON null, an image inside pre-encoded jsontext.Value state is not seen, and an image in Request.Extra is rejected. The rate limiter does not count image bytes as tokens. It reserves a flat 1,600 tokens per image before the call and settles with the real usage after it.
A program that draws a rectangle and asks where it is lives in examples/box.
Explicit options win over environment variables. A blank environment value is ignored.
| Option | Environment | Default |
|---|---|---|
WithAPIKey |
TYPESAFE_API_KEY |
required |
WithBaseURL |
TYPESAFE_BASE_URL |
https://api.typesafe.ai |
WithModel |
TYPESAFE_DEFAULT_MODEL |
jev-latest |
WithLongContext |
off | |
WithTimeout |
10s per attempt | |
WithRetry |
2 retries, 500ms to 5s backoff | |
WithRateLimit |
1,200 requests/min, 250,000 tokens/s | |
WithRateLimiter |
the limiter WithRateLimit builds |
|
WithLogger |
TYPESAFE_LOG_LEVEL |
no logging |
WithHTTPClient |
a client that does not follow redirects | |
WithHeader |
The same options can be passed to one call. They override the client for that call only. WithRateLimit is the exception: it belongs to New, because a limiter built for one call starts with a full budget and paces nothing. For one call, pass WithRateLimiter with a limiter you own, or nil to skip pacing.
WithRetry replaces the whole policy. Copy DefaultRetryPolicy() and edit the copy. A zero RetryPolicy retries nothing, because its status list is empty.
WithLongContext(true) raises the input limit from 64k tokens per request and 32k per question to 262k, by sending TypeSafe-Danger-Zone-Long-Context: true. The price per token is the same, but the API documents long context as slower and less accurate, so turn it on for the calls that need it. Jev 1.13 and 1.14 both support it. See context length.
yes, err := client.Noul(ctx, longContract, "Does the `state` include a termination clause?", jev.WithLongContext(true))The API limits each account to 1,200 requests per minute and 250,000 input tokens per second. A request over either limit is a 429. Retrying after a 429 works, but it costs a round trip and a backoff wait each time. The client stays under the limit instead: every attempt, retries included, first waits on a token bucket built with golang.org/x/time/rate, the same way kataras/httpclient paces its requests.
Requests are spread across the minute, with a burst of one second's worth (20 at the default). Tokens are estimated from the body size before the call and settled with usage.input_tokens after it, so the estimate corrects itself. When the server does answer 429 or 529 with Retry-After, every caller on that limiter pauses until then, not only the call that saw it. The pause is capped by RetryPolicy.MaxRetryAfter, 60s by default.
The limits are per account, so clients on the same key should share one limiter:
shared := jev.NewLimiter(jev.DefaultRateLimit())
latest, err := jev.New(jev.WithRateLimiter(shared))
pinned, err := jev.New(jev.WithModel(jev.ModelJev114), jev.WithRateLimiter(shared))
// or join an existing client's budget:
third, err := jev.New(jev.WithRateLimiter(latest.Limiter()))TypeSafe adjusts the limits without notice, and enterprise plans get higher ones. Set your own figures, or divide the account's budget between processes:
client, err := jev.New(jev.WithRateLimit(jev.RateLimit{
RequestsPerMinute: 300,
TokensPerSecond: 60_000,
}))A zero field disables that axis. jev.WithRateLimit(jev.RateLimit{}) or jev.WithRateLimiter(nil) turns pacing off. A plain *rate.Limiter also satisfies jev.Limiter; it paces requests only.
The limiter waits are not retries. A wait that outlives your context returns that context's error, and no request is sent.
if errors.Is(err, context.Canceled) {
// the caller cancelled; this is not retried
}
if errors.Is(err, jev.ErrUnauthorized) {
// HTTP 401
}
if errors.Is(err, jev.ErrRateLimited) {
// HTTP 429, after retries
}
if errors.Is(err, jev.ErrOverloaded) {
// HTTP 529; this also matches jev.ErrServer
}
var api *jev.APIError
if errors.As(err, &api) {
fmt.Println(api.Status, api.RequestID, api.Message)
}| Sentinel | When |
|---|---|
ErrConfig |
Missing key, bad URL, bad option |
ErrRequest |
Rejected locally, before HTTP |
ErrResponse |
2xx body that does not match the contract |
ErrTimeout |
The per-attempt timeout fired |
ErrConnection |
DNS, TLS, or a dropped connection |
ErrBadRequest |
HTTP 400 |
ErrUnauthorized |
HTTP 401 |
ErrForbidden |
HTTP 403 |
ErrNotFound |
HTTP 404 |
ErrUnprocessable |
HTTP 422 |
ErrRateLimited |
HTTP 429 |
ErrOverloaded |
HTTP 529 |
ErrServer |
HTTP 5xx, including 529 |
A deadline on the context you passed is returned as that context error. It does not match ErrTimeout. ErrTimeout means this client's own per-attempt limit.
The default policy matches the official JavaScript SDK. It retries HTTP 408, 429, and 500 through 599, including 529. The first wait is 500ms, doubled up to 5s, with up to 25% of the wait subtracted at random. Retry-After-Ms is preferred over Retry-After. A server delay longer than 60s falls back to that backoff. Retries are the second line; the rate limiter is the first.
There is no budget across attempts unless you set RetryPolicy.MaxElapsed. The context you pass always applies. Cancellation during a wait stops the call and is not retried.
POST is retried because a System One call only evaluates. Each attempt still spends input tokens.
TYPESAFE_LOG_LEVEL may be debug, info, warn, error, or off. Info logs one line per attempt. Debug also logs headers and bodies. Authorization, Cookie, and X-Api-Key are replaced with [redacted]. Bodies are logged as sent, and they contain your state. Image bytes are the exception: the JSON part of a multipart request is logged, the file parts are not.
A few choices differ from other Go clients, on purpose:
- JSON is encoded with
encoding/json/v2, and<,>, and&are left as themselves.encoding/jsonrewrites them to\u003c,\u003e, and\u0026, so the text the model reads is not the text you passed. - Object keys are sorted. The same request produces the same bytes.
Request.Extracannot replacestate,model,questions, orassets.- A
[]bytestate is sent as a JSON string. A plain byte slice would otherwise be base64. - Score criteria must be a JSON array. A map is rejected. That matches the official SDK change in v0.6.0 (15 Sep 2026).
- Choice criteria must be a JSON object, with at most 255 options.
- A request that carries an image goes out as
multipart/form-data: first a part namedrequestwith the JSON, which gains a top-levelassetsmap, then one file part per image, named by its reference, withContent-Typeimage/pngorimage/jpeg. Every other request isapplication/json. - Two headers are the client's job.
TypeSafe-Danger-Zone-Images: truegoes with any image or box question.TypeSafe-Danger-Zone-Long-Context: truegoes withWithLongContext(true).
TestLiveSystemOne, TestLiveBox, and TestLiveModels call the real API and spend tokens. TestLiveBox draws a red rectangle on a PNG and asks jev-preview where it is. go test skips all three when TYPESAFE_API_KEY is unset or blank, and always under go test -short. Every other test runs against an in-process server and needs no key.
For GitHub Actions, add a repository secret named TYPESAFE_API_KEY. The workflow runs the unit tests without the key, then runs -run '^TestLive' only when the secret is present. Pull requests from forks do not receive secrets, so there the live step prints a note and passes. Do not commit a key.
skills/jev/SKILL.md is an Agent Skill for this module. The signatures in it match the Go API above. Claude Code, Cursor, Codex, and Plexon all read that format.
Install it for every agent this machine already has, including Plexon (it adopts ~/.agents/skills):
npx skills add kataras/jev --skill jev -g -yOne project only: drop -g. The files land in .agents/skills/jev or .claude/skills/jev. Plexon reads both.
Pick agents by name when you do not want the full set:
npx skills add kataras/jev --skill jev -g -y -a claude-code -a cursor -a codexClaude Code can install the same skill as a plugin. Inside a Claude Code session:
/plugin marketplace add kataras/jev
/plugin install jev@kataras-jev
MIT.