Running.
Create a client, run a chain, stream its events. Runs don't throw; they report.
createJev#
createJev(options) makes a client with run and stream attached. Make one per process and share it: the concurrency limit and the batching queue live on the client.
import { createJev } from "jevchain";
const jev = createJev({
model: "jev-1.13.0", // pin a version instead of jev-latest
timeoutMs: _000, // per attempt
retry: { maxRetries: 3 }, // merged over the defaults
maxConcurrency: 16,
batch: { windowMs: 5 }, // wait 5ms for siblings before sending
});| name | type | default | what it does |
|---|---|---|---|
| apiKey | string | null | default env | Defaults to process.env.TYPESAFE_API_KEY (or JEV_API_KEY) where there is a process. Pass null when a proxy adds the key for you. |
| baseURL | string | default api.typesafe.ai | API root. In the browser, point it at your own proxy. |
| path | string | default "/v1/systemone" | Appended to baseURL. Set "" when baseURL is the full endpoint. |
| model | string | default "jev-latest" | Default model for every call. Nodes can override it with their own model. |
| timeoutMs | number | default 10_000 | Per attempt, including reading the body. A timed-out attempt is retried. |
| retry | Partial<RetryPolicy> | default see below | maxRetries 2, initialDelayMs 250 (doubling), maxDelayMs 4000, jitter 0.25, maxRetryAfterMs 30000. |
| maxConcurrency | number | default 8 | HTTP requests in flight at once, across every run on this client. |
| batch | boolean | BatchOptions | default true | Merge same-state asks into one request. windowMs (default 0: same tick) and maxQuestions (default 64). false turns it off. |
| usdPerMillionTokens | number | default 0.042 | Price used for costUsd in traces (jev-1.13 list price, input tokens; output is free). |
| headers | Record<string, string> | Extra headers on every request. | |
| fetch | typeof fetch | default global fetch | Bring your own: for tests, instrumentation, or adding headers per request. |
Retries cover timeouts, connection failures and HTTP 408, 409, 429, 500, 502, 503, 504 and 529. On a 429 the client honours retry-after (or retry-after-ms) up to maxRetryAfterMs; otherwise it backs off exponentially with jitter. Every retry lands in the trace.
import { createJev, type JevClient } from "jevchain";
const fake: JevClient = {
model: "jev-latest",
usdPerMillionTokens: 0.042,
async ask(state, questions) {
return { answers: cannedAnswersFor(questions), model: "jev-1.13.0",
usage: { inputTokens: 0, outputTokens: 0 }, costUsd: 0, latencyMs: 1, attempts: 1 };
},
};
const jev = createJev(fake); // run() and stream() work as usual, no networkrun#
jev.run(chain, input, options?) runs to completion and resolves with the output and the trace. The input is type-checked against the chain's input type.
| name | type | default | what it does |
|---|---|---|---|
| signal | AbortSignal | Cancel the run. See cancellation. | |
| timeoutMs | number | A deadline for the whole run (separate from the per-attempt timeout). | |
| runId | string | default random | Set your own id, e.g. a request id, so traces join up with your logs. |
| onEvent | (event) => void | Every trace event as it happens. stream is built on this. | |
| maxTraceString | number | default 4000 | Longest string kept in trace inputs and outputs before truncating. |
Statuses#
run doesn't throw for things that go wrong at runtime. Network down, key revoked, your step exploded: you get a result with a status and the trace up to that point. RunResult is a discriminated union, so TypeScript knows output only exists when status is "ok".
const result = await jev.run(desk, "My toaster whispers my name at 3am.");
switch (result.status) {
case "ok":
reply(result.output); // typed as the chain's output
break;
case "halted":
log(result.trace.halted?.summary);
break;
case "error":
case "aborted":
alert(result.error.code); // a JevChainError, never undefined here
break;
}
save(result.trace); // always there, whatever happened| name | type | what it does |
|---|---|---|
| "ok" | { output, trace } | It finished. output is typed. |
| "halted" | { trace } | A gate without otherwise said no. Not a failure; trace.halted says where and why. |
| "error" | { trace, error } | Something failed. error is a JevChainError, usually a NodeError naming the node. |
| "aborted" | { trace, error } | Your AbortSignal fired. error.code is "aborted". |
Streaming#
jev.stream(chain, input, options?) starts the same run and hands you its events as an async iterable. s.result resolves to the same RunResult run would have returned.
const s = jev.stream(desk, ticket);
for await (const event of s) {
if (event.type === "decision") console.log(event.path, event.decision.summary);
}
const { status, trace } = await s.result;| name | type | what it does |
|---|---|---|
| run:start | runId, chainId, startedAt, input | Exactly once, first. |
| span:start | at, span | A node started. The span has its path, parent, edge, id, kind and input. |
| jev:call | path, call | A Jev call came back, with its full answers, tokens, cost and latency. |
| retry | path, retry | A Jev call or a step attempt failed and will be retried. |
| log | path, log | Your step called ctx.log. |
| decision | path, decision | A route, gate or cascade chose an edge. |
| span:end | at, path, status, output?, error? | A node finished. |
| run:end | trace | Exactly once, last, carrying the final trace. |
To keep a live view, fold events with reduceTrace. The runtime builds its own trace with this same function, so what you render mid-run and the trace you store at the end can't disagree. traceFromEvents(events) does the whole fold at once, handy for replaying a recorded event log.
import { reduceTrace, type Trace } from "jevchain";
let trace: Trace | undefined;
for await (const event of jev.stream(desk, ticket)) {
trace = reduceTrace(trace, event); // pure: returns a new object, safe for React state
render(trace); // partial traces are valid traces (status "running")
}span:start on the front desk, a jev:call, a decision, and on down the branch it picked.My toaster whispers my name at 3am and the bread comes out cold.
Cancellation and deadlines#
Two ways to stop a run early, and they report differently:
const controller = new AbortController();
stopButton.onclick = () => controller.abort();
const result = await jev.run(desk, ticket, {
signal: controller.signal, // abort → status "aborted"
timeoutMs: _000, // deadline for the whole run → status "error", code "timeout"
});- Your signal aborts in-flight requests, sleeps between retries and queued batches, and the run ends with status
"aborted". - The run deadline (
timeoutMsonrun) cancels the same way, but the reason is aJevTimeoutError, so the status is"error"witherror.code === "timeout". You asked for an answer by a time; not getting one is a failure. - Either way, spans still running are closed as cancelled, and the partial trace comes back as usual.
A failure inside a parallel cancels its sibling branches too. Your own code gets the signal as ctx.signal; pass it along to anything that can be cancelled:
step("lookup", async (ticket: Ticket, ctx) => {
const res = await fetch(`/api/customers/${ticket.customerId}`, { signal: ctx.signal });
return res.json();
});