@fiale-plus/jev-cli
v0.2.0
Published
Unofficial CLI and TypeScript client for the TypeSafe System One API (Jev) — designed for AI agents
Maintainers
Readme
@fiale-plus/jev-cli
Unofficial CLI for the TypeSafe System One API (Jev) — designed for AI agents and anything that can execute a process.
Jev is TypeSafe's System One model: send state + typed questions, get structured answers (noul/choice/score with probabilities + confidence) your code can branch on. No text generation, no parsing.
Transport, retries, and types come from the official @typesafe-ai/sdk. This CLI adds the shell interface: explicit input, machine-readable output, and execution exit codes.
Why a CLI when SDKs exist?
- Shell composability — pipes,
jq, JSONL batch, documented exit codes - Question files as reviewable artifacts — one JSON file holds questions;
lintvalidates structure without spending API calls - Structured state —
--state-format jsonpreserves objects/arrays end to end - Cost visibility — every JSON response carries
usage+cost.estimated_usd(input billed at $42/Btok, output free)
Policy lives in the caller. A successful inference exits 0 even when answers are uncertain — probabilities and confidence are data on stdout, not authorization. jev gate runs that policy offline over a saved judgment, which is where accept/review/deny/abstain exit codes come from.
Quick Start
npm install -g @fiale-plus/jev-cli
export TYPESAFE_API_KEY=ts_...
jev noul "Does this convey urgency?" --state "Payouts failing 3 days, help!"
jev modelsGet a key at: https://console.typesafe.ai/settings/keys
Requires Node.js >= 22.0.0.
The decision pipeline
Inference and policy remain separate primitives. decide composes them when a caller wants one request, one inspectable envelope, and the existing gate exit codes:
jev decide --pack verify --state-file claim.json --state-format json --record decisions/claim-1.json
jev decide --pack-file .jev/claim-pack.json --state-file claim.json --format decisionJSON output distinguishes response, gate, provenance, and correlation IDs. --format decision prints only accept, review, deny, or abstain; diagnostics remain on stderr. decide never executes a follow-up command. ask and gate remain independently usable.
--pack-file loads one project-owned pack at the exact path supplied. It uses the same question/policy validation and content hash as bundled packs; there is no registry or implicit search.
For the lower-level workflow:
# 1. Judge — questions from a bundled pack, state from you, record for the audit trail
jev ask --pack verify --state-file claim.json --state-format json --record decisions/claim-1.json
# 2. Decide — offline, no API call, exit code is the decision
jev gate --input decisions/claim-1.json --pack verify; echo "exit $?"
# 3. Show your work — re-emit the stored answers without paying again
jev replay --record decisions/claim-1.json| jev gate exit | Decision | Caller action |
|---|---|---|
| 0 | accept | proceed |
| 2 | review | escalate to a human or a stronger check |
| 3 | deny | refuse |
| 4 | abstain | not enough information — never treated as acceptance |
deny and abstain are separate codes because remediation differs: one is a verdict, the other is a missing input. Every rule's reason is printed in both formats, so the answer to "why did this pass?" is on stdout.
CLI Usage
Single judgments
jev noul <instructions> --state <text> | --state-file <path> | --stdin
jev choice <instructions> --option name="desc" --option name2="desc" --state ...
jev score <instructions> --level "low" --level "high" --state ...Noul takes optional --true-means / --false-means criteria. Choice needs ≥2 --option entries (name="desc" or bare name). Score needs ≥2 --level entries, lowest first. Trailing positional args are joined as state. Add --state-format json when the state input is JSON.
Batch (ask)
One call, many questions over the same state — parallel, barely extra latency:
jev ask --state-file ticket.txt --questions pack.json
jev ask --state-file ticket.json --state-format json --questions pack.json
echo '{"state":"...","questions":{...}}' | jev ask --request -
jev lint --questions pack.json # validate without calling the APIpack.json maps caller-chosen IDs to questions. IDs are for your code only — never sent to the model, so write complete instructions:
{
"is_urgent": { "type": "noul", "instructions": "Does this convey urgency?" },
"dept": { "type": "choice", "instructions": "Which team handles this?",
"criteria": { "billing": "Payments", "technical": "Bugs" } },
"frustration": { "type": "score", "instructions": "How frustrated?",
"criteria": ["Calm", "Frustrated", "Angry"] }
}Speculative questions are welcome: ask branches that may not apply, then ignore irrelevant answers in code. Uncertainty on an unused branch must not fail the request.
Batch (batch)
Many independent requests from a JSONL file, bounded concurrency, one result record per input record in input order:
jev batch --request requests.jsonl --concurrency 8
jev batch --state-file states.jsonl --questions pack.jsonInput records: {"id": "row-1", "state": ..., "questions": {...}, "model": "jev-1.13.0"}. With --request, every record carries its own questions/model; with --state-file <jsonl> --questions pack.json [--model ...], records carry state (+id) and share them. Output is JSON lines: {"index","id","ok","response"|"error"}. Overall exit is 1 when any record fails — filter and retry failures in the caller.
Packs
A pack is a versioned question set plus the policy that decides when its answers permit proceeding:
jev packs # list bundled packs with their content hash
jev packs verify # dump one pack's questions, policy, and hash
jev lint --pack verify # validate it
jev ask --pack verify --state-file claim.json --state-format json
jev gate --input judgment.json --pack verify| Pack | Questions | Answers |
|---|---|---|
| verify | claim + cited evidence | supports / contradicts / says_nothing |
| screen | untrusted content | injection, harmful content, severity (plus substance and relevance for the caller) |
| route | incoming request | deterministic / specialist / human / none, plus complexity |
screen is advisory: it is a judgment layer, not a security boundary — keep it behind real sandboxing, not instead of it. Ranking candidates is not a pack because its options are dynamic: build the --option flags per call (see examples/README.md).
Each pack carries a state contract in its state_contract field, describing the fields its questions expect. Pack questions and policy are hashed together; gate prints the hash, and a record captures the hash it ran under, so a decision names the exact revision it used.
Gate policies
--policy <file> applies your own thresholds instead of a pack's:
{
"policy_version": 1,
"name": "verify-strict",
"mode": "all",
"rules": [
{ "answer": "relation", "type": "choice", "accept": ["supports"], "accept_at": 0.95, "review_at": 0.75 },
{ "answer": "injection", "type": "noul", "accept_when": "no", "accept_at": 0.9, "review_at": 0.7 },
{ "answer": "severity", "type": "score", "higher_is_worse": true, "accept_at": 0.5, "review_at": 1.5 }
]
}- Every rule names one answer and the condition that permits proceeding.
- A choice label outside
acceptnever accepts, however confident the model is; what can soften a denial intoreviewis probability mass sitting on an accepted label. - A choice answer must carry a probability map whose values sit in
[0, 1]and sum to 1 (tolerance 0.05). A map that is missing, off-label, or unnormalized abstains: confidence is a number about the whole answer, and substituting it for a per-label probability would let a malformed answer pass a threshold. - A score must fall inside its scale — the rule's
range, or the level indices the answer reports inlegend/probabilities. A score outside that scale abstains, soseverity: -100cannot glide pastaccept_at: 0.5. - A missing or wrong-typed answer abstains — it never accepts. Mark a rule
"optional": trueto skip it when absent. mode: "all"(default) takes the worst outcome in the orderdeny > abstain > review > accept;mode: "any"is the reverse.
Rules are validated on load: review_at above accept_at, a choice rule with no accepted label or a repeated one, an out-of-order range, or an unknown type fails loudly instead of silently never firing.
Pack identity
Gating a record against the pack that produced it is checked in two parts, because the two kinds of change mean different things:
| Change since the record was written | Result |
|---|---|
| questions changed | exit 1 — stored answers no longer mean what the current rules assume; re-run the request, or gate with --policy <file> if re-deciding is genuinely intended |
| thresholds changed only | the current policy applies, with a warning on stderr |
| identical | no note |
Both the pack hash and that comparison are printed in json and table output, so an accepted decision never looks unqualified.
Records and replay
--record <path> writes a decision record next to a normal response (noul/choice/score/ask):
{
"record_version": 1,
"created_at": "2026-09-18T10:00:00.000Z",
"cli_version": "0.1.2",
"model_requested": "jev-1.13.0",
"model_resolved": "jev-1.13.0",
"state_sha256": "sha256:...",
"questions_sha256": "sha256:...",
"latency_ms": 412,
"pack": { "name": "verify", "pack_version": 1, "hash": "sha256:..." },
"response": { "model": "...", "answers": { "...": {} }, "usage": { "...": "..." } }
}The state is hashed, not stored: a record can live beside a log without carrying the confidential text that produced the decision, while still committing to the exact input the CLI sent. The hash is type-tagged and versioned, so a text state cannot collide with the JSON state that parses to the same bytes. It is an unsigned commitment, not proof of what the model read: only the response, returned by the API under TLS, attests to that.
jev replay --record <file> prints those stored answers again with "replayed": true — no API call, for re-running downstream policy or comparing a stored decision against a fresh one. Replay is not a rerun: an answer is reproducible only while the model version stays pinned, which is why model_resolved is recorded. <path> is created along with any missing parent directories, before the request is sent, so a bad path cannot fail after you have paid for an answer.
Doctor
jev doctor # engine, key presence, base URL, model, packs — no API call
jev doctor --live # also authenticate, list models, and time a probeThe key is reported by presence and source, never by value. Exit is 1 when a check fails; warnings (no key, skipped live checks) do not fail the run.
Offline development
node tools/stub-server.mjs # deterministic, non-committal stub
export TYPESAFE_BASE_URL=http://127.0.0.1:8787 TYPESAFE_API_KEY=stub
jev ask --pack verify --state-file claim.json --state-format json --record out/claim.json
jev gate --input out/claim.json --pack verify; echo "exit $?"The stub answers the last option of a choice, the middle score, and noul 0.5, and it never claims the model you asked for: model_resolved comes back as stub:<requested> so a stub record is identifiable as one. Every bundled policy denies stub answers — a test asserts exactly that, through the real transport, for all three packs. It exists to test plumbing; never treat its output as a judgment, and never wire fabricated answers into a production path.
Scoring a policy on your data
jev ask --pack verify --state-file examples/verify/c1.json --state-format json --record out/c1.json
# ... one record per labeled example ...
npm run evaluate -- --records out/ --labels examples/verify/labels.jsonlIt reports label accuracy, the accept/review/deny/abstain mix, and the support curve: for each bucket, the share of cases whose label the policy really accepts, bucketed by the probability the policy treats as permission for the primary rule (accepted-label mass for a choice rule, the supportive probability for a noul). That is the curve accept_at sits on. Score rules are excluded from the curve because a score is not a probability. Evaluation stays a script over saved records; the CLI's inference path has no eval or threshold flags.
Models
jev modelsExit codes (execution status)
| Code | Meaning |
|------|---------|
| 0 | success — inference completed, answers on stdout; or gate accepted |
| 1 | usage, transport, or API error |
| 2 | gate: review |
| 3 | gate: deny |
| 4 | gate: abstain |
Common Options
| Flag | Description |
|------|-------------|
| --api-key <key> | API key (or TYPESAFE_API_KEY env var, required) |
| --model <id> | Model override (or TYPESAFE_DEFAULT_MODEL, default jev-latest) |
| --base-url <url> | API root (or TYPESAFE_BASE_URL) |
| --log-level <level> | SDK log level: debug|info|warn|error|off |
| --timeout <ms> | Request timeout in ms (SDK default 10000) |
| --retries <n> | Max retries, 0 disables (SDK default 2) |
| --concurrency <n> | Batch concurrency (default 4, max 32) |
| --pack <name> | Bundled questions (ask) or bundled policy (gate, lint) |
| --policy <file> | Gate policy file |
| --input <file> | Saved judgment or record for gate |
| --record <path> | Write a decision record (noul/choice/score/ask) |
| --live | doctor: also authenticate, list models, and probe |
| -f, --format <json\|table> | Output format (default json) |
| --help | Show help |
| --version | Show version |
Composition
input producer → jev ask (pack) → record → jev gate → authorized action
↘ jev replay / evaluate- A citation tool assembles claims and evidence, calls
jev ask --pack verify, then reads the gate exit code. - An agent gate screens third-party content with
--pack screenand routes work with--pack routebefore spending a call on a specialist. - An evaluation tool joins records with labels and computes metrics (
tools/evaluate.mjs).
Question design guidance lives in the official skill. Calibration runs over saved records, not inside inference: the CLI deliberately has no eval or threshold flags on the model-calling path.
Library Usage
import { TypeSafeClient, choice, noul } from "@fiale-plus/jev-cli";
const jev = new TypeSafeClient({ apiKey: process.env.TYPESAFE_API_KEY! });
const response = await jev.systemOne({
state: "Payouts failing 3 days, help!",
questions: {
is_urgent: noul("Does this convey urgency?"),
dept: choice("Which team handles this?", { billing: "Payments", technical: "Bugs" }),
},
});
console.log(response.answers.is_urgent); // { type: "noul", noul: 0.98 }
console.log(response.usage); // { input_tokens, output_tokens }The package re-exports the official SDK client, builders, and error types, plus the offline decision layer:
import { TypeSafeClient, choice, noul } from "@fiale-plus/jev-cli";
const client = new TypeSafeClient({ apiKey: process.env.TYPESAFE_API_KEY! });
const response = await client.systemOne({
state: "Payouts failing 3 days, help!",
questions: {
is_urgent: noul("Does this convey urgency?"),
dept: choice("Which team handles this?", { billing: "Payments", technical: "Bugs" }),
},
});
console.log(response.answers.is_urgent); // { type: "noul", noul: 0.98 }
console.log(response.usage); // { input_tokens, output_tokens }Reuse one client across calls, own your state and elapsed time, and never apply a policy to a failed request:
import { evaluatePolicy, loadPack, readRecord, buildRecord } from "@fiale-plus/jev-cli";
const { pack } = loadPack("verify");
const started = Date.now();
const response = await client.systemOne({ state: claim, questions: pack.questions });
const record = buildRecord({ pack: null, modelRequested: undefined, state: claim, questions: pack.questions, latencyMs: Date.now() - started }, response);readRecord is the validated boundary for stored records — it returns a checked DecisionRecord or throws — while isRecord only detects the envelope. extractResponse accepts either a bare response or a decision record, so gating code does not care which one it was handed:
const stored = readRecord(JSON.parse(savedText));
const result = evaluatePolicy(pack.policy, stored.response);
if (result.decision !== "accept") process.exit(result.exit_code);A complete runnable version lives at examples/library/record-and-gate.mts. GATE_EXIT maps a decision to its exit code.
Development
git clone https://github.com/fiale-plus/jev-cli.git
cd jev-cli
npm install
npm run build
npm test
# Dev mode (runs TypeScript directly)
npm run dev -- models
npm run dev -- noul "Urgent?" --state "help, failing!"
# Offline end-to-end: stub server + packs + gate + evaluation
npm run stub &
TYPESAFE_BASE_URL=http://127.0.0.1:8787 TYPESAFE_API_KEY=stub npm run dev -- ask --pack verify --state-file examples/verify/c1.json --state-format json
# Integration tests (offline; no key needed)
npm run test:integrationpacks/ ships with the package; examples/ holds inputs and labels only — no recorded model output is committed, because fabricated answers presented as real ones are worse than no examples at all.
Disclaimer
This is an unofficial, community-maintained tool. It is not affiliated with, endorsed by, or connected to TypeSafe.
Typed output guarantees the interface, not the truth — validate thresholds and accuracy on your own data. Start from the confidence guide and confidence-routing pattern.
