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.
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:
| name | type | what it does |
|---|---|---|
| runInput | unknown | The input the whole run started with, however deep you are. |
| results | Record<string, unknown> | Outputs of every node that has finished so far, keyed by node id. Reused ids overwrite each other. |
| signal | AbortSignal | Aborted when the run is cancelled, times out, or this attempt times out. Pass it to fetch. |
| jev | JevClient | The client running this chain, for ad-hoc jev.ask(state, questions) calls. |
| log | (message, data?) => void | Attach a note, with optional JSON data, to this node's span in the trace. |
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:
| name | type | default | what it does |
|---|---|---|---|
| timeoutMs | number | default none | Limit for one attempt. On timeout the attempt's signal aborts and a JevTimeoutError is thrown (and retried, if you allow it). |
| retries | number | default 0 | Extra 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. |
| ref | string | default the id | The name used to re-attach fn when loading from JSON. See fromJSON. |
| title / description | string | For UIs and the graph. |
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.
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 tracesTemplates#
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.
{ "name": "Mo", "tags": ["karaoke", "chaos"],
"user": { "plan": "pro" } }"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 whatstepis for, andstatealso takes a function:state: (t) => t.subject.
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.// 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);Mo — brought a karaoke machine to the offsite, unprompted