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. Break out of the loop and the run is aborted; a stream nobody iterates just runs to the end.
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 with status
errorand that same reason as their error (codeabortedortimeout), and the partial trace comes back as usual. - Stopping means stopping: calls still queued for a concurrency slot are never sent, a retry backoff wakes up and quits, and nothing new starts.
- Leaving a stream's
for awaitloop early (break,return, a throw) aborts the run the same way.s.resultresolves with status"aborted".
A failure inside a parallel cancels its sibling branches too; they close with a cancelled error whose cause is the real failure (see parallel). Your own code gets the signal as ctx.signal; pass it along to anything that can be cancelled. Calls a step makes through ctx.jev are cancelled with it already:
step("lookup", async (ticket: Ticket, ctx) => {
const res = await fetch(`/api/customers/${ticket.customerId}`, { signal: ctx.signal });
return res.json();
});