Compose Dependent API Calls Into One Traced Flow
Oleksandr Zhuravlov
You fetch an order, then the shipment booked against it, then the carrier's live tracking — and the tracking call needs the shipment's code and the order's region, two hops back. Three calls, in order, each needing a result from the one before. When one fails at 2am, you want a trace that shows what ran first, not three unrelated calls.
Written as plain awaits, most of that is already fine. Each call is a stitch — a typed, validated, resilient function over one endpoint — so the intermediate values are real types and an ancestor two steps back is just a variable still in scope:
const = await ({ : { : 1043 } });
const = await ({ : { : . } });
const = await ({
: { : ., : . },
: { : . }, // two hops back — just a variable
});order is Order, shipment is Shipment, no cast. Each stitch carries its own retry, timeout, and output validation. The sharp edge isn't the code — it's the trace. Each bare await mints its own root run, so those three calls are three unrelated operations in your logs; nothing ties them into one. linked() fixes exactly that, and nothing else: it opens a run scope and lets you keep writing the same awaits.
The scope
const = await (async () => {
const = await (, { : { : 1043 } });
const = await (, {
: { : . },
});
return (, {
: { : ., : . },
: { : . }, // still just a variable
});
});linked(body) hands your body a run. Call your stitches through it instead of directly — that's the whole API.
- Same types, no casts.
run(stitch, input)is typed straight from the stitch: input from its input, result from its output. The two-hops-back ancestor isn't a feature you invoke; it's a variable you already have. - One trace. Each
run(...)chains as a child of the call before it, so the trace drawsfetchOrder → fetchShipment → fetchTrackingas one connected sequence —orderthe root run,shipmenta child oforder,trackinga child ofshipment. - Fails fast. A rejected call rejects the whole
linked(...)and the body stops, carrying the failing call's.status,.attempts,.body, and.url. You handle one failure at the boundary — theawaityou already wrote.
Concurrent steps stay in the trace
When two calls inside the scope don't depend on each other, don't await them in series — hand run an all. run accepts any combinator as a node, so the parallel fan nests inside the same trace:
const = await (async () => {
const = await (, { : { : 1043 } });
// shipment and invoice are independent — fan them out, still inside the trace
const { , } = await (
({ : , : }),
{ : { : . } },
);
return { : ., : . };
});The chain shows up in the trace
This is the detail that pays off in production. A hand-written ladder of awaits and a linked scope read almost identically — the difference is entirely in the trace:
Plain awaits | linked | |
|---|---|---|
| Intermediate types | ✅ typed variables | ✅ typed variables |
| Fail-fast | ✅ (await throws) | ✅ |
| Per-call resilience | ✅ each stitch's own | ✅ each stitch's own |
| One linked trace | ❌ three root runs | ✅ one connected chain |
| Concurrent sub-steps | Promise.all (loose) | run(all(...)) (in the trace) |
So when tracking comes back pointing at the wrong region, the trace (and the playground's call-graph DAG) shows the order fetch that ran first, the value it produced, and the input the tracking call received. You read the sequence instead of reassembling it — and each stitch in the scope stays a standalone function you can call, test, or expose through any front door on its own.
Try it
npm install stitchapi@rclinked lives at the stitchapi/pipe subpath, so you only pull it into the bundle when a flow needs it. Each call is a stitch with its own validation, and the whole scope is visible in the event stream.
Related reading: run independent calls in parallel, type-safe pagination, one definition, four front doors, and runtime stitching vs. workflow platforms.