diff --git a/.gitignore b/.gitignore
index fdb1db50d0eddc2e8190301f5a785944b261b246..57389ad9a432bf12e26fee407bf7c969923765c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -66,3 +66,6 @@ commit.msg
 
 # eclipse remote sync folders
 .ptp-sync-folder
+
+# Mac default files
+.DS_Store
diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt
index 4730d3d18565649a951cad791dc007cf76f10f5f..31e7122445088a9258d64958c05a763d31c6b19c 100644
--- a/example/CMakeLists.txt
+++ b/example/CMakeLists.txt
@@ -1,2 +1,3 @@
 add_subdirectory(miniapp)
 add_subdirectory(generators)
+add_subdirectory(brunel)
diff --git a/example/brunel/CMakeLists.txt b/example/brunel/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..a7ed3fd070661657090b2316941f6ebbd4591a21
--- /dev/null
+++ b/example/brunel/CMakeLists.txt
@@ -0,0 +1,23 @@
+set(HEADERS
+    io.hpp
+    partitioner.hpp
+)
+set(MINIAPP_SOURCES
+    brunel_miniapp.cpp
+    io.cpp
+)
+
+add_executable(brunel_miniapp.exe ${MINIAPP_SOURCES} ${HEADERS})
+
+target_link_libraries(brunel_miniapp.exe LINK_PUBLIC ${ARB_LIBRARIES})
+target_link_libraries(brunel_miniapp.exe LINK_PUBLIC ${EXTERNAL_LIBRARIES})
+
+if(ARB_WITH_MPI)
+    target_link_libraries(brunel_miniapp.exe LINK_PUBLIC ${MPI_C_LIBRARIES})
+    set_property(TARGET brunel_miniapp.exe APPEND_STRING PROPERTY LINK_FLAGS "${MPI_C_LINK_FLAGS}")
+endif()
+
+set_target_properties(brunel_miniapp.exe
+   PROPERTIES
+   RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/example/miniapp/brunel"
+)
diff --git a/example/brunel/brunel_miniapp.cpp b/example/brunel/brunel_miniapp.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..ea6ca86d0821b016a77aaacc01aeeadf148c6143
--- /dev/null
+++ b/example/brunel/brunel_miniapp.cpp
@@ -0,0 +1,319 @@
+#include <cmath>
+#include <exception>
+#include <event_generator.hpp>
+#include <iostream>
+#include <fstream>
+#include <memory>
+#include <vector>
+#include <json/json.hpp>
+#include <common_types.hpp>
+#include <communication/communicator.hpp>
+#include <communication/global_policy.hpp>
+#include <io/exporter_spike_file.hpp>
+#include <lif_cell_description.hpp>
+#include <model.hpp>
+#include "partitioner.hpp"
+#include <profiling/profiler.hpp>
+#include <profiling/meter_manager.hpp>
+#include <recipe.hpp>
+#include <set>
+#include <threading/threading.hpp>
+#include <util/config.hpp>
+#include <util/debug.hpp>
+#include <util/ioutil.hpp>
+#include <util/nop.hpp>
+#include <vector>
+#include "io.hpp"
+#include <hardware/gpu.hpp>
+#include <hardware/node_info.hpp>
+
+using namespace arb;
+
+using global_policy = communication::global_policy;
+using file_export_type = io::exporter_spike_file<global_policy>;
+void banner(hw::node_info);
+using communicator_type = communication::communicator<communication::global_policy>;
+
+// Samples m unique values in interval [start, end) - gid.
+// We exclude gid because we don't want self-loops.
+std::vector<cell_gid_type> sample_subset(cell_gid_type gid, cell_gid_type start, cell_gid_type end,  unsigned m) {
+    std::set<cell_gid_type> s;
+    std::mt19937 gen(gid + 42);
+    std::uniform_int_distribution<cell_gid_type> dis(start, end - 1);
+    while (s.size() < m) {
+        auto val = dis(gen);
+        if (val != gid) {
+            s.insert(val);
+        }
+    }
+    return {s.begin(), s.end()};
+}
+
+/*
+   A Brunel network consists of nexc excitatory LIF neurons and ninh inhibitory LIF neurons.
+   Each neuron in the network receives in_degree_prop * nexc excitatory connections
+   chosen randomly, in_degree_prop * ninh inhibitory connections and next (external) Poisson connections.
+   All the connections have the same delay. The strenght of excitatory and Poisson connections is given by
+   parameter weight, whereas the strength of inhibitory connections is rel_inh_strength * weight.
+   Poisson neurons all spike independently with expected number of spikes given by parameter poiss_lambda.
+   Because of the refractory period, the activity is mostly driven by Poisson neurons and
+   recurrent connections have a small effect.
+ */
+class brunel_recipe: public recipe {
+public:
+    brunel_recipe(cell_size_type nexc, cell_size_type ninh, cell_size_type next, double in_degree_prop,
+                  float weight, float delay, float rel_inh_strength, double poiss_lambda, int seed = 42):
+        ncells_exc_(nexc), ncells_inh_(ninh), ncells_ext_(next), delay_(delay), seed_(seed) {
+        // Make sure that in_degree_prop in the interval (0, 1]
+        if (in_degree_prop <= 0.0 || in_degree_prop > 1.0) {
+            std::out_of_range("The proportion of incoming connections should be in the interval (0, 1].");
+        }
+
+        // Set up the parameters.
+        weight_exc_ = weight;
+        weight_inh_ = -rel_inh_strength * weight_exc_;
+        weight_ext_ =  weight;
+        in_degree_exc_ = std::round(in_degree_prop * nexc);
+        in_degree_inh_ = std::round(in_degree_prop * ninh);
+        // each cell receives next incoming Poisson sources with mean rate poiss_lambda, which is equivalent
+        // to a single Poisson source with mean rate next*poiss_lambda
+        lambda_ = next * poiss_lambda;
+    }
+
+    cell_size_type num_cells() const override {
+        return ncells_exc_ + ncells_inh_;
+    }
+
+    cell_kind get_cell_kind(cell_gid_type gid) const override {
+        return cell_kind::lif_neuron;
+    }
+
+    std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
+        std::vector<cell_connection> connections;
+        // Add incoming excitatory connections.
+        for (auto i: sample_subset(gid, 0, ncells_exc_, in_degree_exc_)) {
+            cell_member_type source{cell_gid_type(i), 0};
+            cell_member_type target{gid, 0};
+            cell_connection conn(source, target, weight_exc_, delay_);
+            connections.push_back(conn);
+        }
+
+        // Add incoming inhibitory connections.
+        for (auto i: sample_subset(gid, ncells_exc_, ncells_exc_ + ncells_inh_, in_degree_inh_)) {
+            cell_member_type source{cell_gid_type(i), 0};
+            cell_member_type target{gid, 0};
+            cell_connection conn(source, target, weight_inh_, delay_);
+            connections.push_back(conn);
+        }
+        return connections;
+    }
+
+    util::unique_any get_cell_description(cell_gid_type gid) const override {
+        auto cell = lif_cell_description();
+        cell.tau_m = 10;
+        cell.V_th = 10;
+        cell.C_m = 20;
+        cell.E_L = 0;
+        cell.V_m = 0;
+        cell.V_reset = 0;
+        cell.t_ref = 2;
+        return cell;
+    }
+
+    std::vector<event_generator_ptr> event_generators(cell_gid_type gid) const override {
+        std::vector<arb::event_generator_ptr> gens;
+
+        std::mt19937_64 G;
+        G.seed(gid + seed_);
+
+        using pgen = poisson_generator<std::mt19937_64>;
+
+        time_type t0 = 0;
+        cell_member_type target{gid, 0};
+
+        gens.push_back(arb::make_event_generator<pgen>(
+                        target,
+                        weight_ext_,
+                        G,
+                        t0,
+                        lambda_));
+        return gens;
+    }
+
+    cell_size_type num_sources(cell_gid_type) const override {
+         return 1;
+    }
+
+    cell_size_type num_targets(cell_gid_type) const override {
+        return 1;
+    }
+
+    cell_size_type num_probes(cell_gid_type) const override {
+        return 0;
+    }
+
+    probe_info get_probe(cell_member_type probe_id) const override {
+        return {};
+    }
+
+private:
+    // Number of excitatory cells.
+    cell_size_type ncells_exc_;
+
+    // Number of inhibitory cells.
+    cell_size_type ncells_inh_;
+
+    // Number of Poisson connections each neuron receives 
+    cell_size_type ncells_ext_;
+
+    // Weight of excitatory synapses.
+    float weight_exc_;
+
+    // Weight of inhibitory synapses.
+    float weight_inh_;
+
+    // Weight of external Poisson cell synapses.
+    float weight_ext_;
+
+    // Delay of all synapses.
+    float delay_;
+
+    // Number of connections that each neuron receives from excitatory population.
+    int in_degree_exc_;
+
+    // Number of connections that each neuron receives from inhibitory population.
+    int in_degree_inh_;
+
+    // Expected number of poisson spikes.
+    double lambda_;
+
+    // Seed used for the Poisson spikes generation.
+    int seed_;
+};
+
+using util::any_cast;
+using util::make_span;
+using global_policy = communication::global_policy;
+using file_export_type = io::exporter_spike_file<global_policy>;
+
+int main(int argc, char** argv) {
+    arb::communication::global_policy_guard global_guard(argc, argv);
+    try {
+        arb::util::meter_manager meters;
+        meters.start();
+        std::cout << util::mask_stream(global_policy::id()==0);
+        // read parameters
+        io::cl_options options = io::read_options(argc, argv, global_policy::id()==0);
+        hw::node_info nd;
+        nd.num_cpu_cores = threading::num_threads();
+        nd.num_gpus = hw::num_gpus()>0? 1: 0;
+        banner(nd);
+
+        meters.checkpoint("setup");
+
+        // The size of excitatory population.
+        cell_size_type nexc = options.nexc;
+
+        // The size of inhibitory population.
+        cell_size_type ninh = options.ninh;
+
+        // The size of Poisson (external) population.
+        cell_size_type next = options.next;
+
+        // Fraction of connections each neuron receives from each of the 3 populations.
+        double in_degree_prop = options.syn_per_cell_prop;
+
+        // Weight of excitatory and poisson connections.
+        float w = options.weight;
+
+        // Delay of all the connections.
+        float d = options.delay;
+
+        // Relative strength of inhibitory connections with respect to excitatory connections.
+        float rel_inh_strength = options.rel_inh_strength;
+
+        // Expected number of spikes from a single poisson cell per ms.
+        int poiss_lambda = options.poiss_lambda;
+
+        // The number of cells in a single cell group.
+        cell_size_type group_size = options.group_size;
+
+        unsigned seed = options.seed;
+
+        brunel_recipe recipe(nexc, ninh, next, in_degree_prop, w, d, rel_inh_strength, poiss_lambda, seed);
+
+        auto register_exporter = [] (const io::cl_options& options) {
+            return util::make_unique<file_export_type>
+                       (options.file_name, options.output_path,
+                        options.file_extension, options.over_write);
+        };
+
+        auto decomp = decompose(recipe, group_size);
+        model m(recipe, decomp);
+
+        // Initialize the spike exporting interface
+        std::unique_ptr<file_export_type> file_exporter;
+        if (options.spike_file_output) {
+            if (options.single_file_per_rank) {
+                file_exporter = register_exporter(options);
+
+                m.set_local_spike_callback(
+                    [&](const std::vector<spike>& spikes) {
+                        file_exporter->output(spikes);
+                    }
+                );
+            }
+            else if(communication::global_policy::id()==0) {
+                file_exporter = register_exporter(options);
+
+                m.set_global_spike_callback(
+                    [&](const std::vector<spike>& spikes) {
+                        file_exporter->output(spikes);
+                    }
+                );
+            }
+        }
+        meters.checkpoint("model-init");
+
+        // run model
+        m.run(options.tfinal, options.dt);
+
+        meters.checkpoint("model-simulate");
+
+        // output profile and diagnostic feedback
+        util::profiler_output(0.001, options.profile_only_zero);
+        std::cout << "There were " << m.num_spikes() << " spikes\n";
+
+        auto report = util::make_meter_report(meters);
+        std::cout << report;
+        if (global_policy::id()==0) {
+            std::ofstream fid;
+            fid.exceptions(std::ios_base::badbit | std::ios_base::failbit);
+            fid.open("meters.json");
+            fid << std::setw(1) << util::to_json(report) << "\n";
+        }
+    }
+    catch (io::usage_error& e) {
+        // only print usage/startup errors on master
+        std::cerr << util::mask_stream(global_policy::id()==0);
+        std::cerr << e.what() << "\n";
+        return 1;
+    }
+    catch (std::exception& e) {
+        std::cerr << e.what() << "\n";
+        return 2;
+    }
+    return 0;
+}
+
+void banner(hw::node_info nd) {
+    std::cout << "==========================================\n";
+    std::cout << "  Arbor miniapp\n";
+    std::cout << "  - distributed : " << global_policy::size()
+              << " (" << std::to_string(global_policy::kind()) << ")\n";
+    std::cout << "  - threads     : " << nd.num_cpu_cores
+              << " (" << threading::description() << ")\n";
+    std::cout << "  - gpus        : " << nd.num_gpus << "\n";
+    std::cout << "==========================================\n";
+}
+
diff --git a/example/brunel/io.cpp b/example/brunel/io.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..afb406761a6f35ed76aa62bfdbeff1f4d1d8afba
--- /dev/null
+++ b/example/brunel/io.cpp
@@ -0,0 +1,206 @@
+#include <algorithm>
+#include <exception>
+#include <fstream>
+#include <iostream>
+#include <memory>
+#include <sstream>
+#include <string>
+#include <type_traits>
+#include <tclap/CmdLine.h>
+#include <util/meta.hpp>
+#include <util/optional.hpp>
+#include "io.hpp"
+
+// Let TCLAP understand value arguments that are of an optional type.
+namespace TCLAP {
+    template <typename V>
+    struct ArgTraits<arb::util::optional<V>> {
+        using ValueCategory = ValueLike;
+    };
+} // namespace TCLAP
+
+namespace arb {
+    namespace util {
+        // Using static here because we do not want external linkage for this operator.
+        template <typename V>
+        static std::istream& operator>>(std::istream& I, optional<V>& v) {
+            V u;
+            if (I >> u) {
+                v = u;
+            }
+            return I;
+        }
+    }
+
+    namespace io {
+        // Override annoying parameters listed back-to-front behaviour.
+        //
+        // TCLAP argument creation _prepends_ its arguments to the internal
+        // list (_argList), where standard options --help etc. are already
+        // pre-inserted.
+        //
+        // reorder_arguments() reverses the arguments to restore ordering,
+        // and moves the standard options to the end.
+        class CustomCmdLine: public TCLAP::CmdLine {
+        public:
+            CustomCmdLine(const std::string &message, const std::string &version = "none"):
+            TCLAP::CmdLine(message, ' ', version, true)
+            {}
+
+            void reorder_arguments() {
+                _argList.reverse();
+                for (auto opt: {"help", "version", "ignore_rest"}) {
+                    auto i = std::find_if(
+                                          _argList.begin(), _argList.end(),
+                                          [&opt](TCLAP::Arg* a) { return a->getName()==opt; });
+
+                    if (i!=_argList.end()) {
+                        auto a = *i;
+                        _argList.erase(i);
+                        _argList.push_back(a);
+                    }
+                }
+            }
+        };
+
+        // Update an option value from command line argument if set.
+        template <
+        typename T,
+        typename Arg,
+        typename = util::enable_if_t<std::is_base_of<TCLAP::Arg, Arg>::value>
+        >
+        static void update_option(T& opt, Arg& arg) {
+            if (arg.isSet()) {
+                opt = arg.getValue();
+            }
+        }
+
+        // Read options from (optional) json file and command line arguments.
+        cl_options read_options(int argc, char** argv, bool allow_write) {
+            cl_options options;
+            std::string save_file = "";
+
+            // Parse command line arguments.
+            try {
+                cl_options defopts;
+
+                CustomCmdLine cmd("nest brunel miniapp harness", "0.1");
+
+                TCLAP::ValueArg<uint32_t> nexc_arg
+                    ("n", "n-excitatory", "total number of cells in the excitatory population",
+                     false, defopts.nexc, "integer", cmd);
+
+                TCLAP::ValueArg<uint32_t> ninh_arg
+                    ("m", "n-inhibitory", "total number of cells in the inhibitory population",
+                     false, defopts.ninh, "integer", cmd);
+
+                TCLAP::ValueArg<uint32_t> next_arg
+                    ("e", "n-external", "total number of incoming Poisson (external) connections per cell.",
+                     false, defopts.ninh, "integer", cmd);
+
+                TCLAP::ValueArg<double> syn_prop_arg
+                    ("p", "in-degree-prop", "the proportion of connections both the excitatory and inhibitory populations that each neuron receives",
+                     false, defopts.syn_per_cell_prop, "double", cmd);
+
+                TCLAP::ValueArg<float> weight_arg
+                    ("w", "weight", "the weight of all excitatory connections",
+                     false, defopts.weight, "float", cmd);
+
+                TCLAP::ValueArg<float> delay_arg
+                    ("d", "delay", "the delay of all connections",
+                     false, defopts.delay, "float", cmd);
+
+                TCLAP::ValueArg<float> rel_inh_strength_arg
+                    ("g", "rel-inh-w", "relative strength of inhibitory synapses with respect to the excitatory ones",
+                     false, defopts.rel_inh_strength, "float", cmd);
+
+                TCLAP::ValueArg<double> poiss_lambda_arg
+                    ("l", "lambda", "Expected number of spikes from a single poisson cell per ms",
+                     false, defopts.poiss_lambda, "double", cmd);
+
+                TCLAP::ValueArg<double> tfinal_arg
+                    ("t", "tfinal", "length of the simulation period [ms]",
+                     false, defopts.tfinal, "time", cmd);
+
+                TCLAP::ValueArg<double> dt_arg
+                    ("s", "delta-t", "simulation time step [ms] (this parameter is ignored)",
+                     false, defopts.dt, "time", cmd);
+
+                TCLAP::ValueArg<uint32_t> group_size_arg
+                    ("G", "group-size", "number of cells per cell group",
+                     false, defopts.group_size, "integer", cmd);
+
+                TCLAP::ValueArg<uint32_t> seed_arg
+                    ("S", "seed", "seed for poisson spike generators",
+                     false, defopts.seed, "integer", cmd);
+
+                TCLAP::SwitchArg spike_output_arg
+                    ("f","spike-file-output","save spikes to file", cmd, false);
+
+                TCLAP::SwitchArg profile_only_zero_arg
+                    ("z", "profile-only-zero", "Only output profile information for rank 0",
+                     cmd, false);
+
+                TCLAP::SwitchArg verbose_arg
+                    ("v", "verbose", "Present more verbose information to stdout", cmd, false);
+
+                cmd.reorder_arguments();
+                cmd.parse(argc, argv);
+
+                // Handle verbosity separately from other options: it is not considered part
+                // of the saved option state.
+                options.verbose = verbose_arg.getValue();
+                update_option(options.nexc, nexc_arg);
+                update_option(options.ninh, ninh_arg);
+                update_option(options.next, next_arg);
+                update_option(options.syn_per_cell_prop, syn_prop_arg);
+                update_option(options.weight, weight_arg);
+                update_option(options.delay, delay_arg);
+                update_option(options.rel_inh_strength, rel_inh_strength_arg);
+                update_option(options.poiss_lambda, poiss_lambda_arg);
+                update_option(options.tfinal, tfinal_arg);
+                update_option(options.dt, dt_arg);
+                update_option(options.group_size, group_size_arg);
+                update_option(options.seed, seed_arg);
+                update_option(options.spike_file_output, spike_output_arg);
+                update_option(options.profile_only_zero, profile_only_zero_arg);
+
+                if (options.group_size < 1) {
+                    throw usage_error("minimum of one cell per group");
+                }
+
+                if (options.rel_inh_strength <= 0 || options.rel_inh_strength > 1) {
+                    throw usage_error("relative strength of inhibitory connections must be in the interval (0, 1].");
+                }
+            }
+            catch (TCLAP::ArgException& e) {
+                throw usage_error("error parsing command line argument "+e.argId()+": "+e.error());
+            }
+
+            // If verbose output requested, emit option summary.
+            if (options.verbose) {
+                std::cout << options << "\n";
+            }
+
+            return options;
+        }
+
+        std::ostream& operator<<(std::ostream& o, const cl_options& options) {
+            o << "simulation options:\n";
+            o << "  excitatory cells                                           : " << options.nexc << "\n";
+            o << "  inhibitory cells                                           : " << options.ninh << "\n";
+            o << "  Poisson connections per cell                               : " << options.next << "\n";
+            o << "  proportion of synapses/cell from each population           : " << options.syn_per_cell_prop << "\n";
+            o << "  weight of excitatory synapses                              : " << options.weight << "\n";
+            o << "  relative strength of inhibitory synapses                   : " << options.rel_inh_strength << "\n";
+            o << "  delay of all synapses                                      : " << options.delay << "\n";
+            o << "  expected number of spikes from a single poisson cell per ms: " << options.poiss_lambda << "\n";
+            o << "\n";
+            o << "  simulation time                                            : " << options.tfinal << "\n";
+            o << "  dt                                                         : " << options.dt << "\n";
+            o << "  group size                                                 : " << options.group_size << "\n";
+            o << "  seed                                                       : " << options.seed << "\n";
+            return o;
+        }
+    } // namespace io
+} // namespace arbor
diff --git a/example/brunel/io.hpp b/example/brunel/io.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..45c0c65c7d1b90c4b7201866a82281d0dc3d535e
--- /dev/null
+++ b/example/brunel/io.hpp
@@ -0,0 +1,63 @@
+#pragma once
+
+#include <stdexcept>
+#include <string>
+#include <utility>
+#include <vector>
+#include <common_types.hpp>
+#include <util/optional.hpp>
+#include <util/path.hpp>
+
+namespace arb {
+namespace io {
+    // Holds the options for a simulation run.
+    // Default constructor gives default options.
+    struct cl_options {
+        // Cell parameters:
+        uint32_t nexc = 400;
+        uint32_t ninh = 100;
+        uint32_t next = 40;
+        double syn_per_cell_prop = 0.05;
+        float weight = 1.2;
+        float delay = 0.1;
+        float rel_inh_strength = 1;
+        double poiss_lambda = 1;
+
+        // Simulation running parameters:
+        double tfinal = 100.;
+        double dt = 1;
+        uint32_t group_size = 10;
+        uint32_t seed = 42;
+
+        // Parameters for spike output.
+        bool spike_file_output = false;
+        bool single_file_per_rank = false;
+        bool over_write = true;
+        std::string output_path = "./";
+        std::string file_name = "spikes";
+        std::string file_extension = "gdf";
+
+        // Turn on/off profiling output for all ranks.
+        bool profile_only_zero = false;
+
+        // Be more verbose with informational messages.
+        bool verbose = false;
+    };
+
+    class usage_error: public std::runtime_error {
+    public:
+        template <typename S>
+        usage_error(S&& whatmsg): std::runtime_error(std::forward<S>(whatmsg)) {}
+    };
+
+    class model_description_error: public std::runtime_error {
+    public:
+        template <typename S>
+        model_description_error(S&& whatmsg): std::runtime_error(std::forward<S>(whatmsg)) {}
+    };
+
+    std::ostream& operator<<(std::ostream& o, const cl_options& opt);
+
+    cl_options read_options(int argc, char** argv, bool allow_write = true);
+} // namespace io
+} // namespace arbor
diff --git a/example/brunel/partitioner.hpp b/example/brunel/partitioner.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..8f56c0915b443a06284695669c5fcddf7fb94904
--- /dev/null
+++ b/example/brunel/partitioner.hpp
@@ -0,0 +1,66 @@
+#include <communication/global_policy.hpp>
+#include <domain_decomposition.hpp>
+#include <hardware/node_info.hpp>
+#include <recipe.hpp>
+
+namespace arb {
+    domain_decomposition decompose(const recipe& rec, const unsigned group_size) {
+        struct partition_gid_domain {
+            partition_gid_domain(std::vector<cell_gid_type> divs):
+                gid_divisions(std::move(divs))
+            {}
+
+            int operator()(cell_gid_type gid) const {
+                auto gid_part = util::partition_view(gid_divisions);
+                return gid_part.index(gid);
+            }
+
+            const std::vector<cell_gid_type> gid_divisions;
+        };
+
+        cell_size_type num_global_cells = rec.num_cells();
+        unsigned num_domains = communication::global_policy::size();
+        int domain_id = communication::global_policy::id();
+
+        auto dom_size = [&](unsigned dom) -> cell_gid_type {
+            const cell_gid_type B = num_global_cells/num_domains;
+            const cell_gid_type R = num_global_cells - num_domains*B;
+            return B + (dom<R);
+        };
+
+        // Global load balance
+        std::vector<cell_gid_type> gid_divisions;
+        auto gid_part = make_partition(
+            gid_divisions, util::transform_view(util::make_span(0, num_domains), dom_size));
+
+        auto range = gid_part[domain_id];
+        cell_size_type num_local_cells = range.second - range.first;
+
+        unsigned num_groups = num_local_cells / group_size + (num_local_cells%group_size== 0 ? 0 : 1);
+        std::vector<group_description> groups;
+
+        // Local load balance
+        // i.e. all the groups that the current rank (domain) owns
+        for (unsigned i = 0; i < num_groups; ++i) {
+            unsigned start = i * group_size;
+            unsigned end = std::min(start + group_size, num_local_cells);
+            std::vector<cell_gid_type> group_elements;
+
+            for (unsigned j = start; j < end; ++j) {
+                group_elements.push_back(j);
+            }
+
+            groups.push_back({cell_kind::lif_neuron, std::move(group_elements), backend_kind::multicore});
+        }
+
+        domain_decomposition d;
+        d.num_domains = num_domains;
+        d.domain_id = domain_id;
+        d.num_local_cells = num_local_cells;
+        d.num_global_cells = num_global_cells;
+        d.groups = std::move(groups);
+        d.gid_domain = partition_gid_domain(std::move(gid_divisions));
+
+        return d;
+    }
+}
diff --git a/example/brunel/readme.md b/example/brunel/readme.md
new file mode 100644
index 0000000000000000000000000000000000000000..eb1e2f77d0be33332d22db01d99d5cec56a00e93
--- /dev/null
+++ b/example/brunel/readme.md
@@ -0,0 +1,35 @@
+## Miniapp for simulating the Brunel network of LIF neurons.
+
+The network consists of 2 main populations: excitatory and inhibitory populations.
+Each neuron from the network receives a fixed number (proportional to the size
+of the population) of incoming connections from both of these groups.
+In addition to the input from excitatory and inhibitory populations,
+each neuron receives a fixed number of connections from the Poisson neurons
+producing the Poisson-like input that is integrated into the LIF cell group so
+that the communication of Poisson events is bypassed.
+
+### Parameters
+
+The parameters that can be passed as command-line arguments are the following:
+
+* `-n` (`--n_excitatory`): total number of cells in the excitatory population.
+* `-m` (`--n_inhibitory`): total number of cells in the inhibitory population.
+* `-e` (`--n_external`): number of incoming Poisson sources each excitatory and inhibitory neuron receives.
+* `-p` (`--in_degree_prop`): the proportions of connections from both populations that each neurons receives.
+* `-w` (`--weight`): weight of all excitatory connections.
+* `-g` (`--rel_inh_w`): relative strength of inhibitory synapses with respect to the excitatory ones.
+* `-d` (`--delay`): the delay of all connections.
+* `-l` (`--lambda`): rate of Poisson cells (kHz).
+* `-t` (`--tfinal`): length of the simulation period (ms).
+* `-s` (`--delta_t`): simulation time step (ms). (this parameter is ignored)
+* `-G` (`--group-size`): number of cells per cell group
+* `-S` (`--seed`): seed of the Poisson sources attached to cells.
+* `-f` (`--spike-file-output`): save spikes to file (Bool).
+* `-z` (`--profile-only-zero`): only output profile information for rank 0.
+* `-v` (`--verbose`): present more verbose information to stdout.
+
+For example, we could run the miniapp as follows:
+
+```
+./example/miniapp/brunel/brunel_miniapp.exe -n 400 -m 100 -e 20 -p 0.1 -w 1.2 -d 1 -g 0.5 -l 5 -t 100 -s 1 -G 50 -S 123 -f
+```
diff --git a/example/miniapp/CMakeLists.txt b/example/miniapp/CMakeLists.txt
index 70bfd6176610ab77f4255fb75d9ef951e7420256..b309737a8b253fe8927189a88da1ba979edbd23c 100644
--- a/example/miniapp/CMakeLists.txt
+++ b/example/miniapp/CMakeLists.txt
@@ -21,3 +21,4 @@ set_target_properties(
     PROPERTIES
     RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/example"
 )
+
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index fd29c43e070fbac8d35fe9e1bbc45934a1e72838..348f47a57e8c9f5707dfbe9a998f42a20da2cce7 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -4,6 +4,7 @@ set(BASE_SOURCES
     common_types_io.cpp
     cell.cpp
     event_binner.cpp
+    lif_cell_group.cpp
     hardware/affinity.cpp
     hardware/gpu.cpp
     hardware/memory.cpp
diff --git a/src/cell_group_factory.cpp b/src/cell_group_factory.cpp
index a1e5fc1e9e13a8d7595878e5ffb0bb84e31820d7..f1f9711fc8c0b07a0cf0c5a0ac4f860d368d57ba 100644
--- a/src/cell_group_factory.cpp
+++ b/src/cell_group_factory.cpp
@@ -5,6 +5,7 @@
 #include <domain_decomposition.hpp>
 #include <dss_cell_group.hpp>
 #include <fvm_multicell.hpp>
+#include <lif_cell_group.hpp>
 #include <mc_cell_group.hpp>
 #include <recipe.hpp>
 #include <rss_cell_group.hpp>
@@ -28,6 +29,9 @@ cell_group_ptr cell_group_factory(const recipe& rec, const group_description& gr
     case cell_kind::regular_spike_source:
         return make_cell_group<rss_cell_group>(group.gids, rec);
 
+    case cell_kind::lif_neuron:
+        return make_cell_group<lif_cell_group>(group.gids, rec);
+
     case cell_kind::data_spike_source:
         return make_cell_group<dss_cell_group>(group.gids, rec);
 
diff --git a/src/common_types.hpp b/src/common_types.hpp
index 19a5aacf1c7dfb91a10a1949cf3dd012897c8fda..41284522a6c12286a0dbc25024f6531d0d505642 100644
--- a/src/common_types.hpp
+++ b/src/common_types.hpp
@@ -69,6 +69,7 @@ using sample_size_type = std::int32_t;
 
 enum cell_kind {
     cable1d_neuron,           // Our own special mc neuron
+    lif_neuron,               // Leaky-integrate and fire neuron
     regular_spike_source,     // Regular spiking source
     data_spike_source,        // Spike source from values inserted via description
 };
diff --git a/src/common_types_io.cpp b/src/common_types_io.cpp
index 9e478115f053a4774c0a26b86bda20182966e986..3a3fa34e7605d3cb2c7da8c984341d31c2907d41 100644
--- a/src/common_types_io.cpp
+++ b/src/common_types_io.cpp
@@ -15,6 +15,8 @@ std::ostream& operator<<(std::ostream& o, arb::cell_kind k) {
         return o << "cable1d_neuron";
     case arb::cell_kind::data_spike_source:
         return o << "data_spike_source";
+    case arb::cell_kind::lif_neuron:
+        return o << "lif_neuron";
     }
     return o;
 }
diff --git a/src/lif_cell_description.hpp b/src/lif_cell_description.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..24c0973feb7a8b8e15ad753d2144f11511f863d2
--- /dev/null
+++ b/src/lif_cell_description.hpp
@@ -0,0 +1,13 @@
+#pragma once
+
+// Model parameteres of leaky integrate and fire neuron model.
+struct lif_cell_description {
+    // Neuronal parameters.
+    double tau_m = 10;    // Membrane potential decaying constant [ms].
+    double V_th = 10;     // Firing threshold [mV].
+    double C_m = 20;      // Membrane capacitance [pF].
+    double E_L = 0;       // Resting potential [mV].
+    double V_m = E_L;     // Initial value of the Membrane potential [mV].
+    double V_reset = E_L; // Reset potential [mV].
+    double t_ref = 2;     // Refractory period [ms].
+};
diff --git a/src/lif_cell_group.cpp b/src/lif_cell_group.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e8b18852547924cf5671e1d3c5b1f9cf8cdea9a9
--- /dev/null
+++ b/src/lif_cell_group.cpp
@@ -0,0 +1,104 @@
+#include <lif_cell_group.hpp>
+
+using namespace arb;
+
+// Constructor containing gid of first cell in a group and a container of all cells.
+lif_cell_group::lif_cell_group(std::vector<cell_gid_type> gids, const recipe& rec):
+gids_(std::move(gids))
+{
+    // Default to no binning of events
+    set_binning_policy(binning_kind::none, 0);
+
+    cells_.reserve(gids_.size());
+    last_time_updated_.resize(gids_.size());
+
+    for (auto lid: util::make_span(0, gids_.size())) {
+        cells_.push_back(util::any_cast<lif_cell_description>(rec.get_cell_description(gids_[lid])));
+    }
+}
+
+cell_kind lif_cell_group::get_cell_kind() const {
+    return cell_kind::lif_neuron;
+}
+
+void lif_cell_group::advance(epoch ep, time_type dt, const event_lane_subrange& event_lanes) {
+    PE("lif");
+    if (event_lanes.size() > 0) {
+        for (auto lid: util::make_span(0, gids_.size())) {
+            // Advance each cell independently.
+            advance_cell(ep.tfinal, dt, lid, event_lanes[lid]);
+        }
+    }
+    PL();
+}
+
+const std::vector<spike>& lif_cell_group::spikes() const {
+    return spikes_;
+}
+
+void lif_cell_group::clear_spikes() {
+    spikes_.clear();
+}
+
+// TODO: implement sampler
+void lif_cell_group::add_sampler(sampler_association_handle h, cell_member_predicate probe_ids,
+                                    schedule sched, sampler_function fn, sampling_policy policy) {}
+void lif_cell_group::remove_sampler(sampler_association_handle h) {}
+void lif_cell_group::remove_all_samplers() {}
+
+// TODO: implement binner_
+void lif_cell_group::set_binning_policy(binning_kind policy, time_type bin_interval) {
+}
+
+void lif_cell_group::reset() {
+    spikes_.clear();
+    last_time_updated_.clear();
+}
+
+// Advances a single cell (lid) with the exact solution (jumps can be arbitrary).
+// Parameter dt is ignored, since we make jumps between two consecutive spikes.
+void lif_cell_group::advance_cell(time_type tfinal, time_type dt, cell_gid_type lid, pse_vector& event_lane) {
+    // Current time of last update.
+    auto t = last_time_updated_[lid];
+    auto& cell = cells_[lid];
+    const auto n_events = event_lane.size();
+
+    // Integrate until tfinal using the exact solution of membrane voltage differential equation.
+    for (unsigned i=0; i<n_events; ++i ) {
+        auto& ev = event_lane[i];
+        const auto time = ev.time;
+        auto weight = ev.weight;
+
+        if (time < t) continue;        // skip event if a neuron is in refactory period
+        if (time >= tfinal) break;     // end of integration interval
+
+        // if there are events that happened at the same time as this event, process them as well
+        while (i + 1 < n_events && event_lane[i+1].time <= time) {
+            weight += event_lane[i+1].weight;
+            i++;
+        }
+
+        // Let the membrane potential decay.
+        auto decay = exp(-(time - t) / cell.tau_m);
+        cell.V_m *= decay;
+        auto update = weight / cell.C_m;
+        // Add jump due to spike.
+        cell.V_m += update;
+        t = time;
+        // If crossing threshold occurred
+        if (cell.V_m >= cell.V_th) {
+            cell_member_type spike_neuron_gid = {gids_[lid], 0};
+            spike s = {spike_neuron_gid, t};
+            spikes_.push_back(s);
+
+            // Advance the last_time_updated to account for the refractory period.
+            t += cell.t_ref;
+
+            // Reset the voltage to resting potential.
+            cell.V_m = cell.E_L;
+        }
+    }
+
+    // This is the last time a cell was updated.
+    last_time_updated_[lid] = t;
+}
diff --git a/src/lif_cell_group.hpp b/src/lif_cell_group.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..4a1067381f9b940eb794343e794e349b9c82a652
--- /dev/null
+++ b/src/lif_cell_group.hpp
@@ -0,0 +1,53 @@
+#pragma once
+#include <algorithm>
+#include <threading/timer.hpp>
+#include <cell_group.hpp>
+#include <event_queue.hpp>
+#include <lif_cell_description.hpp>
+#include <profiling/profiler.hpp>
+#include <recipe.hpp>
+#include <util/unique_any.hpp>
+#include <vector>
+
+namespace arb {
+class lif_cell_group: public cell_group {
+public:
+    using value_type = double;
+
+    lif_cell_group() = default;
+
+    // Constructor containing gid of first cell in a group and a container of all cells.
+    lif_cell_group(std::vector<cell_gid_type> gids, const recipe& rec);
+
+    virtual cell_kind get_cell_kind() const override;
+    virtual void reset() override;
+    virtual void set_binning_policy(binning_kind policy, time_type bin_interval) override;
+    virtual void advance(epoch epoch, time_type dt, const event_lane_subrange& events) override;
+
+    virtual const std::vector<spike>& spikes() const override;
+    virtual void clear_spikes() override;
+
+    // Sampler association methods below should be thread-safe, as they might be invoked
+    // from a sampler call back called from a different cell group running on a different thread.
+    virtual void add_sampler(sampler_association_handle, cell_member_predicate, schedule, sampler_function, sampling_policy) override;
+    virtual void remove_sampler(sampler_association_handle) override;
+    virtual void remove_all_samplers() override;
+
+private:
+    // Advances a single cell (lid) with the exact solution (jumps can be arbitrary).
+    // Parameter dt is ignored, since we make jumps between two consecutive spikes.
+    void advance_cell(time_type tfinal, time_type dt, cell_gid_type lid, pse_vector& event_lane);
+
+    // List of the gids of the cells in the group.
+    std::vector<cell_gid_type> gids_;
+
+    // Cells that belong to this group.
+    std::vector<lif_cell_description> cells_;
+
+    // Spikes that are generated (not necessarily sorted).
+    std::vector<spike> spikes_;
+
+    // Time when the cell was last updated.
+    std::vector<time_type> last_time_updated_;
+};
+} // namespace arb
diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt
index d913b07b58700a540f2db93d95f72300caa2917d..f2efc57058477160ec83cce1d79a9aa1c54c8a94 100644
--- a/tests/unit/CMakeLists.txt
+++ b/tests/unit/CMakeLists.txt
@@ -49,6 +49,7 @@ set(TEST_SOURCES
     test_event_queue.cpp
     test_filter.cpp
     test_fvm_multi.cpp
+    test_lif_cell_group.cpp
     test_mc_cell_group.cpp
     test_lexcmp.cpp
     test_mask_stream.cpp
diff --git a/tests/unit/test_lif_cell_group.cpp b/tests/unit/test_lif_cell_group.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8cc7f921a22892defacbbf555b34c29eaadf7401
--- /dev/null
+++ b/tests/unit/test_lif_cell_group.cpp
@@ -0,0 +1,233 @@
+#include "../gtest.h"
+#include <cell_group_factory.hpp>
+#include <fstream>
+#include <lif_cell_description.hpp>
+#include <lif_cell_group.hpp>
+#include <load_balance.hpp>
+#include <model.hpp>
+#include <recipe.hpp>
+#include <rss_cell.hpp>
+#include <rss_cell_group.hpp>
+
+using namespace arb;
+// Simple ring network of LIF neurons.
+// with one regularly spiking cell (fake cell) connected to the first cell in the ring.
+class ring_recipe: public arb::recipe {
+public:
+    ring_recipe(cell_size_type n_lif_cells, float weight, float delay):
+        n_lif_cells_(n_lif_cells), weight_(weight), delay_(delay)
+    {}
+
+    cell_size_type num_cells() const override {
+        return n_lif_cells_ + 1;
+    }
+
+    // LIF neurons have gid in range [1..n_lif_cells_] whereas fake cell is numbered with 0.
+    cell_kind get_cell_kind(cell_gid_type gid) const override {
+        if (gid == 0) {
+            return cell_kind::regular_spike_source;
+        }
+        return cell_kind::lif_neuron;
+    }
+
+    std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
+        if (gid == 0) {
+            return {};
+        }
+
+        // In a ring, each cell has just one incoming connection.
+        std::vector<cell_connection> connections;
+        // gid-1 >= 0 since gid != 0
+        cell_member_type source{(gid - 1) % n_lif_cells_, 0};
+        cell_member_type target{gid, 0};
+        cell_connection conn(source, target, weight_, delay_);
+        connections.push_back(conn);
+
+        // If first LIF cell, then add
+        // the connection from the last LIF cell as well
+        if (gid == 1) {
+            cell_member_type source{n_lif_cells_, 0};
+            cell_member_type target{gid, 0};
+            cell_connection conn(source, target, weight_, delay_);
+            connections.push_back(conn);
+        }
+
+        return connections;
+    }
+
+    util::unique_any get_cell_description(cell_gid_type gid) const override {
+        // regularly spiking cell.
+        if (gid == 0) {
+            // Produces just a single spike at time 0ms.
+            auto rs = arb::rss_cell();
+            rs.start_time = 0;
+            rs.period = 1;
+            rs.stop_time = 0.5;
+            return rs;
+        }
+        // LIF cell.
+        return lif_cell_description();
+    }
+
+    cell_size_type num_sources(cell_gid_type) const override {
+        return 1;
+    }
+    cell_size_type num_targets(cell_gid_type) const override {
+        return 1;
+    }
+    cell_size_type num_probes(cell_gid_type) const override {
+        return 0;
+    }
+    probe_info get_probe(cell_member_type probe_id) const override {
+        return {};
+    }
+    std::vector<event_generator_ptr> event_generators(cell_gid_type) const override {
+        return {};
+    }
+
+private:
+    cell_size_type n_lif_cells_;
+    float weight_, delay_;
+};
+
+// LIF cells connected in the manner of a path 0->1->...->n-1.
+class path_recipe: public arb::recipe {
+public:
+    path_recipe(cell_size_type n, float weight, float delay):
+        ncells_(n), weight_(weight), delay_(delay)
+    {}
+
+    cell_size_type num_cells() const override {
+        return ncells_;
+    }
+
+    cell_kind get_cell_kind(cell_gid_type gid) const override {
+        return cell_kind::lif_neuron;
+    }
+
+    std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
+        if (gid == 0) {
+            return {};
+        }
+        std::vector<cell_connection> connections;
+        cell_member_type source{gid - 1, 0};
+        cell_member_type target{gid, 0};
+        cell_connection conn(source, target, weight_, delay_);
+        connections.push_back(conn);
+
+        return connections;
+    }
+
+    util::unique_any get_cell_description(cell_gid_type gid) const override {
+        return lif_cell_description();
+    }
+
+    cell_size_type num_sources(cell_gid_type) const override {
+        return 1;
+    }
+    cell_size_type num_targets(cell_gid_type) const override {
+        return 1;
+    }
+    cell_size_type num_probes(cell_gid_type) const override {
+        return 0;
+    }
+    probe_info get_probe(cell_member_type probe_id) const override {
+        return {};
+    }
+    std::vector<event_generator_ptr> event_generators(cell_gid_type) const override {
+        return {};
+    }
+
+private:
+    cell_size_type ncells_;
+    float weight_, delay_;
+};
+
+TEST(lif_cell_group, recipe)
+{
+    ring_recipe rr(100, 1, 0.1);
+    EXPECT_EQ(101u, rr.num_cells());
+    EXPECT_EQ(2u, rr.connections_on(1u).size());
+    EXPECT_EQ(1u, rr.connections_on(55u).size());
+    EXPECT_EQ(0u, rr.connections_on(1u)[0].source.gid);
+    EXPECT_EQ(100u, rr.connections_on(1u)[1].source.gid);
+}
+
+TEST(lif_cell_group, spikes) {
+    // make two lif cells
+    path_recipe recipe(2, 1000, 0.1);
+
+    hw::node_info nd;
+    nd.num_cpu_cores = threading::num_threads();
+
+    auto decomp = partition_load_balance(recipe, nd);
+    model m(recipe, decomp);
+
+    std::vector<postsynaptic_spike_event> events;
+
+    // First event to trigger the spike (first neuron).
+    events.push_back({{0, 0}, 1, 1000});
+
+    // This event happens inside the refractory period of the previous
+    // event, thus, should be ignored (first neuron)
+    events.push_back({{0, 0}, 1.1, 1000});
+
+    // This event happens long after the refractory period of the previous
+    // event, should thus trigger new spike (first neuron).
+    events.push_back({{0, 0}, 50, 1000});
+
+    m.inject_events(events);
+
+    time_type tfinal = 100;
+    time_type dt = 0.01;
+    m.run(tfinal, dt);
+
+    // we expect 4 spikes: 2 by both neurons
+    EXPECT_EQ(4u, m.num_spikes());
+}
+
+TEST(lif_cell_group, ring)
+{
+    // Total number of LIF cells.
+    cell_size_type num_lif_cells = 99;
+    double weight = 1000;
+    double delay = 1;
+
+    hw::node_info nd;
+    nd.num_cpu_cores = threading::num_threads();
+
+    // Total simulation time.
+    time_type simulation_time = 100;
+
+    auto recipe = ring_recipe(num_lif_cells, weight, delay);
+    auto decomp = partition_load_balance(recipe, nd);
+
+    // Creates a model with a ring recipe of lif neurons
+    model mod(recipe, decomp);
+
+    std::vector<spike> spike_buffer;
+
+    mod.set_global_spike_callback(
+        [&spike_buffer](const std::vector<spike>& spikes) {
+            spike_buffer.insert(spike_buffer.end(), spikes.begin(), spikes.end());
+        }
+    );
+
+    // Runs the simulation for simulation_time with given timestep
+    mod.run(simulation_time, 0.01);
+    // The total number of cells in all the cell groups.
+    // There is one additional fake cell (regularly spiking cell).
+    EXPECT_EQ(num_lif_cells + 1u, recipe.num_cells());
+
+    for (auto& spike : spike_buffer) {
+        // Assumes that delay = 1
+        // We expect that Regular Spiking Cell spiked at time 0s.
+        if (spike.source.gid == 0) {
+            EXPECT_EQ(0, spike.time);
+        // Other LIF cell should spike consecutively.
+        } else {
+            EXPECT_EQ(spike.source.gid, spike.time);
+        }
+    }
+}
+