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. Break out of the loop and the run is aborted; a stream nobody iterates just runs to the end.

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 with status error and that same reason as their error (code aborted or timeout), 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 await loop early (break, return, a throw) aborts the run the same way. s.result resolves 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:

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.