Six months ago I watched an n8n demo and did what every engineer does two minutes into a demo: started redesigning it in my head before the video ended. That night I found Code with Antonio's YouTube tutorial on building an n8n-style automation platform and started following along.
What's running today shares maybe its first weekend with that tutorial: a manual trigger, an HTTP node, a canvas you could drag two boxes onto and connect. Everything past that, I built once the tutorial ended and I kept going anyway. A tool-calling AI agent. Eight AI providers. Encrypted per-user credentials with OAuth account linking. A full execution history with read-only replay. Auth, billing, profile management. The tutorial got me a canvas that could run one thing after another. It didn't get me any of the reasons I actually kept building.
This is that build log: what Relay is, what I built on top of the tutorial, and the two things that turned out to matter most while building it — running long AI tasks without babysitting them on Inngest, and what React Flow actually teaches you once you go past dragging boxes around.
What Relay actually does
Relay is a visual, node-based automation tool in the shape of n8n or Zapier. You drag nodes onto a canvas, wire them together, and click Execute Workflow. Every node runs for real: an HTTP Request node makes an actual HTTP call, an AI node calls an actual model, and an IF node actually decides which half of the graph runs next.
Thirteen node types ship today: a manual trigger, HTTP Request, IF, Switch, a multi-step AI Agent that can call other nodes as tools mid-conversation, and eight single-shot AI provider nodes — OpenAI, Anthropic, Gemini, Groq, DeepSeek, Mistral, Moonshot, and Ollama for anyone who'd rather run a model locally than hand a provider an API key. Any field that accepts a {{path.to.value}} expression, an HTTP endpoint, an AI prompt, an IF condition, has a variable picker attached to it that shows the real shape of whatever the upstream node actually returned last time it ran, not a guess at its schema.
Where the tutorial's scaffolding ends
I want to be specific about the split, because "inspired by a tutorial" can mean anything from watching five minutes of it to copying the whole repo. The tutorial got me a working skeleton: Next.js project structure, a React Flow canvas that could hold a couple of node types, a trigger node, an HTTP node, and the idea that a node's saved configuration and its execution behavior belong in two different files. That's a real, useful skeleton. It is not a product.
Once I'd built past the last video in the series, the list of things I wanted that weren't in it kept growing. A node that could reason, not just fetch. Branches that actually branch instead of drawing a different-colored line. A way to know why a run failed without opening a debugger. A way for more than one person to use this without reading each other's API keys off a shared .env file. None of that was in the tutorial, so building each one meant picking the right tool for the job and learning it well enough to trust it in a real run, which is most of what this post is actually about.
Branches that actually branch
The IF and Switch nodes were the first place "for real, not a mockup" got tested. In the tutorial's model, and in a lot of no-code tools I'd used before, a false branch is just a line on the canvas that doesn't get colored: the node behind it is still reachable, it just looks skipped. That's cosmetic, and it bit me once in testing when a downstream node quietly ran anyway and called an API I didn't want called.
So branch pruning in Relay is structural, not cosmetic. An IF or Switch executor returns which output it took, "true", "false", a specific case, and the workflow's graph walk only marks a node reachable if a connection actually leaves that exact output handle. A node sitting behind the branch you didn't take never gets visited. It isn't skipped and greyed out. It's never called at all.
Running long AI tasks without babysitting them
This is the part the tutorial didn't need to solve, because a two-node demo workflow finishes in under a second. A real workflow doesn't. An AI Agent node can run several tool calls back and forth with a model before it's done. An HTTP call to a slow API can take ten seconds on a bad day. String enough of those together and you have a request that legitimately needs a minute or two to finish, longer than most serverless functions stay alive for, and far longer than I wanted a user staring at a spinning button.
Inngest is what actually runs a workflow. Clicking Execute Workflow doesn't run anything inline: it writes a WorkflowRun row, fires an event, and returns immediately. An Inngest function picks the event up and walks the graph in topological order, one node at a time, and every node's real work happens inside step.run(...), which Inngest checkpoints independently. If the process restarts mid-run, or a single node throws, Inngest retries from the failed step instead of the whole workflow starting over from the trigger.
Two decisions in that flow took longer to get right than the diagram suggests. The first was retry semantics. A node with a bad configuration, a missing required field, should never retry, because nothing about retrying fixes a config error, it just burns time and quota. A node that fails because a provider had a bad five seconds should retry. Every executor in Relay throws NonRetriableError for the first case and a plain Error for the second, and Inngest treats those two throws completely differently without me writing any retry logic myself.
The second was realtime status. My first pass polled /executions every couple of seconds for status changes, and it worked, and it was miserable: laggy badges, wasted requests, a UI that looked done fifteen seconds before the actual run finished. @inngest/realtime replaced that with a channel the Inngest function publishes to directly, per node, and the browser subscribes to once when a run starts. A node's status badge on the canvas updates the instant Inngest's function publishes it. That publish, and the matching database write, are both best-effort: if either one throws, the failure is swallowed rather than allowed to override the run's actual success or failure. A bookkeeping hiccup should never be the reason a successful run gets reported as broken.
The Inngest dev server ended up being the tool I had open more than the app itself while building executors. Every step.run shows up as its own row with its own duration, and when something's wrong it's usually obvious from that trace alone, without adding a single console.log.
What React Flow actually teaches you
I'd used React Flow before for something small, and I assumed a workflow canvas was mostly that experience scaled up. It isn't. A node in Relay isn't a shape with a label, it's a component that reads its own live status out of Jotai state and re-renders its badge without re-rendering the whole canvas, because a canvas with fifteen nodes shouldn't repaint all fifteen every time one of them changes from loading to success.
Every node type in Relay follows the same three-file shape: a canvas component for how it looks, a dialog for how you configure it, and an executor for what it actually does when a run reaches it. Keeping those three separate is what made adding the ninth AI provider node take an afternoon instead of a week. The canvas component and the config dialog barely change between providers, so most of a new provider node is copying the shape and swapping which SDK the executor calls.
The other thing I hadn't appreciated: an edge in React Flow isn't just a line, it's data. A connection out of an IF node's true handle and a connection out of its false handle are two different edges with two different source handle IDs, and the graph walk that decides what's reachable reads those handle IDs directly. The branch-pruning logic from earlier in this post doesn't live in some separate rules engine. It's a property of which edge is attached to which handle on the canvas you're looking at.
Credentials, and not trusting myself with plaintext
Every AI provider node needs an API key, and I didn't want one shared key in an env var that every user of the app would effectively be spending against. So credentials in Relay are per-user: you add your own OpenAI or Gemini key from /credentials, it's encrypted before it touches the database, and an executor can only decrypt the key belonging to the workflow's owner. better-auth handles the account side of that, including OAuth linking for Google, Slack, GitHub, Microsoft, and Discord, ahead of integration nodes for those services that don't exist yet.
Encryption key management is the one place I was strict to the point of being annoying to myself: the app refuses to boot without ENCRYPTION_KEY set. I'd rather see that failure at startup, loudly, than have it silently fall back to storing a key in plaintext because I forgot an env var on a Tuesday.
The parts that make it a product instead of a demo
Auth, billing, and profile management didn't come from the tutorial either, and none of them are interesting to write about node by node, but skipping them would leave Relay as a toy nobody else could actually sign into. better-auth handles email/password on top of the OAuth providers above. Polar, through @polar-sh/better-auth, runs subscriptions and checkout, so /billing reflects a real subscription status instead of a hardcoded "Pro" badge. /profile is where sessions and account settings live. None of it shows up in a screenshot worth including in a post about workflow automation, but it's the difference between a workflow canvas I built and something a second person could actually use.
Watching a run happen, and going back to look at it later
Every node's status badge updates live as a run reaches it, and clicking a completed node opens exactly what it saw and what it returned, no separate log viewer to check.
/executions is the same idea zoomed out: every run, across every workflow, with its full step timeline. What I liked most once it existed wasn't the list, it was opening a past run from that list and landing back on the same editor, in a read-only replay hydrated from that run's actual recorded history. Same canvas, same node-output drawer, same badges, just frozen at ?run=<id> instead of live. I didn't want a second UI to build and maintain just for looking backward.
What I'd tell someone starting from the same tutorial
Finish the tutorial before you judge it. Mine got me a canvas, a trigger, and enough of a mental model of the node/executor split to build the other twelve node types on my own once I understood the shape of the first one. That's worth more than the actual lines of code it left behind.
Then pick the one thing about the finished tutorial project that would stop you from actually using it, and build that next. For me it was that watching a run happen felt like a black box, no visibility into what a node actually did. Chasing that turned into Inngest, which turned into realtime status, which turned into execution history, which turned into replay. None of it was planned upfront as a roadmap. Each piece was just the answer to why the last one didn't feel real yet.
Relay is still missing things I know about: no Dockerfile or CI pipeline yet, and the OAuth account links on /credentials are ahead of any integration node that actually uses them. It's AGPL-3.0, which means if you run a modified version of it as a network service, the source for your version has to be available too.
If you're building something similar, or you just want to see how the branch pruning, the Inngest functions, or the credential encryption are actually wired up, the code is at github.com/yash27007/relay. If it's useful to you, star the repo, it's the easiest way to tell me which of these posts are worth writing more of.