diff --git a/arbor/include/arbor/simulation.hpp b/arbor/include/arbor/simulation.hpp
index b5de7b4a5cd3f416ddceddd541fa8f2904f3d280..86e30e104f1b0d44ff90deb38f83831522bb11ff 100644
--- a/arbor/include/arbor/simulation.hpp
+++ b/arbor/include/arbor/simulation.hpp
@@ -85,11 +85,6 @@ public:
     // start of the simulation.
     void set_epoch_callback(epoch_function = epoch_function{});
 
-    // Add events directly to targets.
-    // Must be called before calling simulation::run, and must contain events that
-    // are to be delivered at or after the current simulation time.
-    void inject_events(const cse_vector& events);
-
     // If remote connections are present, export only the spikes for which this
     // predicate returns true.
     void set_remote_spike_filter(const spike_predicate&);
diff --git a/arbor/include/arbor/spike_event.hpp b/arbor/include/arbor/spike_event.hpp
index d44cd7cd449b7b113a5c6e6e503758b51466190e..7e1fc637e82e99448dc24131a4f4df1a93b84ce7 100644
--- a/arbor/include/arbor/spike_event.hpp
+++ b/arbor/include/arbor/spike_event.hpp
@@ -29,13 +29,6 @@ ARB_DEFINE_LEXICOGRAPHIC_ORDERING(spike_event,(a.time,a.target,a.weight),(b.time
 
 using pse_vector = std::vector<spike_event>;
 
-struct cell_spike_events {
-    cell_gid_type target;
-    pse_vector events;
-};
-
-using cse_vector = std::vector<cell_spike_events>;
-
 ARB_ARBOR_API std::ostream& operator<<(std::ostream&, const spike_event&);
 
 } // namespace arb
diff --git a/arbor/simulation.cpp b/arbor/simulation.cpp
index 95963e85fd13f6067d271003d16d1dc9322fc079..55c027710a92c8892e7f568daabc561d91c608a8 100644
--- a/arbor/simulation.cpp
+++ b/arbor/simulation.cpp
@@ -108,8 +108,6 @@ public:
 
     void set_remote_spike_filter(const spike_predicate& p) { return communicator_.set_remote_spike_filter(p); }
 
-    void inject_events(const cse_vector& events);
-
     time_type min_delay() { return communicator_.min_delay(); }
 
     spike_export_function global_export_callback_;
@@ -560,22 +558,6 @@ std::vector<probe_metadata> simulation_state::get_probe_metadata(const cell_addr
     }
 }
 
-void simulation_state::inject_events(const cse_vector& events) {
-    // Push all events that are to be delivered to local cells into the
-    // pending event list for the event's target cell.
-    for (auto& [gid, pse_vector]: events) {
-        for (auto& e: pse_vector) {
-            if (e.time < epoch_.t1) {
-                throw bad_event_time(e.time, epoch_.t1);
-            }
-            // gid_to_local_ maps gid to index in local cells and of corresponding cell group.
-            if (auto lidx = util::value_by_key(gid_to_local_, gid)) {
-                pending_events_[lidx->cell_index].push_back(e);
-            }
-        }
-    }
-}
-
 // Simulation class implementations forward to implementation class.
 
 simulation_builder simulation::create(recipe const & rec) { return {rec}; };
@@ -639,10 +621,6 @@ void simulation::set_epoch_callback(epoch_function epoch_callback) {
     impl_->epoch_callback_ = std::move(epoch_callback);
 }
 
-void simulation::inject_events(const cse_vector& events) {
-    impl_->inject_events(events);
-}
-
 simulation::simulation(simulation&&) = default;
 
 simulation::~simulation() = default;
diff --git a/doc/cpp/simulation.rst b/doc/cpp/simulation.rst
index 6edca840e4ff6c7d1c3774d437dbfb3f2e73ba4f..23f8ccd040b6d95a62624463a9091550d279ebb9 100644
--- a/doc/cpp/simulation.rst
+++ b/doc/cpp/simulation.rst
@@ -100,14 +100,6 @@ Class documentation
         Returns a builder object to which the constructor arguments can be passed selectively (see
         also example above).
 
-    **Experimental inputs:**
-
-    .. cpp:function:: void inject_events(const pse_vector& events)
-
-        Add events directly to targets.
-        Must be called before calling :cpp:func:`run`, and must contain events that
-        are to be delivered at or after the current simulation time.
-
     **Updating Model State:**
 
     .. cpp:function:: void reset()
diff --git a/example/single/single.cpp b/example/single/single.cpp
index 200ad24a6ab1a222973843aef0d734864b623549..53b05855ef970b367fba613ae778fd1287731964 100644
--- a/example/single/single.cpp
+++ b/example/single/single.cpp
@@ -32,8 +32,10 @@ options parse_options(int argc, char** argv);
 arborio::loaded_morphology default_morphology();
 
 struct single_recipe: public arb::recipe {
-    explicit single_recipe(arborio::loaded_morphology m, arb::cv_policy pol):
-        morpho(std::move(m.morphology)) {
+    explicit single_recipe(arborio::loaded_morphology m, arb::cv_policy pol, float w):
+        morpho(std::move(m.morphology)),
+        syn_weight{w}
+    {
         gprop.default_parameters = arb::neuron_parameter_defaults;
         gprop.default_parameters.discretization = pol;
     }
@@ -74,15 +76,21 @@ struct single_recipe: public arb::recipe {
         return arb::cable_cell(morpho, decor, dict);
     }
 
+    std::vector<arb::event_generator> event_generators(arb::cell_gid_type) const override {
+        return {arb::explicit_generator_from_milliseconds({"synapse"}, syn_weight, std::vector{1.0})};
+    }
+
     arb::morphology morpho;
     arb::cable_cell_global_properties gprop;
+    float syn_weight;
 };
 
 int main(int argc, char** argv) {
     try {
         options opt = parse_options(argc, argv);
         single_recipe R(opt.swc_file.empty() ? default_morphology() : arborio::load_swc_arbor(opt.swc_file),
-                        opt.policy);
+                        opt.policy,
+                        opt.syn_weight);
 
         arb::simulation sim(R);
 
@@ -92,12 +100,6 @@ int main(int argc, char** argv) {
                         arb::regular_schedule(0.1*arb::units::ms),
                         arb::make_simple_sampler(traces));
 
-        // Trigger the single synapse (target is gid 0, index 0) at t = 1 ms
-        // with the given weight.
-        arb::spike_event spike = {0, 1., opt.syn_weight};
-        arb::cell_spike_events cell_spikes = {0, {spike}};
-        sim.inject_events({cell_spikes});
-
         sim.run(opt.t_end*arb::units::ms, opt.dt*arb::units::ms);
 
         for (auto entry: traces.at(0)) {
diff --git a/example/v_clamp/v-clamp.cpp b/example/v_clamp/v-clamp.cpp
index 9068065cd6111535201e88b9d1f5f3d345640b14..86f4441c3ff1aa034ab455a7c7fee0aefeb4c4d8 100644
--- a/example/v_clamp/v-clamp.cpp
+++ b/example/v_clamp/v-clamp.cpp
@@ -1,7 +1,5 @@
 #include <any>
-#include <fstream>
 #include <iostream>
-#include <stdexcept>
 #include <string>
 #include <vector>
 
@@ -33,7 +31,10 @@ options parse_options(int argc, char** argv);
 arborio::loaded_morphology default_morphology();
 
 struct single_recipe: public arb::recipe {
-    explicit single_recipe(arborio::loaded_morphology m, arb::cv_policy pol): morpho(std::move(m.morphology)) {
+    explicit single_recipe(arborio::loaded_morphology m, arb::cv_policy pol, float w):
+        morpho(std::move(m.morphology)),
+        syn_weight(w)
+    {
         gprop.default_parameters = arb::neuron_parameter_defaults;
         gprop.default_parameters.discretization = pol;
     }
@@ -75,15 +76,22 @@ struct single_recipe: public arb::recipe {
         return arb::cable_cell(morpho, decor, dict);
     }
 
+    std::vector<arb::event_generator> event_generators(arb::cell_gid_type) const override {
+        return {arb::explicit_generator_from_milliseconds({"synapse"}, syn_weight, std::vector{1.0})};
+    }
+
     std::optional<double> voltage_clamp;
     arb::morphology morpho;
     arb::cable_cell_global_properties gprop;
+    float syn_weight;
 };
 
 int main(int argc, char** argv) {
     try {
         options opt = parse_options(argc, argv);
-        single_recipe R(opt.swc_file.empty()? default_morphology(): arborio::load_swc_arbor(opt.swc_file), opt.policy);
+        single_recipe R(opt.swc_file.empty()? default_morphology(): arborio::load_swc_arbor(opt.swc_file),
+                        opt.policy,
+                        opt.syn_weight);
         R.voltage_clamp = opt.voltage;
         arb::simulation sim(R);
 
@@ -92,13 +100,6 @@ int main(int argc, char** argv) {
         arb::trace_vector<double> traces;
         sim.add_sampler(arb::all_probes, arb::regular_schedule(0.1*arb::units::ms), arb::make_simple_sampler(traces));
 
-        // Trigger the single synapse (target is gid 0, index 0) at t = 1 ms with
-        // the given weight.
-
-        arb::spike_event spike = {0, 1., opt.syn_weight};
-        arb::cell_spike_events cell_spikes = {0, {spike}};
-        sim.inject_events({cell_spikes});
-
         sim.run(opt.t_end*arb::units::ms, opt.dt*arb::units::ms);
 
         for (auto entry: traces.at(0)) {
diff --git a/python/example/single_cell_bluepyopt_l5pc.py b/python/example/single_cell_bluepyopt_l5pc.py
index 90e0e31adc558f1f822ea0a8a8d856a196c81541..ec305b0e35b0b7f0f1733b4a5b5b5bb2bfa97973 100755
--- a/python/example/single_cell_bluepyopt_l5pc.py
+++ b/python/example/single_cell_bluepyopt_l5pc.py
@@ -69,7 +69,7 @@ class single_recipe(arbor.recipe):
     def num_cells(self):
         return 1
 
-    # (6.3) Override the num_targets method
+    # (6.3) Override the cell_kind method
     def cell_kind(self, gid):
         return arbor.cell_kind.cable
 
diff --git a/test/unit/test_event_delivery.cpp b/test/unit/test_event_delivery.cpp
index 8800224e249e6669f65d0ca9c28b01e65547ec6e..b5985eabea0240abf98f0b6eee0bff7ac1def74e 100644
--- a/test/unit/test_event_delivery.cpp
+++ b/test/unit/test_event_delivery.cpp
@@ -25,6 +25,8 @@ using namespace arb;
 
 using n_cable_cell_recipe = homogeneous_recipe<cell_kind::cable, cable_cell>;
 
+constexpr time_type ev_delta_t = 0.2;
+
 struct test_recipe: public n_cable_cell_recipe {
     explicit test_recipe(int n): n_cable_cell_recipe(n, test_cell()) {}
 
@@ -43,6 +45,10 @@ struct test_recipe: public n_cable_cell_recipe {
 
         return c;
     }
+
+    std::vector<arb::event_generator> event_generators(arb::cell_gid_type gid) const override {
+        return {arb::explicit_generator_from_milliseconds({"synapse"}, 1.0f, std::vector{gid*ev_delta_t})};
+    }
 };
 
 using gid_vector = std::vector<cell_gid_type>;
@@ -66,14 +72,6 @@ std::vector<cell_gid_type> run_test_sim(const recipe& R, const group_gids_type&
                 spikes.insert(spikes.end(), ss.begin(), ss.end());
             });
 
-    constexpr time_type ev_delta_t = 0.2;
-
-    cse_vector cell_events;
-    for (unsigned i = 0; i<n; ++i) {
-        cell_events.push_back({i, {{0u, i*ev_delta_t, 1.f}}});
-    }
-
-    sim.inject_events(cell_events);
     sim.run((n+1)*ev_delta_t*arb::units::ms, 0.01*arb::units::ms);
 
     std::vector<cell_gid_type> spike_gids;
diff --git a/test/unit/test_lif_cell_group.cpp b/test/unit/test_lif_cell_group.cpp
index 8e13b222faa0b5b1e9bd153cd00c943110b9b124..72d79d858aa27a47b658348997d8170f8303c197 100644
--- a/test/unit/test_lif_cell_group.cpp
+++ b/test/unit/test_lif_cell_group.cpp
@@ -101,6 +101,16 @@ public:
         return cell;
     }
 
+    std::vector<arb::event_generator> event_generators(arb::cell_gid_type gid) const override {
+        if (gid != 0) return {};
+        return {arb::explicit_generator_from_milliseconds({"tgt"},
+                                                          1000.0,           // use a large weight to trigger spikes
+                                                          std::vector{ 1.0, // First event to trigger the spike
+                                                                       1.1, // inside refractory period; should be ignored
+                                                                       50.0 // long after previous event; should trigger new spike
+                                                  })};
+    }
+
 private:
     cell_size_type ncells_;
     float weight_, delay_;
@@ -177,20 +187,6 @@ TEST(lif_cell_group, spikes) {
     auto decomp = partition_load_balance(recipe, context);
     simulation sim(recipe, context, decomp);
 
-    cse_vector 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}}});
-
-    sim.inject_events(events);
     sim.run(100*U::ms, 0.01*U::ms);
 
     // we expect 4 spikes: 2 by both neurons