diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 918d02cf4aa7b20b0645be8d79b750667d14c023..e02c515945b6e6f25573ba06b153509301b986de 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -222,7 +222,7 @@ build_test-clang-14:
       junit:
         - ${CI_PROJECT_DIR}/build/test_outputs/junit*.xml
     paths:
-      - ${CI_PROJECT_DIR}/build/build_examples/example_outputs/radio_em_shower_outputs #python examples need this
+      - ${CI_PROJECT_DIR}/build/build_examples/example_outputs/radio_em_shower_outputs #python tests/examples need this
       - ${CI_PROJECT_DIR}/build/test_outputs/junit*.xml
       - ${CI_PROJECT_DIR}/build/CMakeCache.txt #python tests need this
       - ${CI_PROJECT_DIR}/build/bin #python tests need this
@@ -411,9 +411,7 @@ python-tests:
   allow_failure: false
 
 python-examples:
-  stage: python
-  tags:
-    - corsika
+  extends: .python
   image: corsika/devel:u-22.04
   needs:
     - job: build_test_example-u-22_04
diff --git a/applications/c8_air_shower.cpp b/applications/c8_air_shower.cpp
index 7abd71bbb8240b370296ac4e99ab91e8738b11c9..8912d6a91529770bfb2390a1ccbf4cfd90df9635 100644
--- a/applications/c8_air_shower.cpp
+++ b/applications/c8_air_shower.cpp
@@ -27,6 +27,7 @@
 #include <corsika/framework/utility/SaveBoostHistogram.hpp>
 
 #include <corsika/modules/writers/EnergyLossWriter.hpp>
+#include <corsika/modules/writers/InteractionWriter.hpp>
 #include <corsika/modules/writers/LongitudinalWriter.hpp>
 #include <corsika/modules/writers/PrimaryWriter.hpp>
 #include <corsika/modules/writers/SubWriter.hpp>
@@ -218,6 +219,9 @@ int main(int argc, char** argv) {
   app.add_flag("--force-interaction", force_interaction,
                "Force the location of the first interaction.")
       ->group("Misc.");
+  bool force_decay = false;
+  app.add_flag("--force-decay", force_decay, "Force the primary to immediately decay")
+      ->group("Misc.");
   bool disable_interaction_hists = false;
   app.add_flag("--disable-interaction-histograms", disable_interaction_hists,
                "Store interaction histograms")
@@ -571,10 +575,15 @@ int main(int argc, char** argv) {
   // register ZHS with the output manager
   output.add("ZHS", zhs);
 
+  // make and register the first interaction writer
+  InteractionWriter<setup::Tracking, ParticleWriterParquet> inter_writer(
+      showerAxis, observationLevel);
+  output.add("interactions", inter_writer);
+
   // assemble the final process sequence with radio
   auto sequence = make_sequence(stackInspect, neutrinoPrimaryPythia, hadronSequence,
                                 decayPythia, emCascade, emContinuous, coreas, zhs,
-                                longprof, observationLevel, thinning, cut);
+                                longprof, observationLevel, inter_writer, thinning, cut);
 
   /* === END: SETUP PROCESS LIST === */
 
@@ -627,6 +636,11 @@ int main(int argc, char** argv) {
       EAS.forceInteraction();
     }
 
+    if (force_decay) {
+      CORSIKA_LOG_INFO("Forcing the primary to decay");
+      EAS.forceDecay();
+    }
+
     primaryWriter.recordPrimary(primaryProperties);
 
     // run the shower
diff --git a/corsika/detail/framework/core/Cascade.inl b/corsika/detail/framework/core/Cascade.inl
index bb741627f9cdebe233694a7c55ea5b979a47c46b..63ccdd7dd19b939e75872353618452876523d3da 100644
--- a/corsika/detail/framework/core/Cascade.inl
+++ b/corsika/detail/framework/core/Cascade.inl
@@ -43,7 +43,8 @@ namespace corsika {
       , sequence_(pl)
       , output_(out)
       , stack_(stack)
-      , forceInteraction_(false) {
+      , forceInteraction_(false)
+      , forceDecay_(false) {
     CORSIKA_LOG_INFO(c8_ascii_);
     CORSIKA_LOG_INFO("This is CORSIKA {}.{}.{}.{}", CORSIKA_RELEASE_NUMBER,
                      CORSIKA_MAJOR_NUMBER, CORSIKA_MINOR_NUMBER, CORSIKA_PATCH_NUMBER);
@@ -97,6 +98,21 @@ namespace corsika {
   template <typename TTracking, typename TProcessList, typename TOutput, typename TStack>
   inline void Cascade<TTracking, TProcessList, TOutput, TStack>::forceInteraction() {
     forceInteraction_ = true;
+    if (forceDecay_) {
+      CORSIKA_LOG_ERROR("Cannot set forceInteraction when forceDecay is already set");
+      throw std::runtime_error(
+          "Cannot set forceInteraction when forceDecay is already set");
+    }
+  }
+
+  template <typename TTracking, typename TProcessList, typename TOutput, typename TStack>
+  inline void Cascade<TTracking, TProcessList, TOutput, TStack>::forceDecay() {
+    forceDecay_ = true;
+    if (forceInteraction_) {
+      CORSIKA_LOG_ERROR("Cannot set forceDecay when forceInteraction is already set");
+      throw std::runtime_error(
+          "Cannot set forceDecay when forceInteraction is already set");
+    }
   }
 
   template <typename TTracking, typename TProcessList, typename TOutput, typename TStack>
@@ -140,6 +156,22 @@ namespace corsika {
       return;
     }
 
+    if (forceDecay_) {
+      CORSIKA_LOG_TRACE("forced decay!");
+      forceDecay_ = false; // just one decay
+      stack_view_type secondaries(particle);
+      decay(secondaries, sequence_.getInverseLifetime(particle));
+      if (secondaries.getSize() == 1 && secondaries.getProjectile().getPID() ==
+                                            secondaries.getNextParticle().getPID()) {
+        throw std::runtime_error(
+            fmt::format("Particle {} decays into itself!",
+                        get_name(secondaries.getProjectile().getPID())));
+      }
+      sequence_.doSecondaries(secondaries);
+      particle.erase(); // primary particle is done
+      return;
+    }
+
     // calculate interaction length in medium
     GrammageType const total_lambda =
         (composition.getAverageMassNumber() * constants::u) / total_cx_pre;
diff --git a/corsika/detail/modules/writers/InteractionWriter.inl b/corsika/detail/modules/writers/InteractionWriter.inl
new file mode 100644
index 0000000000000000000000000000000000000000..e4d23bd50f587f25454c73f574175a988f3d07e5
--- /dev/null
+++ b/corsika/detail/modules/writers/InteractionWriter.inl
@@ -0,0 +1,152 @@
+/*
+ * (c) Copyright 2024 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#pragma once
+
+#include <corsika/framework/core/Logging.hpp>
+
+namespace corsika {
+
+  template <typename TTracking, typename TOutput>
+  inline InteractionWriter<TTracking, TOutput>::InteractionWriter(
+      ShowerAxis const& axis, ObservationPlane<TTracking, TOutput> const& obsPlane)
+      : obsPlane_(obsPlane)
+      , showerAxis_(axis)
+      , interactionCounter_(0)
+      , showerId_(0) {}
+
+  template <typename TTracking, typename TOutput>
+  template <typename TStackView>
+  inline void InteractionWriter<TTracking, TOutput>::doSecondaries(TStackView& vS) {
+    // Dumps the stack to the output parquet stream
+    // The primary and secondaries are all written along with the slant depth
+
+    if (interactionCounter_++) { return; } // Only run on the first interaction
+
+    auto primary = vS.getProjectile();
+    auto dX = showerAxis_.getProjectedX(primary.getPosition());
+    CORSIKA_LOG_INFO("First interaction at dX {}", dX);
+    CORSIKA_LOG_INFO("Primary: {}, E_kin {}", primary.getPID(),
+                     primary.getKineticEnergy());
+
+    // get the location of the primary w.r.t. observation plane
+    Vector const displacement = primary.getPosition() - obsPlane_.getPlane().getCenter();
+
+    auto const x = displacement.dot(obsPlane_.getXAxis());
+    auto const y = displacement.dot(obsPlane_.getYAxis());
+    auto const z = displacement.dot(obsPlane_.getPlane().getNormal());
+    auto const nx = primary.getDirection().dot(obsPlane_.getXAxis());
+    auto const ny = primary.getDirection().dot(obsPlane_.getYAxis());
+    auto const nz = primary.getDirection().dot(obsPlane_.getPlane().getNormal());
+
+    auto const px = primary.getMomentum().dot(obsPlane_.getXAxis());
+    auto const py = primary.getMomentum().dot(obsPlane_.getYAxis());
+    auto const pz = primary.getMomentum().dot(obsPlane_.getPlane().getNormal());
+
+    summary_["shower_" + std::to_string(showerId_)]["pdg"] =
+        static_cast<int>(get_PDG(primary.getPID()));
+    summary_["shower_" + std::to_string(showerId_)]["name"] =
+        static_cast<std::string>(get_name(primary.getPID()));
+    summary_["shower_" + std::to_string(showerId_)]["total_energy"] =
+        (primary.getKineticEnergy() + get_mass(primary.getPID())) / 1_GeV;
+    summary_["shower_" + std::to_string(showerId_)]["kinetic_energy"] =
+        primary.getKineticEnergy() / 1_GeV;
+    summary_["shower_" + std::to_string(showerId_)]["x"] = x / 1_m;
+    summary_["shower_" + std::to_string(showerId_)]["y"] = y / 1_m;
+    summary_["shower_" + std::to_string(showerId_)]["z"] = z / 1_m;
+    summary_["shower_" + std::to_string(showerId_)]["nx"] = static_cast<double>(nx);
+    summary_["shower_" + std::to_string(showerId_)]["ny"] = static_cast<double>(ny);
+    summary_["shower_" + std::to_string(showerId_)]["nz"] = static_cast<double>(nz);
+    summary_["shower_" + std::to_string(showerId_)]["px"] =
+        static_cast<double>(px / 1_GeV);
+    summary_["shower_" + std::to_string(showerId_)]["py"] =
+        static_cast<double>(py / 1_GeV);
+    summary_["shower_" + std::to_string(showerId_)]["pz"] =
+        static_cast<double>(pz / 1_GeV);
+    summary_["shower_" + std::to_string(showerId_)]["time"] = primary.getTime() / 1_s;
+    summary_["shower_" + std::to_string(showerId_)]["slant_depth"] =
+        dX / (1_g / 1_cm / 1_cm);
+
+    uint nSecondaries = 0;
+
+    // Loop through secondaries
+    auto particle = vS.begin();
+    while (particle != vS.end()) {
+
+      // Get the momentum of the secondary, write w.r.t. observation plane
+      auto const p_2nd = particle.getMomentum();
+
+      *(output_.getWriter())
+          << showerId_ << static_cast<int>(get_PDG(particle.getPID()))
+          << static_cast<float>(p_2nd.dot(obsPlane_.getXAxis()) / 1_GeV)
+          << static_cast<float>(p_2nd.dot(obsPlane_.getYAxis()) / 1_GeV)
+          << static_cast<float>(p_2nd.dot(obsPlane_.getPlane().getNormal()) / 1_GeV)
+          << parquet::EndRow;
+
+      CORSIKA_LOG_INFO(" 2ndary: {}, E_kin {}", particle.getPID(),
+                       particle.getKineticEnergy());
+      ++particle;
+      ++nSecondaries;
+    }
+
+    summary_["shower_" + std::to_string(showerId_)]["n_secondaries"] = nSecondaries;
+  }
+
+  template <typename TTracking, typename TOutput>
+  inline void InteractionWriter<TTracking, TOutput>::startOfLibrary(
+      boost::filesystem::path const& directory) {
+    output_.initStreamer((directory / ("interactions.parquet")).string());
+
+    output_.addField("pdg", parquet::Repetition::REQUIRED, parquet::Type::INT32,
+                     parquet::ConvertedType::INT_32);
+    output_.addField("px", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+    output_.addField("py", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+    output_.addField("pz", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+
+    output_.buildStreamer();
+
+    showerId_ = 0;
+    interactionCounter_ = 0;
+    summary_ = YAML::Node();
+  }
+
+  template <typename TTracking, typename TOutput>
+  inline void InteractionWriter<TTracking, TOutput>::startOfShower(
+      unsigned int const showerId) {
+    showerId_ = showerId;
+    interactionCounter_ = 0;
+  }
+
+  template <typename TTracking, typename TOutput>
+  inline void InteractionWriter<TTracking, TOutput>::endOfShower(unsigned int const) {}
+
+  template <typename TTracking, typename TOutput>
+  inline void InteractionWriter<TTracking, TOutput>::endOfLibrary() {
+    output_.closeStreamer();
+  }
+
+  template <typename TTracking, typename TOutput>
+  inline YAML::Node InteractionWriter<TTracking, TOutput>::getConfig() const {
+    YAML::Node node;
+    node["type"] = "Interactions";
+    node["units"]["energy"] = "GeV";
+    node["units"]["length"] = "m";
+    node["units"]["time"] = "ns";
+    node["units"]["grammage"] = "g/cm^2";
+    return node;
+  }
+
+  template <typename TTracking, typename TOutput>
+  inline YAML::Node InteractionWriter<TTracking, TOutput>::getSummary() const {
+    return summary_;
+  }
+
+} // namespace corsika
diff --git a/corsika/framework/core/Cascade.hpp b/corsika/framework/core/Cascade.hpp
index d4b7d9774c7f951dedb33db7df5c36e4daaf9762..87bea31427df998270957159e59d87e4b4a5ccf6 100644
--- a/corsika/framework/core/Cascade.hpp
+++ b/corsika/framework/core/Cascade.hpp
@@ -92,9 +92,18 @@ namespace corsika {
      * Force an interaction of the top particle of the stack at its current position.
      * Note that setNodes() or an equivalent procedure needs to be called first if you
      * want to call forceInteraction() for the primary interaction.
+     * Incompatible with forceDecay()
      */
     void forceInteraction();
 
+    /**
+     * Force an decay of the top particle of the stack at its current position.
+     * Note that setNodes() or an equivalent procedure needs to be called first if you
+     * want to call forceDecay() for the primary interaction.
+     * Incompatible with forceInteraction()
+     */
+    void forceDecay();
+
   private:
     /**
      * The Step function is executed for each particle from the
@@ -122,6 +131,7 @@ namespace corsika {
     TStack& stack_;
     default_prng_type& rng_ = RNGManager<>::getInstance().getRandomStream("cascade");
     bool forceInteraction_;
+    bool forceDecay_;
     unsigned int count_ = 0;
 
     // but this here temporarily. Should go into dedicated file later:
diff --git a/corsika/modules/writers/EnergyLossWriter.hpp b/corsika/modules/writers/EnergyLossWriter.hpp
index 6f42de944526bcc32b6f33f73c83401c8e66df09..d0a201062dffcab7e458219b12b28f6434c5a61d 100644
--- a/corsika/modules/writers/EnergyLossWriter.hpp
+++ b/corsika/modules/writers/EnergyLossWriter.hpp
@@ -161,7 +161,7 @@ namespace corsika {
     /**
      * Return a summary.
      */
-    YAML::Node getSummary() const;
+    YAML::Node getSummary() const override;
 
     /**
      * Return the configuration of this output.
diff --git a/corsika/modules/writers/InteractionWriter.hpp b/corsika/modules/writers/InteractionWriter.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..86527001f7a169df3924e200c7aee48ca4843b95
--- /dev/null
+++ b/corsika/modules/writers/InteractionWriter.hpp
@@ -0,0 +1,68 @@
+/*
+ * (c) Copyright 2024 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#pragma once
+
+#include <corsika/framework/process/SecondariesProcess.hpp>
+
+#include <corsika/media/ShowerAxis.hpp>
+
+#include <corsika/modules/ObservationPlane.hpp>
+
+#include <corsika/output/BaseOutput.hpp>
+#include <corsika/output/ParquetStreamer.hpp>
+
+namespace corsika {
+
+  template <typename TTracking, typename TOutput>
+  class InteractionWriter
+      : public SecondariesProcess<InteractionWriter<TTracking, TOutput>>,
+        public BaseOutput {
+
+  public:
+    /**
+     *
+     * @param axis - axis used for calculating slant depth of interaction
+     * @param obsPlane - used to define the location of the particles
+     */
+    InteractionWriter(ShowerAxis const& axis,
+                      ObservationPlane<TTracking, TOutput> const& obsPlane);
+
+    /**
+     *
+     * @tparam TStackView
+     */
+    template <typename TStackView>
+    void doSecondaries(TStackView&);
+
+    void startOfLibrary(boost::filesystem::path const&) override;
+    void endOfLibrary() override;
+
+    void startOfShower(unsigned int const) override;
+    void endOfShower(unsigned int const) override;
+
+    YAML::Node getConfig() const override;
+    YAML::Node getSummary() const override;
+
+    auto getInteractionCounter() { return interactionCounter_; }
+
+  private:
+    ObservationPlane<TTracking, TOutput> const obsPlane_;
+    ShowerAxis const& showerAxis_;
+
+    unsigned int interactionCounter_;
+    unsigned int showerId_;
+
+    ParquetStreamer output_;
+    YAML::Node summary_;
+
+  }; // namespace corsika
+
+} // namespace corsika
+
+#include <corsika/detail/modules/writers/InteractionWriter.inl>
diff --git a/corsika/output/OutputManager.hpp b/corsika/output/OutputManager.hpp
index 6befc54ff741c55e63030237bdff2542d0312b81..1359b0d959322460ea9ab3f49621d74e9cfac6a1 100644
--- a/corsika/output/OutputManager.hpp
+++ b/corsika/output/OutputManager.hpp
@@ -35,8 +35,10 @@ namespace corsika {
     /**
      * Construct an OutputManager instance with a name in a given directory.
      *
-     * @param name    The name of this output collection.
-     * @param dir     The directory where the output directory will be stored.
+     * @param name        The name of this output collection.
+     * @param vseed       The seed for the simulation (written to summary file)
+     * @param input_args  The command line arguments at runtime (written to summary file)
+     * @param dir         The directory where the output directory will be stored.
      */
     OutputManager(std::string const& name, const long& vseed,
                   std::string const& input_args, boost::filesystem::path const& dir);
diff --git a/python/corsika/io/outputs/__init__.py b/python/corsika/io/outputs/__init__.py
index 54731dd42d775073d5d15e3d21e914706b5a7755..7ea762ac7fbcd666d5de961c99e0785bb9acd699 100644
--- a/python/corsika/io/outputs/__init__.py
+++ b/python/corsika/io/outputs/__init__.py
@@ -9,6 +9,7 @@
 
 from .bethe_bloch import BetheBlochPDG
 from .energy_loss import EnergyLoss
+from .interaction import Interactions
 from .longitudinal_profile import LongitudinalProfile
 from .observation_plane import ObservationPlane
 from .output import Output
@@ -28,4 +29,5 @@ __all__ = [
     "RadioProcess",
     "PrimaryParticle",
     "Particle",
+    "Interactions",
 ]
diff --git a/python/corsika/io/outputs/interaction.py b/python/corsika/io/outputs/interaction.py
new file mode 100644
index 0000000000000000000000000000000000000000..21a1b1d54650066d2d3e664238e8dc3f6286d154
--- /dev/null
+++ b/python/corsika/io/outputs/interaction.py
@@ -0,0 +1,133 @@
+"""
+ Read data written by InteractionWriter.
+
+ (c) Copyright 2024 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+import logging
+import os.path as op
+from typing import Any
+
+import pyarrow.parquet as pq
+import yaml
+
+from ..converters import arrow_to_numpy
+from .output import Output
+from .primary import Particle
+
+
+class FirstInteraction(Particle):
+    """
+    Simple class that holds the information of the first interaction
+    This is a complete wrapper of the `Particle` class at the moment
+    but is "new" so that it can be distinguished using `type(thing)`
+    """
+
+    def __init__(self, prop_dict: dict):
+        """
+        Parameters
+        ----------
+        prop_dict: dict
+            the entry from the output yaml file for a single shower
+        """
+        Particle.__init__(self, prop_dict)
+
+        # need to define for momentum prop
+        self.px = self.py = self.pz = None
+
+    def __repr__(self) -> str:
+        """
+        Return a string representation of this class.
+        """
+        out_str = "FirstInteraction:\n"
+        for key in self.__dict__.keys():
+            out_str += f"\t{key}: {self.__dict__[key]}\n"
+        return out_str
+
+    @property
+    def momentum(self) -> list:
+        return [self.px, self.py, self.pz]
+
+
+class Interactions(Output):
+    """
+    Reads the interactions and secondaries
+    """
+
+    def __init__(self, path: str):
+        """
+        Load the particle data into a parquet table.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing this output.
+        """
+        super().__init__(path)
+
+        # try and load our data
+        try:
+            self.__data = pq.read_table(op.join(path, "interactions.parquet"))
+            with open(op.join(path, "summary.yaml"), "r") as f:
+                temp = yaml.load(f, Loader=yaml.Loader)
+                self.__projectiles = [FirstInteraction(x) for x in temp.values()]
+        except Exception as e:
+            logging.getLogger("corsika").warn(
+                f"An error occured loading an Interaction: {e}"
+            )
+
+    @property
+    def projectiles(self) -> list:
+        return self.__projectiles
+
+    def is_good(self) -> bool:
+        """
+        Returns true if this output has been read successfully
+        and has the correct files/state/etc.
+
+        Returns
+        -------
+        bool:
+            True if this is a good output.
+        """
+        return self.__data is not None
+
+    def astype(self, dtype: str = "pandas", **kwargs: Any) -> Any:
+        """
+        Load the particle data of the particle interactions.
+
+        All additional keyword arguments are passed to `parquet.read_table`
+
+        Parameters
+        ----------
+        dtype: str
+            The data format to return the data in (i.e. numpy, pandas, etc.)
+
+        Returns
+        -------
+        Any:
+            The return type of this method is determined by `dtype`.
+        """
+        if dtype == "arrow":
+            return self.__data
+        elif dtype == "pandas":
+            return self.__data.to_pandas()
+        elif dtype == "numpy":
+            return arrow_to_numpy.convert_to_numpy(self.__data)
+        else:
+            raise ValueError(
+                (
+                    f"Unknown format '{dtype}' for Interaction. "
+                    "We currently only support ['arrow', 'pandas', 'numpy']."
+                )
+            )
+
+    def __repr__(self) -> str:
+        """
+        Return a string representation of this class.
+        """
+        return f"Interaction('{self.config['name']}')"
diff --git a/python/examples/first_interactions.py b/python/examples/first_interactions.py
new file mode 100644
index 0000000000000000000000000000000000000000..d86d8d70dd7cbe21adad95ac6eed4d71b8dfa97a
--- /dev/null
+++ b/python/examples/first_interactions.py
@@ -0,0 +1,114 @@
+#!/usr/bin/python3
+
+import argparse
+import os
+
+import matplotlib.pyplot as plt
+import numpy as np
+import particle
+
+import corsika
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+parser = argparse.ArgumentParser()
+parser.add_argument(
+    "--input-dir", required=True, help="output directory of the CORSIKA 8 simulation"
+)
+parser.add_argument(
+    "--output-dir",
+    default=os.path.join(here, "example_plots"),
+    help="output directory for plots",
+)
+args = parser.parse_args()
+
+if not os.path.isdir(args.output_dir):
+    print("Making directory", args.output_dir)
+    os.makedirs(args.output_dir)
+
+# Load the shower simulation output
+lib = corsika.Library(args.input_dir)
+
+# Load the primary particle information from the shower
+interactions = lib.get("interactions").data
+interaction_config = lib.get("interactions").config
+projectiles = lib.get("interactions").projectiles
+
+shower_ids = np.unique(interactions["shower"])
+
+ncols = 2
+nrows = len(shower_ids)
+fig, ax = plt.subplots(
+    ncols=ncols,
+    nrows=nrows,
+    figsize=(ncols * 8, nrows * 8),
+    gridspec_kw={"wspace": 0.2, "hspace": 0.3},
+)
+ax = np.atleast_2d(ax)
+if ax.shape == (1, 2):
+    ax = ax.T
+
+for ish, sh_id in enumerate(shower_ids):
+
+    # Find all of the secondary particles associated with this shower
+    daughters = interactions[interactions["shower"] == sh_id]
+
+    mother = projectiles[ish]
+    norm = np.sqrt(sum(np.array(mother.momentum) ** 2))  # total momentum
+
+    print("Plotting shower", sh_id)
+    print(mother)
+
+    px, py, pz = mother.momentum
+
+    mother_name = f"${particle.Particle.from_pdgid(mother.pdg).latex_name}$"
+    ax[0, sh_id].plot([-px, 0], [-pz, 0], label=mother_name)
+    ax[1, sh_id].plot([-py, 0], [-pz, 0], label=mother_name)
+
+    # Get daughters, filter out zero-momentum particles
+    p_tot = np.sqrt(daughters["px"] ** 2 + daughters["py"] ** 2 + daughters["pz"] ** 2)
+    daughters = daughters[p_tot > 0]
+
+    # keep track of largest values
+    max_pz = abs(pz)
+    max_pxy = max(abs(px), abs(py))
+
+    # plot the daughter particles
+    for i in range(len(daughters)):
+        pz = daughters["pz"].iloc[i]
+        px = daughters["px"].iloc[i]
+        py = daughters["py"].iloc[i]
+
+        pdg = int(daughters["pdg"].iloc[i])
+        try:
+            name = f"${particle.Particle.from_pdgid(pdg).latex_name}$"
+        except Exception:
+            name = "Unknown"
+
+        ax[0, sh_id].plot([px, 0], [pz, 0], label=name)
+        ax[1, sh_id].plot([py, 0], [pz, 0], label=name)
+
+        max_pz = max(max_pz, abs(pz))
+        max_pxy = max(max_pxy, max(abs(px), abs(py)))
+
+    # set plotting style and names
+    for i in range(2):
+        ax[i, sh_id].legend()
+
+        ymin, ymax = ax[i, sh_id].get_ylim()
+        ax[i, sh_id].set_ylim(-max_pz, max_pz)
+        ax[i, sh_id].set_xlim(-max_pxy, max_pxy)
+
+        unit_energy_str = interaction_config["units"]["energy"]
+        title = f"Mother: {mother.kinetic_energy:0.2e} {unit_energy_str}"
+        title += ", " + mother_name
+        title += f"\n{mother.n_secondaries} secondaries"
+        ax[i, sh_id].set_title(title)
+        ax[i, sh_id].set_ylabel(f"$P_z$ ({unit_energy_str})")
+
+    ax[0, sh_id].set_xlabel(f"$P_x$ ({unit_energy_str})")
+    ax[1, sh_id].set_xlabel(f"$P_y$ ({unit_energy_str})")
+
+plot_path = os.path.join(args.output_dir, "interactions.png")
+print("Saving", plot_path)
+fig.savefig(plot_path, bbox_inches="tight")
diff --git a/tests/framework/testCascade.cpp b/tests/framework/testCascade.cpp
index c6aff27cf29a59676d1b448ceb08c2ff8c92ad19..63da8381b2f93dcdf426580d202f7c3a8482e61d 100644
--- a/tests/framework/testCascade.cpp
+++ b/tests/framework/testCascade.cpp
@@ -184,6 +184,32 @@ private:
   int calls_ = 0;
 };
 
+class DummyDecay : public DecayProcess<DummyDecay> {
+  // Dummy decay process that puts pre-predefined particles on stack
+public:
+  DummyDecay(Code code, HEPEnergyType ek, DirectionVector dir)
+      : code_(code)
+      , e0_(ek)
+      , dir_(dir) {}
+
+  Code code_;
+  HEPEnergyType e0_;
+  DirectionVector dir_;
+
+  template <typename Particle>
+  TimeType getLifetime(Particle&) const {
+    return 1_s;
+  }
+
+  template <typename TView>
+  void doDecay(TView& view) const {
+    auto projectile = view.getProjectile();
+    auto const dir = projectile.getMomentum().normalized();
+    projectile.addSecondary(std::make_tuple(code_, e0_, dir_));
+    CORSIKA_LOG_INFO("Particle stack size {}", view.getSize());
+  }
+};
+
 TEST_CASE("Cascade", "[Cascade]") {
 
   logging::set_level(logging::level::info);
@@ -196,19 +222,24 @@ TEST_CASE("Cascade", "[Cascade]") {
   auto env = make_dummy_env();
   auto const& rootCS = env.getCoordinateSystem();
 
+  // Properties of primary particle
+  auto const primCode = Code::Electron;
+  auto const primEk = E0 - get_mass(Code::Electron);
+  auto const primDir = DirectionVector(rootCS, {0, 0, -1});
+
   StackInspector<TestCascadeStack> stackInspect(100, true, E0);
   NullModel nullModel;
+  DummyDecay decay(primCode, primEk, primDir); // decay product will be primary
 
   HEPEnergyType const Ecrit = 85_MeV;
   ProcessSplit split;
   ProcessCut cut(Ecrit);
-  auto sequence = make_sequence(nullModel, stackInspect, split, cut);
+  auto sequence = make_sequence(nullModel, decay, stackInspect, split, cut);
   TestCascadeStack stack;
+
   stack.clear();
-  stack.addParticle(std::make_tuple(Code::Electron,
-                                    E0 - get_mass(Code::Electron), // Ekin
-                                    DirectionVector(rootCS, {0, 0, -1}),
-                                    Point(rootCS, {0_m, 0_m, 10_km}), 0_ns));
+  stack.addParticle(
+      std::make_tuple(primCode, primEk, primDir, Point(rootCS, {0_m, 0_m, 10_km}), 0_ns));
 
   DummyTracking tracking;
   DummyOutputManager output;
@@ -235,6 +266,36 @@ TEST_CASE("Cascade", "[Cascade]") {
     CHECK(stack.getSize() == 13);
     CHECK(split.getCalls() == 2047);
   }
+
+  SECTION("double_forcing_1") {
+    EAS.forceInteraction();
+    REQUIRE_THROWS(EAS.forceDecay());
+  }
+
+  SECTION("double_forcing_2") {
+    EAS.forceDecay();
+    REQUIRE_THROWS(EAS.forceInteraction());
+  }
+
+  SECTION("forced decay") {
+    // Put anything new so that it won't trigger "decays into self" error
+    stack.clear();
+    stack.addParticle(std::make_tuple(Code::PiPlus,
+                                      0.5_GeV, // Ekin
+                                      DirectionVector(rootCS, {0, 0, -1}),
+                                      Point(rootCS, {0_m, 0_m, 10_km}), 0_ns));
+
+    CHECK(tracking.getCalls() == 0);
+    EAS.forceDecay();
+    CHECK(tracking.getCalls() == 0);
+    CHECK(stack.getEntries() == 1);
+    CHECK(stack.getSize() == 1);
+    EAS.run();
+    CHECK(tracking.getCalls() == 2047);
+    CHECK(stack.getEntries() == 0);
+    CHECK(stack.getSize() == 14);
+    CHECK(split.getCalls() == 2047);
+  }
 }
 
 TEST_CASE("Cascade Zero Interaction", "[Cascade]") {
@@ -273,4 +334,4 @@ TEST_CASE("Cascade Zero Interaction", "[Cascade]") {
   // expect 100 calls. if it would be less, this means that the particle has been erased
   // early by the Cascade.inl algorithm
   CHECK(counter.getCalls() == 100);
-}
\ No newline at end of file
+}
diff --git a/tests/output/CMakeLists.txt b/tests/output/CMakeLists.txt
index f85f95aa71a5aeb00e768eb5aa85286ec3f67466..3b954de5f94129ad897fd8785a8aa676b6d75e01 100644
--- a/tests/output/CMakeLists.txt
+++ b/tests/output/CMakeLists.txt
@@ -1,7 +1,8 @@
 set (test_output_sources
+  testDummyOutputManager.cpp
+  testInteractionWriter.cpp
   TestMain.cpp
   testOutputManager.cpp
-  testDummyOutputManager.cpp
   testParquetStreamer.cpp
   testWriterObservationPlane.cpp
   testWriterTracks.cpp
diff --git a/tests/output/testInteractionWriter.cpp b/tests/output/testInteractionWriter.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..d9a45e41883643dbf8a841a609dd137f43be022d
--- /dev/null
+++ b/tests/output/testInteractionWriter.cpp
@@ -0,0 +1,124 @@
+/*
+ * (c) Copyright 2024 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#include <boost/filesystem.hpp>
+
+#include <catch2/catch.hpp>
+
+#include <corsika/modules/writers/InteractionWriter.hpp>
+
+#include <corsika/framework/core/PhysicalUnits.hpp>
+#include <corsika/framework/geometry/Point.hpp>
+#include <corsika/framework/geometry/RootCoordinateSystem.hpp>
+#include <corsika/framework/geometry/Vector.hpp>
+
+#include <corsika/media/Environment.hpp>
+#include <corsika/media/HomogeneousMedium.hpp>
+
+#include <corsika/setup/SetupTrajectory.hpp>
+
+#include <SetupTestStack.hpp>
+#include <SetupTestTrajectory.hpp>
+
+using namespace corsika;
+
+using DummyEnvironmentInterface = IMediumPropertyModel<IMagneticFieldModel<IMediumModel>>;
+using DummyEnvironment = Environment<DummyEnvironmentInterface>;
+
+TEST_CASE("InteractionWriter", "process") {
+  logging::set_level(logging::level::info);
+
+  auto env = std::make_unique<Environment<IMediumModel>>();
+  auto& universe = *(env->getUniverse());
+  CoordinateSystemPtr const& rootCS = env->getCoordinateSystem();
+  auto theMedium = Environment<IMediumModel>::createNode<Sphere>(
+      Point{rootCS, 0_m, 0_m, 0_m}, 1_km * std::numeric_limits<double>::infinity());
+  using MyHomogeneousModel = HomogeneousMedium<IMediumModel>;
+  const auto density = 1_kg / (1_m * 1_m * 1_m);
+  theMedium->setModelProperties<MyHomogeneousModel>(
+      density, NuclearComposition({Code::Nitrogen}, {1.}));
+  universe.addChild(std::move(theMedium));
+
+  std::string const outputDir = "./output_interactions";
+
+  // preparation
+  if (boost::filesystem::exists(outputDir)) { boost::filesystem::remove_all(outputDir); }
+  boost::filesystem::create_directory(outputDir);
+
+  // setup empty particle stack
+  test::Stack stack;
+  stack.clear();
+
+  // Make shower axis
+  auto start = Point(rootCS, -1_m, 0_m, 0_m);
+  auto stop = Point(rootCS, 1_m, 0_m, 0_m);
+  auto length = stop - start;
+  ShowerAxis axis(start, length, *env);
+
+  Plane const plane(Point(rootCS, {0_m, 0_m, 0_m}),
+                    DirectionVector(rootCS, {0., 0., 1.}));
+
+  ObservationPlane<setup::Tracking, ParticleWriterParquet> obsPlane{
+      plane, DirectionVector(rootCS, {1., 0., 0.}),
+      true, // plane should "absorb" particles
+      false};
+
+  InteractionWriter<setup::Tracking, ParticleWriterParquet> writer(axis, obsPlane);
+  writer.startOfLibrary(outputDir);
+  writer.startOfShower(0);
+
+  CHECK(!writer.getInteractionCounter());
+
+  // add primary particle to stack
+  auto particle = stack.addParticle(std::make_tuple(Code::Proton, 10_GeV,
+                                                    DirectionVector(rootCS, {1, 0, 0}),
+                                                    Point(rootCS, 0_m, 0_m, 0_m), 0_ns));
+
+  test::StackView view(particle);
+  auto projectile = view.getProjectile();
+
+  std::vector<Code> const particleList = {Code::PiPlus,   Code::PiMinus, Code::KPlus,
+                                          Code::KMinus,   Code::K0Long,  Code::K0Short,
+                                          Code::Electron, Code::MuPlus,  Code::NuE,
+                                          Code::Neutron,  Code::NuMu};
+
+  for (auto proType : particleList)
+    projectile.addSecondary(
+        std::make_tuple(proType, 1_GeV, DirectionVector(rootCS, {1, 0, 0})));
+
+  // pre-check on stack size
+  CHECK(view.getEntries() == 11);
+  CHECK(stack.getEntries() == 12);
+
+  // correctly initialized counter
+  CHECK(!writer.getInteractionCounter());
+
+  // run the process and check for consistency
+  writer.doSecondaries(view);
+  CHECK(1 == writer.getInteractionCounter());
+  CHECK(view.getEntries() == 11);
+  CHECK(stack.getEntries() == 12);
+
+  // ensure the counter bumps
+  writer.doSecondaries(view);
+  CHECK(2 == writer.getInteractionCounter());
+
+  writer.endOfShower(0);
+  writer.endOfLibrary();
+
+  CHECK(boost::filesystem::exists(outputDir + "/interactions.parquet"));
+
+  auto const config = writer.getConfig();
+  CHECK(config["type"].as<std::string>() == "Interactions");
+
+  auto const summary = writer.getSummary();
+  CHECK(summary["shower_0"]["n_secondaries"].as<int>() == 11);
+
+  // clean up
+  if (boost::filesystem::exists(outputDir)) { boost::filesystem::remove_all(outputDir); }
+}