skip to content
docs / building blocks

step & emit()

Your code as a node, and constant or templated leaves.

step#

step(id, fn, options?) is your code as a node: transform the input, call a tool, hit a database, ask an LLM. It gets the node's input and returns its output (sync or async), and both types are read straight off your function's signature. That's what lets chain check the whole pipeline.

steps.ts
import { step } from "jevchain";

// Types come from your function: StepNode<Message, User>.
const lookup = step("lookup-user", async (msg: Message) => db.users.find(msg.userId));

// Sync is fine too. Whatever you return is the node's output.
const shout = step("shout", (s: string) => s.toUpperCase());

Steps don't call Jev, so they cost nothing but your own time. They appear in the trace like any other span, with their input, output, logs and retries.

StepContext#

The second argument to every step function (and to parallel joins) is a context with everything the run knows:

StepContext
runInputunknownThe input the whole run started with, however deep you are.
resultsRecord<string, unknown>Outputs of every node that has finished so far, keyed by node id. Reused ids overwrite each other.
signalAbortSignalAborted when the run is cancelled, times out, or this attempt times out. Pass it to fetch.
jevJevClientThe client running this chain, for ad-hoc jev.ask(state, questions) calls.
log(message, data?) => voidAttach a note, with optional JSON data, to this node's span in the trace.
enrich.ts
const enrich = step("enrich", async (ticket: Ticket, ctx) => {
  const triage = ctx.results["triage"];          // output of the node with id "triage"
  ctx.log("looking up customer", { id: ticket.customerId });

  // Pass the signal on, so aborts and deadlines reach your I/O.
  const res = await fetch(`/customers/${ticket.customerId}`, { signal: ctx.signal });
  return { ...ticket, customer: await res.json(), triage };
});

Retries and timeouts#

Steps talk to the outside world, and the outside world flakes. Two options handle it:

step(id, fn, options)
timeoutMsnumberdefault noneLimit for one attempt. On timeout the attempt's signal aborts and a JevTimeoutError is thrown (and retried, if you allow it).
retriesnumberdefault 0Extra attempts after the first when fn throws. Backoff is min(2000, 100 × 2^(attempt−1)) ms: 100, 200, 400… Never retried once the run is aborted.
refstringdefault the idThe name used to re-attach fn when loading from JSON. See fromJSON.
title / descriptionstringFor UIs and the graph.
weather.ts
const fetchWeather = step("fetch-weather", async (city: string, ctx) => {
  const res = await fetch(`https://wttr.in/${city}?format=j1`, { signal: ctx.signal });
  if (!res.ok) throw new Error(`weather service said ${res.status}`);
  return res.json();
}, { timeoutMs: _000, retries: 2 });

Every retry is recorded on the span (attempt, delay and the error), so “why was this slow” has an answer. The Errors page has a step that fails on purpose.

emit#

emit(value, options?) outputs a fixed JSON value. It's how most routes end: each branch emits a verdict, a team name or a canned reply. Strings are templates over the node's input.

leaves.ts
emit("page on-call")                         // EmitNode<string>
emit({ team: "billing", priority: 2 })       // EmitNode<{ readonly team: "billing"; readonly priority: 2 }>
emit("Booked for {{input.name}}.", { id: "book" })   // give leaves ids: they show up in traces

Templates#

emit strings and every state option (on ask, route, gate and cascade tiers) take the same tiny template language: {{path}} holes. Paths are resolved against three roots:

  • {{input…}}: the node's own input. {{input.user.name}}, {{input.items.0}}.
  • {{run…}}: the input the whole run started with.
  • {{results.<nodeId>…}}: the output of an earlier node.
given this input
{ "name": "Mo", "tags": ["karaoke", "chaos"],
  "user": { "plan": "pro" } }
templates render as
"hi {{input.name}}"        → "hi Mo"
"{{input.tags.0}} fan"      → "karaoke fan"
"plan: {{input.user}}"      → 'plan: {"plan":"pro"}'
"{{input.user}}"            → { plan: "pro" }   (raw value)
"{{input.nope}}!"           → "!"
  • A template that is exactly one hole returns the raw value, so objects and arrays survive as structured state.
  • Holes inside text stringify objects as JSON; missing values render as an empty string.
  • No expressions, no function calls, no eval. Just paths. Need logic? That's what step is for, and state also takes a function: state: (t) => t.subject.
▶ run itName tag printerdocs chainchains.ts ↗
A step splits a bio into a name, a route asks about the bio only (via state), templated emits print the tag, and a last step logs to the trace with ctx.log.
chains.ts
// step: your code. Input and output types flow from the function signature.
const normalize = step("normalize", (raw: string) => ({
  name: raw.trim().split(/\s+/)[0] ?? "friend",
  bio: raw.trim(),
}));

const greet = route("greeting", {
  ask: choice("What energy does this person bring?", ["chaotic", "calm"]),
  state: "{{input.bio}}", // ask about the bio only, not the whole object
  branches: {
    // emit: a constant, or a template over the node's input.
    chaotic: emit("HELLO MY NAME IS {{input.name}} 🎉"),
    calm: emit("hello, my name is {{input.name}}."),
  },
});

const print = step("print", (tag: string, ctx) => {
  ctx.log("printed a name tag", { chars: tag.length });
  return { tag, original: ctx.runInput as string };
});

const nameTag = chain("name-tag-printer", normalize, greet, print);
step · normalizestepnormalizeroute · greetingroutegreetingemit · emitemitemitemit · emitemitemitstep · printstepprintchaoticcalm

Mo — brought a karaoke machine to the offsite, unprompted

open in studio →

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.