skip to content
docs / running & inspecting

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.

jev.ts
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
});
createJev options (all optional)
apiKeystring | nulldefault envDefaults 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.
baseURLstringdefault api.typesafe.aiAPI root. In the browser, point it at your own proxy.
pathstringdefault "/v1/systemone"Appended to baseURL. Set "" when baseURL is the full endpoint.
modelstringdefault "jev-latest"Default model for every call. Nodes can override it with their own model.
timeoutMsnumberdefault 10_000Per attempt, including reading the body. A timed-out attempt is retried.
retryPartial<RetryPolicy>default see belowmaxRetries 2, initialDelayMs 250 (doubling), maxDelayMs 4000, jitter 0.25, maxRetryAfterMs 30000.
maxConcurrencynumberdefault 8HTTP requests in flight at once, across every run on this client.
batchboolean | BatchOptionsdefault trueMerge same-state asks into one request. windowMs (default 0: same tick) and maxQuestions (default 64). false turns it off.
usdPerMillionTokensnumberdefault 0.042Price used for costUsd in traces (jev-1.13 list price, input tokens; output is free).
headersRecord<string, string>Extra headers on every request.
fetchtypeof fetchdefault global fetchBring 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.

desk.test.ts
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 network

run#

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.

run options
signalAbortSignalCancel the run. See cancellation.
timeoutMsnumberA deadline for the whole run (separate from the per-attempt timeout).
runIdstringdefault randomSet your own id, e.g. a request id, so traces join up with your logs.
onEvent(event) => voidEvery trace event as it happens. stream is built on this.
maxTraceStringnumberdefault 4000Longest 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".

handle.ts
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
"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.

stream.ts
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;
TraceEvent types, in the order you'll see them
run:startrunId, chainId, startedAt, inputExactly once, first.
span:startat, spanA node started. The span has its path, parent, edge, id, kind and input.
jev:callpath, callA Jev call came back, with its full answers, tokens, cost and latency.
retrypath, retryA Jev call or a step attempt failed and will be retried.
logpath, logYour step called ctx.log.
decisionpath, decisionA route, gate or cascade chose an edge.
span:endat, path, status, output?, error?A node finished.
run:endtraceExactly 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.

live.ts
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")
}
▶ run itHaunted Appliance Support Deskgallerysource →
Run it in the studio and watch the events arrive: span:start on the front desk, a jev:call, a decision, and on down the branch it picked.
route · front-deskrouteFront deskemit · book-technicianemitbook-technicianemit · forward-billingemitforward-billinggate · anyone-in-dangergateAnyone in danger?route · classify-entityrouteWhat are we dealing with?emit · book-exorcistemitbook-exorcistemit · power-cycleemitpower-cycleemit · close-windowemitclose-windowemit · evacuateemitevacuateemit · ask-daveemitask-daverepairbillingpoltergeistpossessed-firmwarejust-a-draftthenotherwiseparanormalunsure

My toaster whispers my name at 3am and the bread comes out cold.

open in studio →

Cancellation and deadlines#

Two ways to stop a run early, and they report differently:

cancel.ts
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 (timeoutMs on run) cancels the same way, but the reason is a JevTimeoutError, so the status is "error" with error.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:

lookup.ts
step("lookup", async (ticket: Ticket, ctx) => {
  const res = await fetch(`/api/customers/${ticket.customerId}`, { signal: ctx.signal });
  return res.json();
});

api key

bring your own typesafe key, or ride the shared one (rate-limited, be nice).

shared key
checking…
your key
not set

your key stays in this browser (localStorage, jevchain.byok). it only travels to this site's /api/jev proxy in an x-typesafe-key header, which forwards it to typesafe and immediately forgets it. nothing is logged or stored server-side. requests on your own key get a much roomier rate limit.

keyboard shortcuts

fewer clicks, more chains. these work anywhere outside a text field.