diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 79dff3cb2cbf8a0d7a3e704675510136d323343b..a0ba22355bd5e7a684a6e42e32390219ef57eb37 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -64,6 +64,7 @@ common:detector:
 
 include: 
   - local: 'benchmarks/ecal/config.yml'
+  - local: 'benchmarks/far_forward/config.yml'
   - local: 'benchmarks/track_finding/config.yml'
   - local: 'benchmarks/track_fitting/config.yml'
   - local: 'benchmarks/tracking/config.yml'
diff --git a/benchmarks/far_forward/config.yml b/benchmarks/far_forward/config.yml
new file mode 100644
index 0000000000000000000000000000000000000000..05e79484c57dc0cc1bab6e24d440f26b5bfa8244
--- /dev/null
+++ b/benchmarks/far_forward/config.yml
@@ -0,0 +1,6 @@
+tracking_ff:
+  extends: .rec_benchmark
+  stage: run
+  timeout: 24 hours
+  script:
+    - bash benchmarks/far_forward/dummy_ff.sh
diff --git a/benchmarks/far_forward/dummy_ff.sh b/benchmarks/far_forward/dummy_ff.sh
new file mode 100644
index 0000000000000000000000000000000000000000..cc759747e7919cb86fda1c5039aa0e15c9732dce
--- /dev/null
+++ b/benchmarks/far_forward/dummy_ff.sh
@@ -0,0 +1,135 @@
+#!/bin/bash
+
+function print_the_help {
+  echo "USAGE: ${0} [--rec-only]  "
+  echo "  OPTIONS: "
+  echo "            --rec-only   only run gaudi reconstruction part"
+  exit 
+}
+
+export PBEAM="100.0"
+export XANGLE="-0.025"
+
+REC_ONLY=
+ANALYSIS_ONLY=
+PARTICLE="proton"
+
+POSITIONAL=()
+while [[ $# -gt 0 ]]
+do
+  key="$1"
+
+  case $key in
+    -h|--help)
+      shift # past argument
+      print_the_help
+      ;;
+    --rec-only)
+      REC_ONLY=1
+      shift # past value
+      ;;
+    --ana-only)
+      ANALYSIS_ONLY=1
+      shift # past value
+      ;;
+    --particle)
+      PARTICLE=$2
+      shift # past key
+      shift # past value
+      ;;
+    *)    # unknown option
+      #POSITIONAL+=("$1") # save it in an array for later
+      echo "unknown option $1"
+      print_the_help
+      shift # past argument
+      ;;
+  esac
+done
+set -- "${POSITIONAL[@]}" # restore positional parameters
+
+
+print_env.sh
+
+## To run the reconstruction, we need the following global variables:
+## - JUGGLER_INSTALL_PREFIX:   Install prefix for Juggler (simu/recon)
+## - JUGGLER_DETECTOR:         the detector package we want to use for this benchmark
+## - JUGGLER_DETECTOR_VERSION: the detector package we want to use for this benchmark
+## - DETECTOR_PATH:            full path to the detector definitions
+##
+## You can ready options/env.sh for more in-depth explanations of the variables
+## and how they can be controlled.
+export DETECTOR_PATH=${DETECTOR_PATH}
+
+if [[ ! -n  "${JUGGLER_N_EVENTS}" ]] ; then 
+  export JUGGLER_N_EVENTS=100
+fi
+export JUGGLER_N_EVENTS=$(expr ${JUGGLER_N_EVENTS} \* 1)
+
+
+export JUGGLER_FILE_NAME_TAG="genparts"
+export JUGGLER_GEN_FILE="${JUGGLER_FILE_NAME_TAG}.hepmc"
+
+export JUGGLER_SIM_FILE="sim_${JUGGLER_FILE_NAME_TAG}.root"
+export JUGGLER_REC_FILE="rec_${JUGGLER_FILE_NAME_TAG}.root"
+
+echo "JUGGLER_N_EVENTS = ${JUGGLER_N_EVENTS}"
+echo "JUGGLER_DETECTOR = ${JUGGLER_DETECTOR}"
+
+
+if [[ -z "${REC_ONLY}" && -z "${ANALYSIS_ONLY}" ]] ;
+then
+
+  python benchmarks/far_forward/scripts/gen_particles.py \
+    ${JUGGLER_FILE_NAME_TAG}.hepmc \
+    -n $JUGGLER_N_EVENTS -s 1 \
+    --thetamin 0.0 --thetamax 0.030 \
+    --pmin 40 --pmax 100 \
+    --particles ${PARTICLE}
+  ## generate the input events
+  if [[ "$?" -ne "0" ]] ; then
+    echo "ERROR running script"
+    exit 1
+  fi
+
+  echo "Running geant4 simulation"
+  ## run geant4 simulations
+  npsim --runType batch \
+    --part.minimalKineticEnergy 1000*GeV  \
+    -v WARNING \
+    --numberOfEvents ${JUGGLER_N_EVENTS} \
+    --compactFile ${DETECTOR_PATH}/${JUGGLER_DETECTOR}.xml \
+    --inputFiles  ${JUGGLER_FILE_NAME_TAG}.hepmc \
+    --outputFile  ${JUGGLER_SIM_FILE}
+  if [[ "$?" -ne "0" ]] ; then
+    echo "ERROR running script"
+    exit 1
+  fi
+fi
+
+rootls -t ${JUGGLER_SIM_FILE}
+
+if [[ -z "${ANALYSIS_ONLY}" ]] ;
+then
+  # Need to figure out how to pass file name to juggler from the commandline
+  xenv -x ${JUGGLER_INSTALL_PREFIX}/Juggler.xenv gaudirun.py benchmarks/far_forward/options/dummy_ff.py
+  if [[ "$?" -ne "0" ]] ; then
+    echo "ERROR running juggler"
+    exit 1
+  fi
+fi
+
+mkdir -p results/far_forward
+
+root -b -q "benchmarks/far_forward/scripts/rec_ff.cxx(\"${JUGGLER_REC_FILE}\")"
+if [[ "$?" -ne "0" ]] ; then
+  echo "ERROR running root script"
+  exit 1
+fi
+
+root_filesize=$(stat --format=%s "${JUGGLER_REC_FILE}")
+if [[ "${JUGGLER_N_EVENTS}" -lt "500" ]] ; then 
+  # file must be less than 10 MB to upload
+  if [[ "${root_filesize}" -lt "10000000" ]] ; then 
+    cp ${JUGGLER_REC_FILE} results/.
+  fi
+fi
diff --git a/benchmarks/far_forward/options/dummy_ff.py b/benchmarks/far_forward/options/dummy_ff.py
new file mode 100644
index 0000000000000000000000000000000000000000..17202b8ffb071aa4429d26a88501557e4e44dc0f
--- /dev/null
+++ b/benchmarks/far_forward/options/dummy_ff.py
@@ -0,0 +1,58 @@
+from Gaudi.Configuration import *
+
+from Configurables import ApplicationMgr, EICDataSvc, PodioOutput, GeoSvc
+from GaudiKernel import SystemOfUnits as units
+
+detector_name = "athena"
+if "JUGGLER_DETECTOR" in os.environ :
+  detector_name = str(os.environ["JUGGLER_DETECTOR"])
+
+detector_path = ""
+if "DETECTOR_PATH" in os.environ :
+  detector_path = str(os.environ["DETECTOR_PATH"])
+
+# todo add checks
+input_sim_file  = str(os.environ["JUGGLER_SIM_FILE"])
+output_rec_file = str(os.environ["JUGGLER_REC_FILE"])
+n_events = str(os.environ["JUGGLER_N_EVENTS"])
+
+podioevent   = EICDataSvc("EventDataSvc", inputs=[input_sim_file], OutputLevel=WARNING)
+
+from Configurables import PodioInput
+from Configurables import Jug__Base__InputCopier_dd4pod__Geant4ParticleCollection_dd4pod__Geant4ParticleCollection_ as MCCopier
+
+from Configurables import Jug__Reco__DummyFarForwardParticles as DummyFFRec
+
+algorithms = [ ]
+
+podioinput = PodioInput("PodioReader", 
+                        collections=["mcparticles"])#, OutputLevel=DEBUG)
+algorithms.append( podioinput )
+
+## copiers to get around input --> output copy bug. Note the "2" appended to the output collection.
+copier = MCCopier("MCCopier", 
+        inputCollection="mcparticles", 
+        outputCollection="mcparticles2") 
+algorithms.append( copier )
+
+ff_rec = DummyFFRec("DummyFFRec", 
+        inputMCParticles="mcparticles", 
+        outputParticles="ReconstructedParticles",
+        OutputLevel=DEBUG) 
+algorithms.append( ff_rec )
+
+
+out = PodioOutput("out", filename=output_rec_file)
+out.outputCommands = ["keep *", 
+        "drop mcparticles"
+        ]
+algorithms.append(out)
+
+ApplicationMgr(
+    TopAlg = algorithms,
+    EvtSel = 'NONE',
+    EvtMax   = n_events,
+    ExtSvc = [podioevent,geo_service],
+    OutputLevel=WARNING
+ )
+
diff --git a/benchmarks/far_forward/scripts/gen_particles.py b/benchmarks/far_forward/scripts/gen_particles.py
new file mode 100644
index 0000000000000000000000000000000000000000..c8afd9acfb2f0ebaa309a06e693614dae275d6a3
--- /dev/null
+++ b/benchmarks/far_forward/scripts/gen_particles.py
@@ -0,0 +1,111 @@
+import os
+from pyHepMC3 import HepMC3 as hm
+import numpy as np
+import argparse
+
+
+PARTICLES = {
+    "pion0": (111, 0.1349766),       # pi0
+    "pion+": (211, 0.13957018),      # pi+
+    "pion-": (-211, 0.13957018),     # pi-
+    "kaon0": (311, 0.497648),        # K0
+    "kaon+": (321, 0.493677),        # K+
+    "kaon-": (-321, 0.493677),       # K-
+    "proton": (2212, 0.938272),      # proton
+    "neutron": (2112, 0.939565),     # neutron
+    "electron": (11, 0.51099895e-3), # electron
+    "positron": (-11, 0.51099895e-3),# positron
+    "photon": (22, 0),               # photon
+}
+
+
+def gen_event(p, theta, phi, pid, mass, xangle):
+    evt = hm.GenEvent(momentum_unit=hm.Units.MomentumUnit.GEV, length_unit=hm.Units.LengthUnit.MM)
+    # final state
+    state = 1
+    e0 = np.sqrt(p*p + mass*mass)
+    pxc = p * np.cos(phi)*np.sin(theta)
+    pyc = p * np.sin(phi)*np.sin(theta)
+    pzc = p * np.cos(theta)
+
+    sa = np.sin(xangle)
+    ca = np.cos(xangle)
+
+    px = pxc * ca + pzc * sa
+    py = pyc
+    pz = - pxc * sa + pzc * ca
+
+    # beam
+    pbeam = hm.GenParticle(hm.FourVector(0, 0, 0, 0.938272), 2212, 4)
+    ebeam = hm.GenParticle(hm.FourVector(0, 0, e0, np.sqrt(e0*e0 + 0.511e-3*0.511e-3)), 11, 4)
+
+    # out particle
+    hout = hm.GenParticle(hm.FourVector(px, py, pz, e0), pid, state)
+
+    # vertex
+    vert = hm.GenVertex()
+    vert.add_particle_in(ebeam)
+    vert.add_particle_in(pbeam)
+    vert.add_particle_out(hout)
+    evt.add_vertex(vert)
+    return evt
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+
+    parser.add_argument('output', help='path to the output file')
+    parser.add_argument('-n', type=int, default=1000, dest='nev', help='number of events to generate')
+    parser.add_argument('-s', type=int, default=-1, dest='seed', help='seed for random generator')
+    parser.add_argument('--parray', type=str, default="", dest='parray',
+                        help='an array of momenta in GeV, separated by \",\"')
+    parser.add_argument('--xangle', type=float, default=-0.025, dest='xangle', help="crossing angle")
+    parser.add_argument('--pmin', type=float, default=8.0, dest='pmin', help='minimum momentum in GeV')
+    parser.add_argument('--pmax', type=float, default=100.0, dest='pmax', help='maximum momentum in GeV')
+    parser.add_argument('--thetamin', type=float, default=-4, dest='thetamin', help='minimum angle (about the ion direction)')
+    parser.add_argument('--thetamax', type=float, default=4, dest='thetamax', help='maximum angle (about the ion direction)')
+    parser.add_argument('--phmin', type=float, default=0.0, dest='phmin', help='minimum angle in degree')
+    parser.add_argument('--phmax', type=float, default=360.0, dest='phmax', help='maximum angle in degree')
+    parser.add_argument('--particles', type=str, default='electron', dest='particles',
+                        help='particle names, support {}'.format(list(PARTICLES.keys())))
+
+    args = parser.parse_args()
+
+    if args.seed < 0:
+        args.seed = os.environ.get('SEED', int.from_bytes(os.urandom(4), byteorder='big', signed=False))
+    print("Random seed is {}".format(args.seed))
+    np.random.seed(args.seed)
+
+    output = hm.WriterAscii(args.output);
+    if output.failed():
+        print("Cannot open file \"{}\"".format(args.output))
+        sys.exit(2)
+
+    # build particle info
+    parts = []
+    for pid in args.particles.split(','):
+        pid = pid.strip()
+        if pid not in PARTICLES.keys():
+            print('pid {:d} not found in dictionary, ignored.'.format(pid))
+            continue
+        parts.append(PARTICLES[pid])
+
+    # p values
+    pvals = np.random.uniform(args.pmin, args.pmax, args.nev) if not args.parray else \
+            np.random.choice([float(p.strip()) for p in args.parray.split(',')], args.nev)
+    thvals = np.arccos(np.random.uniform(np.cos(args.thetamin), np.cos(args.thetamax), args.nev))
+    phivals = np.random.uniform(args.phmin, args.phmax, args.nev)/180.*np.pi
+    partvals = [parts[i] for i in np.random.choice(len(parts), args.nev)]
+
+    count = 0
+    for p, theta, phi, (pid, mass) in zip(pvals, thvals, phivals, partvals):
+        if (count % 1000 == 0):
+            print("Generated {} events".format(count), end='\r')
+        evt = gen_event(p, theta, phi, pid, mass, args.xangle)
+        output.write_event(evt)
+        evt.clear()
+        count += 1
+
+    print("Generated {} events".format(args.nev))
+    output.close()
+
diff --git a/benchmarks/far_forward/scripts/rec_ff.cxx b/benchmarks/far_forward/scripts/rec_ff.cxx
new file mode 100644
index 0000000000000000000000000000000000000000..1d29f2cf4659636c9d239f2ac85fe35155088edc
--- /dev/null
+++ b/benchmarks/far_forward/scripts/rec_ff.cxx
@@ -0,0 +1,147 @@
+#include "ROOT/RDataFrame.hxx"
+#include "TCanvas.h"
+#include "TH1D.h"
+#include "TLegend.h"
+#include "TProfile.h"
+#include <Math/Vector4D.h>
+
+R__LOAD_LIBRARY(fmt)
+#include <fmt/format.h>
+
+#include <iostream>
+
+R__LOAD_LIBRARY(libeicd)
+R__LOAD_LIBRARY(libDD4pod)
+#include "dd4pod/Geant4ParticleCollection.h"
+#include "eicd/ReconstructedParticleCollection.h"
+
+using ROOT::RDataFrame;
+using namespace ROOT::VecOps;
+
+std::vector<float> pt(std::vector<dd4pod::Geant4ParticleData> const& in)
+{
+  std::vector<float> result;
+  for (size_t i = 0; i < in.size(); ++i) {
+    result.push_back(std::sqrt(in[i].ps.x * in[i].ps.x + in[i].ps.y * in[i].ps.y));
+  }
+  return result;
+}
+
+auto momentum = [](std::vector<ROOT::Math::PxPyPzMVector> const& in) {
+  std::vector<double> result;
+  for (size_t i = 0; i < in.size(); ++i) {
+    result.push_back(in[i].P());
+  }
+  return result;
+};
+auto theta = [](std::vector<ROOT::Math::PxPyPzMVector> const& in) {
+  std::vector<double> result;
+  for (size_t i = 0; i < in.size(); ++i) {
+    result.push_back(in[i].Theta() * 180 / M_PI);
+  }
+  return result;
+};
+auto fourvec = [](ROOT::VecOps::RVec<dd4pod::Geant4ParticleData> const& in) {
+  std::vector<ROOT::Math::PxPyPzMVector> result;
+  ROOT::Math::PxPyPzMVector              lv;
+  for (size_t i = 0; i < in.size(); ++i) {
+    lv.SetCoordinates(in[i].ps.x, in[i].ps.y, in[i].ps.z, in[i].mass);
+    result.push_back(lv);
+  }
+  return result;
+};
+
+std::vector<double> p_rec(const std::vector<eic::ReconstructedParticleData>& parts)
+{
+  std::vector<double> result;
+  for (const auto& part : parts) {
+    result.push_back(part.momentum);
+  }
+  return result;
+}
+std::vector<double> thx(const std::vector<eic::ReconstructedParticleData>& parts)
+{
+  std::vector<double> result;
+  for (const auto& part : parts) {
+    result.push_back(atan(part.p.x / part.p.z) * 1000);
+  }
+  return result;
+}
+std::vector<double> thy(const std::vector<eic::ReconstructedParticleData>& parts)
+{
+  std::vector<double> result;
+  for (const auto& part : parts) {
+    result.push_back(atan(part.p.y / part.p.z) * 1000);
+  }
+  return result;
+}
+std::vector<double> theta_colinear(const std::vector<eic::ReconstructedParticleData>& parts, const double xangle)
+{
+  std::vector<double> result;
+  for (const auto& part : parts) {
+    // undo the crossing angle for this one so things make more sense
+    const double ca = cos(-xangle);
+    const double sa = sin(-xangle);
+    const double pz = -part.p.x * sa + part.p.z * ca;
+    result.push_back(acos(pz / part.momentum) * 1000);
+  }
+  return result;
+}
+
+auto delta_p = [](const std::vector<double>& tracks, const std::vector<double>& thrown) {
+  std::vector<double> res;
+  for (const auto& p1 : thrown) {
+    for (const auto& p2 : tracks) {
+      res.push_back(p1 - p2);
+    }
+  }
+  return res;
+};
+
+auto delta_p_over_p = [](const std::vector<double>& tracks, const std::vector<double>& thrown) {
+  std::vector<double> res;
+  for (const auto& p1 : thrown) {
+    for (const auto& p2 : tracks) {
+      res.push_back((p1 - p2) / p1);
+    }
+  }
+  return res;
+};
+
+int rec_ff(const std::string& fname = "athena/rec_ff.root", const std::string& particle = "proton",
+           const double xangle = -0.25)
+{
+
+  ROOT::EnableImplicitMT();
+  ROOT::RDataFrame df("events", fname);
+
+  auto df0 = df.Define("isThrown", "mcparticles2.genStatus == 1")
+                 .Define("thrownParticles", "mcparticles2[isThrown]")
+                 .Define("xangle", fmt::format("{}", xangle))
+                 .Define("thrownP", fourvec, {"thrownParticles"})
+                 .Define("p_thrown", momentum, {"thrownP"})
+                 .Define("p_rec", p_rec, {"ReconstructedParticles"})
+                 .Define("delta_p", delta_p, {"p_rec", "p_thrown"})
+                 .Define("delta_p_over_p0", delta_p_over_p, {"p_rec", "p_thrown"})
+                 .Define("thx_mrad", thx, {"ReconstructedParticles"})
+                 .Define("thy_mrad", thy, {"ReconstructedParticles"})
+                 .Define("theta_mrad", theta_colinear, {"ReconstructedParticles", "xangle"});
+
+  auto h_angular = df0.Histo2D({"h_angular", ";#theta_{x} (mrad);#theta_{y} (mrad);", 60, -60, 60, 60, -60, 60},
+                               "thx_mrad", "thy_mrad");
+  auto h_thetap =
+      df0.Histo2D({"h_thetap", ";#theta_{with ion} (mrad);P (GeV);", 60, 0, 100, 60, -30, 30}, "p_rec", "theta_mrad");
+
+  auto c = new TCanvas();
+
+  h_angular->DrawCopy("colz");
+  c->SaveAs(fmt::format("results/far_forward/far_forward_angular_{}.png", particle).c_str());
+  c->SaveAs(fmt::format("results/far_forward/far_forward_angular_{}.pdf", particle).c_str());
+
+  // -----------------------------------------------
+  h_thetap->DrawCopy("colz");
+  c->SaveAs(fmt::format("results/far_forward/far_forward_thetap_{}.png", particle).c_str());
+  c->SaveAs(fmt::format("results/far_forward/far_forward_thetap_{}.pdf", particle).c_str());
+
+  return 0;
+}