<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Malhar Lakdawala</title>
    <description>The latest articles on DEV Community by Malhar Lakdawala (@malharlakdawala).</description>
    <link>https://dev.to/malharlakdawala</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4063046%2F2d9c0f38-f9a9-4fe1-a527-c4592f052c67.jpg</url>
      <title>DEV Community: Malhar Lakdawala</title>
      <link>https://dev.to/malharlakdawala</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/malharlakdawala"/>
    <language>en</language>
    <item>
      <title>What n8n Execution Traces Can and Can't Tell You About Workflow Coverage</title>
      <dc:creator>Malhar Lakdawala</dc:creator>
      <pubDate>Sun, 16 Aug 2026 08:09:40 +0000</pubDate>
      <link>https://dev.to/malharlakdawala/what-n8n-execution-traces-can-and-cant-tell-you-about-workflow-coverage-293p</link>
      <guid>https://dev.to/malharlakdawala/what-n8n-execution-traces-can-and-cant-tell-you-about-workflow-coverage-293p</guid>
      <description>&lt;p&gt;For my own reference, I was trying to answer a simple sounding question about an n8n workflow which branches did this execution exercise?&lt;/p&gt;

&lt;p&gt;It seems like a lookup given an execution, find the nodes, and for their outputs, observe which items were present.&lt;/p&gt;

&lt;p&gt;That gives you the exercised branches. This might be possible for some nodes.&lt;/p&gt;

&lt;p&gt;Then you get to filters.&lt;/p&gt;

&lt;p&gt;Then you get to loops.&lt;/p&gt;

&lt;p&gt;Then you realize no, the trace doesn't record everything. It records specific things, elides others, and sometimes gives you enough information to reconstruct what it didn't record.&lt;/p&gt;

&lt;p&gt;That distinction matters if you're trying to build anything on top of this.&lt;/p&gt;

&lt;p&gt;The useful question to ask isn't simply what does the trace say? but also what can I prove from it, and what am I assuming?&lt;/p&gt;

&lt;p&gt;And here's what I found.&lt;/p&gt;

&lt;p&gt;The obvious setup branch coverage sounds like a lookup&lt;/p&gt;

&lt;p&gt;Consider this node&lt;/p&gt;

&lt;p&gt;┌── true ──&amp;gt; Send email&lt;/p&gt;

&lt;p&gt;Trigger → IF ────┤&lt;/p&gt;

&lt;p&gt;└── false ─&amp;gt; Create task&lt;/p&gt;

&lt;p&gt;After executing, I'd like to know whether both branches were exercised.&lt;/p&gt;

&lt;p&gt;A coverage report might then look like this&lt;/p&gt;

&lt;p&gt;IF&lt;/p&gt;

&lt;p&gt;├── true ✓ exercised&lt;/p&gt;

&lt;p&gt;└── false ✗ not exercised&lt;/p&gt;

&lt;p&gt;That's useful when testing a workflow, since the UI can tell me that both branches exist, but only an actual execution can tell me whether either of them was exercised.&lt;/p&gt;

&lt;p&gt;The same applies to a Switch&lt;/p&gt;

&lt;p&gt;Trigger&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;v&lt;/p&gt;

&lt;p&gt;Switch&lt;/p&gt;

&lt;p&gt;/ | \&lt;/p&gt;

&lt;p&gt;A B C&lt;/p&gt;

&lt;p&gt;If a test run only uses A, I'd like the coverage tool to be able to report that B and C weren't exercised.&lt;/p&gt;

&lt;p&gt;That sounds simple enough.&lt;/p&gt;

&lt;p&gt;The execution trace already has the node outputs, so why not simply read which outputs had items?&lt;/p&gt;

&lt;p&gt;That's actually the case for branching nodes.&lt;/p&gt;

&lt;p&gt;The problems begin when you start trying to apply the same logic to every node.&lt;/p&gt;

&lt;p&gt;So let's talk about what the trace does record.&lt;/p&gt;

&lt;p&gt;What the trace does record&lt;/p&gt;

&lt;p&gt;One of the neat things about the execution trace is how node outputs are structured.&lt;/p&gt;

&lt;p&gt;They're arrays of items, with each item having a JSON field, and multiple outputs per node&lt;/p&gt;

&lt;p&gt;json&lt;/p&gt;

&lt;p&gt;{"Switch": [[{ "json": { "id": 101 } }],[],[{ "json": { "id": 103 } }]}&lt;/p&gt;

&lt;p&gt;Let's say these are the outputs of a switch.&lt;/p&gt;

&lt;p&gt;Output 0 had an item, output 1 had nothing, and output 2 had an item.&lt;/p&gt;

&lt;p&gt;That gives us three states&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;output had items the branch was taken&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;output was empty the branch was skipped&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the node wasn't present at all the branch didn't exist&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third state arises from the simplest of causes if the node wasn't executed, it won't appear in the execution trace.&lt;/p&gt;

&lt;p&gt;So, for branching nodes, it's actually possible to reconstruct a basic coverage report directly from the trace.&lt;/p&gt;

&lt;p&gt;If IF was executed, and its true output has items, then the true branch was exercised.&lt;/p&gt;

&lt;p&gt;If the false output is empty, then that branch wasn't taken.&lt;/p&gt;

&lt;p&gt;And if the downstream node doesn't appear in the trace at all, it wasn't executed in this run.&lt;/p&gt;

&lt;p&gt;That's actually incredibly useful, since it means that for IF like nodes, coverage is directly observable.&lt;/p&gt;

&lt;p&gt;The graph can tell you that a branch exists, but only the execution can tell you whether it was taken.&lt;/p&gt;

&lt;p&gt;For most other nodes, those are orthogonal pieces of information.&lt;/p&gt;

&lt;p&gt;That's why I'm tempted to think of this as the simplest possible coverage report.&lt;/p&gt;

&lt;p&gt;But there's a trap waiting for us here, and it's in the third state absence from the trace.&lt;/p&gt;

&lt;p&gt;An empty output means something specific, but an absent node means something else entirely, and the trace won't tell you which.&lt;/p&gt;

&lt;p&gt;That's important enough that we'll come back to it.&lt;/p&gt;

&lt;p&gt;Let's look at nodes that actually process items and see what the trace can and can't say about them.&lt;/p&gt;

&lt;p&gt;What the trace doesn't record&lt;/p&gt;

&lt;p&gt;Let's take a Filter:&lt;/p&gt;

&lt;p&gt;Input&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;v&lt;/p&gt;

&lt;p&gt;Filter&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;v&lt;/p&gt;

&lt;p&gt;Output&lt;/p&gt;

&lt;p&gt;Let's say that 100 items were input into the filter, and the condition was met for 70 of them.&lt;/p&gt;

&lt;p&gt;The trace will then have the 70 items in the output.&lt;/p&gt;

&lt;p&gt;But there is no field in the trace that would have the number 100 in it, so the number of input items is not directly observable.&lt;/p&gt;

&lt;p&gt;That means the trace can't tell you how many items were dropped by the filter.&lt;/p&gt;

&lt;p&gt;It can help you reconstruct that number, assuming you know the number of input items:&lt;/p&gt;

&lt;p&gt;100 - 70 = 30&lt;/p&gt;

&lt;p&gt;If the upstream node emitted 100 items, and the filter only emitted 70, it's reasonable to assume that 30 were dropped.&lt;/p&gt;

&lt;p&gt;But that's an inference that's not directly supported by the trace.&lt;/p&gt;

&lt;p&gt;This makes all the difference when it comes to tools that report coverage.&lt;/p&gt;

&lt;p&gt;I think it's much more useful to see something like this&lt;/p&gt;

&lt;p&gt;Input: 100 (inferred from upstream)&lt;/p&gt;

&lt;p&gt;Output: 70&lt;/p&gt;

&lt;p&gt;Dropped: 30 (inferred)&lt;/p&gt;

&lt;p&gt;rather than&lt;/p&gt;

&lt;p&gt;Filter dropped 30 items&lt;/p&gt;

&lt;p&gt;The reason for this is simple the second bullet implies that n8n itself knows that 30 items were dropped, when in reality, that number was reconstructed from other values.&lt;/p&gt;

&lt;p&gt;This is especially useful when it comes to loops, since the obvious source of truth about items in them stops being reliable.&lt;/p&gt;

&lt;p&gt;The loop trap&lt;/p&gt;

&lt;p&gt;The standard pattern for looping in n8n looks like this&lt;/p&gt;

&lt;p&gt;┌─────────────────────┐&lt;/p&gt;

&lt;p&gt;│ │&lt;/p&gt;

&lt;p&gt;│ ┌─────────────┐ │&lt;/p&gt;

&lt;p&gt;Input ──────────────┼──&amp;gt;│ Loop Over │ │&lt;/p&gt;

&lt;p&gt;│ │ Items │ │&lt;/p&gt;

&lt;p&gt;│ └──────┬──────┘ │&lt;/p&gt;

&lt;p&gt;│ │ │&lt;/p&gt;

&lt;p&gt;│ v │&lt;/p&gt;

&lt;p&gt;│ Loop body │&lt;/p&gt;

&lt;p&gt;│ │ │&lt;/p&gt;

&lt;p&gt;│ └──────────┘&lt;/p&gt;

&lt;p&gt;│ back to&lt;/p&gt;

&lt;p&gt;│ input&lt;/p&gt;

&lt;p&gt;└─────────────────────┘&lt;/p&gt;

&lt;p&gt;│&lt;/p&gt;

&lt;p&gt;Done ───────────────&amp;gt; next node&lt;/p&gt;

&lt;p&gt;By default, Loop Over Items nodes feed batches of items back into themselves until the loop is completed, with the Done output being used to signal that the loop has completed.&lt;/p&gt;

&lt;p&gt;This has an impact on the analysis of loop cardinality, since the number of loop iterations isn't directly recorded.&lt;/p&gt;

&lt;p&gt;The most obvious approach to loop iterations is to infer them from input items&lt;/p&gt;

&lt;p&gt;Loop iterations = number of items input to loop&lt;/p&gt;

&lt;p&gt;If true, then with a batch size of 1, a single item would cause the loop to execute once.&lt;/p&gt;

&lt;p&gt;However, the input to this loop comes from two places: the initial input to the loop node and the loop body itself.&lt;/p&gt;

&lt;p&gt;A naive approach to counting loop iterations would then mistakenly count the number of items input to the loop as the number of loop iterations.&lt;/p&gt;

&lt;p&gt;For example, if you used this pattern to implement a map&lt;/p&gt;

&lt;p&gt;Loop iterations = number of items input to loop node&lt;/p&gt;

&lt;p&gt;then a cardinality analysis of the loop body would erroneously report that the loop executed more times than it actually did.&lt;/p&gt;

&lt;p&gt;The reason for this has to do with how feedback connections work in n8n.&lt;/p&gt;

&lt;p&gt;If you're trying to count loop iterations, the number you're looking for is not the number of items input to the loop node, but the number of items dispatched from its loop slot&lt;/p&gt;

&lt;p&gt;Loop iterations = number of items dispatched from loop slot&lt;/p&gt;

&lt;p&gt;Using the cardinality of the loop body as a proxy for loop iterations is not reliable, since the loop body may have other inputs besides the loop node itself.&lt;/p&gt;

&lt;p&gt;This is why I think of it as the cardinality of the loop dispatch itself.&lt;/p&gt;

&lt;p&gt;Conceptually, it's similar to this&lt;/p&gt;

&lt;p&gt;input&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;v&lt;/p&gt;

&lt;p&gt;┌───────────────┐&lt;/p&gt;

&lt;p&gt;│ Loop Over │&lt;/p&gt;

&lt;p&gt;│ Items │&lt;/p&gt;

&lt;p&gt;└───────┬───────┘&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;v&lt;/p&gt;

&lt;p&gt;loop body&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;└───────────────┐&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;v&lt;/p&gt;

&lt;p&gt;loop input again&lt;/p&gt;

&lt;p&gt;Done ───────────────&amp;gt; after completion&lt;/p&gt;

&lt;p&gt;The input to the loop body isn't a new original input item, but a re used item from the loop output.&lt;/p&gt;

&lt;p&gt;This means that the same cardinality tracking rules that applied to the filter don't automatically apply to loops.&lt;/p&gt;

&lt;p&gt;You have to examine the context of the node and, specifically, the slot you're looking at, since each slot represents a different kind of connection.&lt;/p&gt;

&lt;p&gt;That brings us to the next subject what you can't observe.&lt;/p&gt;

&lt;p&gt;What you can't observe&lt;/p&gt;

&lt;p&gt;The example that I think has the most interesting edge case is a missing Loop Over Items node.&lt;/p&gt;

&lt;p&gt;If it's not in the execution trace, does that mean that it was reached with zero items, or that it was never reached at all?&lt;/p&gt;

&lt;p&gt;There is no flag in the trace that would distinguish between the two cases, which means that a coverage tool can't know for sure which one is the case.&lt;/p&gt;

&lt;p&gt;This means that a branch coverage tool can't reliably distinguish between&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the loop was reached, but no items were present when it was executed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the loop was never reached.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no separate "never executed" marker in the trace.&lt;/p&gt;

&lt;p&gt;So the best coverage report for such a case is this&lt;/p&gt;

&lt;p&gt;Loop: UNOBSERVABLE&lt;/p&gt;

&lt;p&gt;this might be frustrating for the user, but it's the correct report given the information available in the trace.&lt;/p&gt;

&lt;p&gt;The loop could have been executed zero times: either because it had no items to process, or because it was never reached.&lt;/p&gt;

&lt;p&gt;This is an example of why I think such a distinction is useful for coverage tools, especially the ones that have to run on top of the same execution traces that n8n uses.&lt;/p&gt;

&lt;p&gt;When you start writing tools that need to reason about arbitrary node executions, you find that binary outcome was it executed isn't always useful.&lt;/p&gt;

&lt;p&gt;This is especially the case when the tool can't look at the node's code, only at the execution trace.&lt;/p&gt;

&lt;p&gt;That's where the unobservable outcome is useful.&lt;/p&gt;

&lt;p&gt;I think this can be especially valuable in testing: a test report that knows when it can't observe certain outcomes is much more useful than one that tries to pretend that it knows everything.&lt;/p&gt;

&lt;p&gt;Two CLI traps&lt;/p&gt;

&lt;p&gt;There are also two gotchas when it comes to using the CLI to drive execution in scripts.&lt;/p&gt;

&lt;p&gt;--rawOutput isn't as raw as the documentation suggests&lt;/p&gt;

&lt;p&gt;The --rawOutput option is supposed to suppress any extra text and only print the JSON, but that's not always the case.&lt;/p&gt;

&lt;p&gt;In particular, n8n may print some diagnostic information at the start of the execution.&lt;/p&gt;

&lt;p&gt;So a script like this&lt;/p&gt;

&lt;p&gt;n8n execute --rawOutput ... | jq&lt;/p&gt;

&lt;p&gt;is not guaranteed to parse the JSON correctly, since some diagnostic text may appear above it.&lt;/p&gt;

&lt;p&gt;This was the case in the 2.33.4 release, which printed a runner identifier line like this&lt;/p&gt;

&lt;p&gt;Runner ID: abc123&lt;/p&gt;

&lt;p&gt;So a naïve Python script that tried to do this:&lt;/p&gt;

&lt;p&gt;python&lt;/p&gt;

&lt;p&gt;import json&lt;/p&gt;

&lt;p&gt;json.loads(stdout) would fail.&lt;/p&gt;

&lt;p&gt;It's better to process the stream more carefully, for example, by finding the actual JSON in the stream.&lt;/p&gt;

&lt;p&gt;That said, this approach may not be necessary if you're writing the tool yourself.&lt;/p&gt;

&lt;p&gt;The point is that the CLI's behavior may change, and if you're writing automation around it, you should be prepared to handle those changes.&lt;/p&gt;

&lt;p&gt;A good rule of thumb is not to make assumptions about the machine parseable output format.&lt;/p&gt;

&lt;p&gt;If you're writing a script that's supposed to run on n8n and parse execution data, design it in such a way that it would continue working if the logging format changes.&lt;/p&gt;

&lt;p&gt;It won't be easy, but it's worth it if you're trying to write long lasting automation.&lt;/p&gt;

&lt;p&gt;execute --file can return success exit code when the file wasn't executed&lt;/p&gt;

&lt;p&gt;The second gotcha is much more serious.&lt;/p&gt;

&lt;p&gt;If you're using n8n execute --file to run a workflow as part of your testing infrastructure, it's reasonable to assume that a nonzero exit code means that the execution has failed.&lt;/p&gt;

&lt;p&gt;However, if you try to run a file that n8n refuses to execute for any reason (for example, because it's not a valid JSON), it'll exit with code zero.&lt;/p&gt;

&lt;p&gt;This means that this simple check&lt;/p&gt;

&lt;p&gt;n8n execute --file workflow.json&lt;/p&gt;

&lt;p&gt;if [ $? -ne 0 ]; then&lt;/p&gt;

&lt;p&gt;echo "execution failed"fi&lt;/p&gt;

&lt;p&gt;is not sufficient to determine whether the file was executed.&lt;/p&gt;

&lt;p&gt;The tool has exited successfully, but the execution wasn't performed.&lt;/p&gt;

&lt;p&gt;Your testing infrastructure now has to do more sophisticated analysis, for example, by looking at the standard output and checking for specific markers.&lt;/p&gt;

&lt;p&gt;It's an easy mistake to make, since exit codes are usually the primary signal that a process has completed successfully.&lt;/p&gt;

&lt;p&gt;But in this case, they're not sufficient to determine whether the tool actually did what you wanted it to do.&lt;/p&gt;

&lt;p&gt;That's why provenance is so important in testing.&lt;/p&gt;

&lt;p&gt;Why every coverage report needs provenance&lt;/p&gt;

&lt;p&gt;Once you start building workflow coverage from execution data, the most useful feature isn't a clever branch counter&lt;/p&gt;

&lt;p&gt;When the report says&lt;/p&gt;

&lt;p&gt;IF → false branch&lt;/p&gt;

&lt;p&gt;NOT COVERED&lt;/p&gt;

&lt;p&gt;I want to know what that means.&lt;/p&gt;

&lt;p&gt;When it says&lt;/p&gt;

&lt;p&gt;Filter&lt;/p&gt;

&lt;p&gt;Dropped: 30&lt;/p&gt;

&lt;p&gt;I want to know whether that's directly observed or reconstructed.&lt;/p&gt;

&lt;p&gt;And when it says&lt;/p&gt;

&lt;p&gt;Loop&lt;/p&gt;

&lt;p&gt;UNOBSERVABLE&lt;/p&gt;

&lt;p&gt;I want to see something like this&lt;/p&gt;

&lt;p&gt;Node absent from execution data.&lt;/p&gt;

&lt;p&gt;The trace does not distinguish between&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;reached with zero items&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;never reached&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That way, I can actually reason about the report.&lt;/p&gt;

&lt;p&gt;That's the value of the provenance driven approach, and it's the reason why these distinctions are important to me when writing such tools.&lt;/p&gt;

&lt;p&gt;n8n's execution trace is not a perfect source of truth about the execution, and I don't think it was ever meant to be one.&lt;/p&gt;

&lt;p&gt;However, it does have enough structure to make some things directly observable, enough information to allow for reconstructing others, and some gaps that can't be reconstructed.&lt;/p&gt;

&lt;p&gt;For example, for branching nodes, the presence of output arrays allows us to directly observe which branches were taken.&lt;/p&gt;

&lt;p&gt;For filters, the number of dropped items can be reconstructed from other information, but the fact that it's reconstruction needs to be noted.&lt;/p&gt;

&lt;p&gt;For loops, the cardinality can be observed in the dispatch output, not the input, due to the way feedback connections work.&lt;/p&gt;

&lt;p&gt;For an absent loop node, the trace may not be able to distinguish between zero items and no execution at all, and the coverage tool should report that as UNOBSERVABLE.&lt;/p&gt;

&lt;p&gt;These are the insights that let me write a program that analyzes n8n execution traces. They're also the reason why the small CLI tool I'm working on reports this information alongside each coverage finding&lt;/p&gt;

&lt;p&gt;The tool itself is not that interesting, but the ideas behind it are.&lt;/p&gt;

&lt;p&gt;That's why I'm writing this post I think that execution analysis is an interesting space, and the way n8n exposes execution information makes some tasks much more interesting than they would be elsewhere.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>debugging</category>
      <category>testing</category>
    </item>
    <item>
      <title>Building Self Hosted Apollo.io Style Prospecting UI on Top of Blitz API</title>
      <dc:creator>Malhar Lakdawala</dc:creator>
      <pubDate>Sun, 09 Aug 2026 10:36:56 +0000</pubDate>
      <link>https://dev.to/malharlakdawala/building-self-hosted-apolloio-style-prospecting-ui-on-top-of-blitz-api-4kbk</link>
      <guid>https://dev.to/malharlakdawala/building-self-hosted-apolloio-style-prospecting-ui-on-top-of-blitz-api-4kbk</guid>
      <description>&lt;p&gt;Outbound prospecting tools have become essential for sales teams, founders, and agencies. However most of the best tools are recurring per seat software and lock you into a specific stack.&lt;/p&gt;

&lt;p&gt;Blitz API already has all the people and company data you need to perform prospecting, but it only exposes this data through an API and MCP server. If you want a nice UI on top of it, user management or data export features, you have to build that yourself.&lt;/p&gt;

&lt;p&gt;This is why I built BlitzProspector.&lt;/p&gt;

&lt;p&gt;It is an opensource self hosted web application built on top of Blitz API that provides an Apollo.io style UI for managing your outbound sales prospects and allows you to self-host it all with a single docker compose command.&lt;/p&gt;

&lt;p&gt;It is MIT licensed.&lt;/p&gt;

&lt;p&gt;The problem&lt;/p&gt;

&lt;p&gt;Most prospecting platforms are basically a combination of three areas in one hosted SaaS:&lt;/p&gt;

&lt;p&gt;Data&lt;/p&gt;

&lt;p&gt;User Interface&lt;/p&gt;

&lt;p&gt;Team management&lt;/p&gt;

&lt;p&gt;While it is really convenient for the end user to have everything in one place, it also means they are now dependent on another hosted platform plus a subscription.&lt;/p&gt;

&lt;p&gt;Blitz API already solves the data layer. It has search functionality exposed as an API, but doesn't offer a UI on top of it. This leaves room for a product that would focus on self hosting.&lt;/p&gt;

&lt;p&gt;What BlitzProspector offers&lt;/p&gt;

&lt;p&gt;The author’s objective was to offer a familiar user experience (to Apollo customers) while providing a quick and convenient setup and deployment.&lt;/p&gt;

&lt;p&gt;Some of the core features include:&lt;/p&gt;

&lt;p&gt;Multiple user accounts and roles (Admin, Member)&lt;/p&gt;

&lt;p&gt;Search people and companies&lt;/p&gt;

&lt;p&gt;Email validation&lt;/p&gt;

&lt;p&gt;CSV exports&lt;/p&gt;

&lt;p&gt;CRM export profiles&lt;/p&gt;

&lt;p&gt;Deployed with Docker first approach&lt;/p&gt;

&lt;p&gt;Using SQLite database&lt;/p&gt;

&lt;p&gt;Open-sourced under MIT license&lt;/p&gt;

&lt;p&gt;A functioning instance can be launched with a single command using Docker Compose instead of relying on a cloud provider.&lt;/p&gt;

&lt;p&gt;Architecture&lt;/p&gt;

&lt;p&gt;The project uses a simple stack for the app.&lt;/p&gt;

&lt;p&gt;Backend&lt;/p&gt;

&lt;p&gt;The backend is built with FastAPI framework which is used to expose an API and the business logic of the application.&lt;/p&gt;

&lt;p&gt;Frontend&lt;/p&gt;

&lt;p&gt;The user interface is built using react with vite, providing a responsive single page application.&lt;/p&gt;

&lt;p&gt;Storage&lt;/p&gt;

&lt;p&gt;Storage is handled by SQLite which makes it really easy to setup and launch the app locally.&lt;/p&gt;

&lt;p&gt;Because the application is self hosted, users remain in control of their own environment instead of depending on another hosted SaaS platform.&lt;/p&gt;

&lt;p&gt;Extensible by design&lt;/p&gt;

&lt;p&gt;One of the design goals was to make integrations as easy to extend as possible&lt;/p&gt;

&lt;p&gt;Email verification providers&lt;/p&gt;

&lt;p&gt;Instead of being tied to a specific verification service, BlitzProspector uses an abstraction of a provider.&lt;/p&gt;

&lt;p&gt;Additional providers can be added by implementing a particular verification interface and registering a provider.&lt;/p&gt;

&lt;p&gt;In case no provider is configured, the application does not attempt to perform any verification requests.&lt;/p&gt;

&lt;p&gt;Thus, an integration becomes optional and is not mandatory anymore.&lt;/p&gt;

&lt;p&gt;CRM export profiles&lt;/p&gt;

&lt;p&gt;CRM exports follow a registrybased approach&lt;/p&gt;

&lt;p&gt;Currently, the following integrations are supported:&lt;/p&gt;

&lt;p&gt;Instantly&lt;/p&gt;

&lt;p&gt;Smartlead&lt;/p&gt;

&lt;p&gt;HubSpot&lt;/p&gt;

&lt;p&gt;For most CRMs, adding a new profile boils down to describing column mappings. As a result, the export logic remains consistent and easily maintained.&lt;/p&gt;

&lt;p&gt;Flexible filtering&lt;/p&gt;

&lt;p&gt;One of the critical features of prospecting is being able to use the right search filters. BlitzProspector implements search in a way that is similar to how Apollo query language works. It maps each Apollo style field to a specific Blitz API field.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;p&gt;Person filters&lt;/p&gt;

&lt;p&gt;Job title&lt;/p&gt;

&lt;p&gt;Seniority&lt;/p&gt;

&lt;p&gt;Department&lt;/p&gt;

&lt;p&gt;Country&lt;/p&gt;

&lt;p&gt;City&lt;/p&gt;

&lt;p&gt;Region&lt;/p&gt;

&lt;p&gt;Connection count&lt;/p&gt;

&lt;p&gt;Company filters&lt;/p&gt;

&lt;p&gt;Industry&lt;/p&gt;

&lt;p&gt;Employee count&lt;/p&gt;

&lt;p&gt;Founded year&lt;/p&gt;

&lt;p&gt;Keywords&lt;/p&gt;

&lt;p&gt;LinkedIn followers&lt;/p&gt;

&lt;p&gt;Website traffic&lt;/p&gt;

&lt;p&gt;Headquarters location&lt;/p&gt;

&lt;p&gt;The mapping layer keeps the UI intuitive while translating searches into Blitz API requests.&lt;/p&gt;

&lt;p&gt;Design Choices&lt;/p&gt;

&lt;p&gt;Keeping the project modular was more important than adding every possible feature.&lt;/p&gt;

&lt;p&gt;Instead of having providers or export formats built into the application, both are implemented as extension points allowing to add new ones without large code changes.&lt;/p&gt;

&lt;p&gt;Similarly, the decision to go with Docker first approach reduces the burden of selfbhosting while keeping the default implementation simple with SQLite.&lt;/p&gt;

&lt;p&gt;The similar consideration applies to every part of the system, focusing on the most useful features with the cheapest possible implementation and reducing the surface area of possible changes.&lt;/p&gt;

&lt;p&gt;What's next&lt;/p&gt;

&lt;p&gt;There are several obvious directions for taking this project further.&lt;/p&gt;

&lt;p&gt;The most obvious ones are more email verification providers, additional CRM export formats, recurring searches, additional query filters, and several general quality of life features.&lt;/p&gt;

&lt;p&gt;Since the project is open source under the MIT license, contributions are welcome.&lt;/p&gt;

&lt;p&gt;Final thoughts&lt;/p&gt;

&lt;p&gt;Blitz API offers the data layer for prospecting. BlitzProspector is about the surrounding bells and whistles, the modern UI, user management, filtering, exports, etc, and an overall pleasant self hosted experience.&lt;/p&gt;

&lt;p&gt;If you are looking to emulate the Apollo workflow, but want to avoid adding yet another 3rd party SAAS to your stack, hopefully this project serves as a good foundation.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>react</category>
      <category>showdev</category>
      <category>python</category>
    </item>
  </channel>
</rss>
