Repository: multiscale/muscle3 (language: python)

Issue:
Implement checkpointing API in C++ and Fortran
The 0.6.0 release added support for checkpointing to the Python API. The same should be exposed to the C++ and Fortran APIs as well.

Task list (no dependencies):

- [x] Port new internal classes to C++
  - [x] libmuscle.api_guard
  - [x] libmuscle.checkpoint_triggers
  - [x] libmuscle.snapshot
  - [x] libmuscle.snapshot_manager
    - [x] MMPClient and Communicator functions
- [x] Create unit tests for ported internal classes
  - [x] libmuscle.api_guard
  - [x] libmuscle.checkpoint_triggers
  - [x] libmuscle.snapshot
  - [x] libmuscle.snapshot_manager
    - [x] MMPClient and Communicator mocks

Task list, dependent on merging the [profiling branch](https://github.com/multiscale/muscle3/tree/profiling):

- [x] Implement InstanceFlags (PR #195)
  - [x] C++
  - [x] Fortran bindings
    - [x] Write tests for defined constants
- [x] Implement new calls to the muscle manager in MMPClient
- [x] Implement getting/setting port counts in Communicator

Task list, blocked by PR #195

- [x] Implement public API on Instance
  - [x] C++
  - [x] Fortran bindings
- [x] Add integration tests for checkpointing C++ and Fortran code
- [x] Documentation
  - [x] Add C++ and Fortran examples for checkpointing in reaction-diffusion
  - [x] Update checkpointing.rst to link to the C++ and Fortran examples
  - [x] Update checkpointing.rst with C++ and Fortran code samples



Discussion / hints:

Fix patch:
diff --git a/docs/source/checkpointing.rst b/docs/source/checkpointing.rst
index 3e1546ba..bbac8932 100644
--- a/docs/source/checkpointing.rst
+++ b/docs/source/checkpointing.rst
@@ -13,14 +13,6 @@ comes with built-in checkpointing support. This page describes in detail how to
 use the MUSCLE3 checkpointing API, how to specify checkpoints in the workflow
 configuration and how to resume a workflow.
 
-.. warning::
-
-    Checkpointing in MUSCLE3 version 0.6.0 is still in development: the API may
-    change in a future MUSCLE3 release.
-
-    Checkpointing is only available in the Python API. C++ and Fortran support
-    is planned for version 0.7.0.
-
 In the :ref:`user tutorial`, you can read about the checkpointing concepts and
 how to use the API when running and resuming MUSCLE3 simulations. This is
 followed by a :ref:`developer tutorial`, which explains how to add checkpointing
@@ -359,7 +351,7 @@ repository. Then execute the following command:
 .. code-block:: bash
 
     $ mkdir run_rd_example
-    $ muscle_manager --start-all --run-dir run_rd_example rd_implementations.ymmsl rd_checkpoints.ymmsl rd_settings.ymmsl
+    $ muscle_manager --start-all --run-dir run_rd_example rd_implementations.ymmsl rd_checkpoints_python.ymmsl rd_settings.ymmsl
 
 .. note::
 
@@ -372,11 +364,11 @@ repository. Then execute the following command:
         $ make test_examples
 
 The above command runs the ``muscle_manager`` and starts all components (the
-reaction model and the diffusion model). The ``rd_checkpoints.ymmsl`` file
+reaction model and the diffusion model). The ``rd_checkpoints_python.ymmsl`` file
 contains the checkpoint definitions used in this example:
 
-.. literalinclude:: examples/rd_checkpoints.ymmsl
-    :caption: ``docs/source/examples/rd_checkpoints.ymmsl, lines 31-33``
+.. literalinclude:: examples/rd_checkpoints_python.ymmsl
+    :caption: ``docs/source/examples/rd_checkpoints_python.ymmsl, lines 31-33``
     :lines: 31-33
     :language: yaml
 
@@ -428,7 +420,7 @@ point to the snapshot you want to resume from.
     :caption: Resume from an earlier snapshot. Replace ``<date>`` and ``<time>`` to point to an actual snapshot file.
 
     $ mkdir run_rd_resume
-    $ muscle_manager --start-all --run-dir run_rd_resume rd_implementations.ymmsl rd_checkpoints.ymmsl rd_settings.ymmsl run_rd_example/snapshots/snapshot_<date>_<time>.ymmsl
+    $ muscle_manager --start-all --run-dir run_rd_resume rd_implementations.ymmsl rd_checkpoints_python.ymmsl rd_settings.ymmsl run_rd_example/snapshots/snapshot_<date>_<time>.ymmsl
 
 When the command completes you can see the output in the new working directory
 ``run_rd_resume``.
@@ -654,7 +646,7 @@ Step 1: Set ``USES_CHECKPOINT_API`` on instance creation
 ````````````````````````````````````````````````````````
 
 As first step, you need to indicate that you intend to use the checkpoint API.
-You do this through the :attr:`~InstanceFlags.USES_CHECKPOINT_API` flag when
+You do this through the :py:attr:`~InstanceFlags.USES_CHECKPOINT_API` flag when
 creating the instance:
 
 .. tabs::
@@ -670,19 +662,53 @@ creating the instance:
             ports = ...
             instance = Instance(ports, USES_CHECKPOINT_API)
 
-    ..
-        group-tab:: C++
+        .. seealso::
+
+            API documentation for
+            :py:attr:`~libmuscle.InstanceFlags.USES_CHECKPOINT_API`.
+
+    ..  group-tab:: C++
 
         .. code-block:: C++
 
-            TODO
+            #include <libmuscle/libmuscle.hpp>
+            #include <ymmsl/ymmsl.hpp>
+
+            using libmuscle::PortsDescription;
+            using libmuscle::Instance;
+            using libmuscle::InstanceFlags;
+
+            ...
+
+            int main(int argc, char * argv[]) {
+                PortsDescription ports = ...;
+                Instance instance(argc, argv, ports, InstanceFlags::USES_CHECKPOINT_API);
 
-    ..
-        group-tab:: Fortran
+                ...
+            }
+
+        .. seealso::
+
+            API documentation for
+            :cpp:enumerator:`~libmuscle::impl::InstanceFlags::USES_CHECKPOINT_API`.
+
+    .. group-tab:: Fortran
 
         .. code-block:: Fortran
 
-            TODO
+            use ymmsl
+            use libmuscle
+
+            type(LIBMUSCLE_PortsDescription) :: ports
+            type(LIBMUSCLE_Instance) :: instance
+
+            ports = ...
+            instance = LIBMUSCLE_Instance_create( &
+                ports, LIBMUSCLE_InstanceFlags(USES_CHECKPOINT_API=.true.))
+
+        .. seealso::
+
+            API documentation for :f:func:`LIBMUSCLE_InstanceFlags`.
 
 
 If you do not set this flag, you'll get a runtime error when trying to use any
@@ -761,9 +787,16 @@ See :ref:`Example: implemented checkpoint hooks` for example implementations in
 the reaction-diffusion models and the component template.
 
 .. seealso::
-    Python API documentation:
-    :py:meth:`~libmuscle.Instance.should_save_snapshot`,
-    :py:meth:`~libmuscle.Instance.save_snapshot`.
+
+    - Python API documentation:
+      :py:meth:`~libmuscle.Instance.should_save_snapshot`,
+      :py:meth:`~libmuscle.Instance.save_snapshot`.
+    - C++ API documentation:
+      :cpp:func:`~libmuscle::impl::Instance::should_save_snapshot`,
+      :cpp:func:`~libmuscle::impl::Instance::save_snapshot`.
+    - Fortran API documentation:
+      :f:func:`LIBMUSCLE_Instance_should_save_snapshot`,
+      :f:func:`LIBMUSCLE_Instance_save_snapshot`.
 
 
 Final snapshots
@@ -788,9 +821,16 @@ See :ref:`Example: implemented checkpoint hooks` for example implementations in
 the reaction-diffusion models and the component template.
 
 .. seealso::
-    Python API documentation:
-    :py:meth:`~libmuscle.Instance.should_save_final_snapshot`,
-    :py:meth:`~libmuscle.Instance.save_final_snapshot`.
+
+    - Python API documentation:
+      :py:meth:`~libmuscle.Instance.should_save_final_snapshot`,
+      :py:meth:`~libmuscle.Instance.save_final_snapshot`.
+    - C++ API documentation:
+      :cpp:func:`~libmuscle::impl::Instance::should_save_final_snapshot`,
+      :cpp:func:`~libmuscle::impl::Instance::save_final_snapshot`.
+    - Fortran API documentation:
+      :f:func:`LIBMUSCLE_Instance_should_save_final_snapshot`,
+      :f:func:`LIBMUSCLE_Instance_save_final_snapshot`.
 
 
 Example: implemented checkpoint hooks
@@ -826,23 +866,19 @@ full contents of the files in the git repository.
                     :language: python
                     :diff: examples/python/reaction.py
 
-            ..
-                group-tab:: C++
+            .. group-tab:: C++
 
-                .. literalinclude:: examples/cpp/reaction.cpp
-                    :caption: ``docs/source/examples/cpp/reaction.cpp``
+                .. literalinclude:: tutorial_code/checkpointing_reaction_partial.cpp
+                    :caption: ``docs/source/tutorial_code/checkpointing_reaction_partial.cpp``
                     :language: c++
+                    :diff: examples/cpp/reaction.cpp
 
-                TODO
-
-            ..
-                group-tab:: Fortran
+            .. group-tab:: Fortran
 
-                .. literalinclude:: examples/fortran/reaction.f90
-                    :caption: ``docs/source/examples/fortran/reaction.f90``
+                .. literalinclude:: tutorial_code/checkpointing_reaction_partial.f90
+                    :caption: ``docs/source/tutorial_code/checkpointing_reaction_partial.f90``
                     :language: fortran
-
-                TODO
+                    :diff: examples/fortran/reaction.f90
 
     .. group-tab:: Diffusion model
 
@@ -867,23 +903,19 @@ full contents of the files in the git repository.
                     :language: python
                     :diff: examples/python/diffusion.py
 
-            ..
-                group-tab:: C++
+            .. group-tab:: C++
 
-                .. literalinclude:: examples/cpp/diffusion.cpp
-                    :caption: ``docs/source/examples/cpp/diffusion.cpp``
+                .. literalinclude:: tutorial_code/checkpointing_diffusion_partial.cpp
+                    :caption: ``docs/source/tutorial_code/checkpointing_diffusion_partial.cpp``
                     :language: c++
+                    :diff: examples/cpp/diffusion.cpp
 
-                TODO
-
-            ..
-                group-tab:: Fortran
+            .. group-tab:: Fortran
 
-                .. literalinclude:: examples/fortran/diffusion.f90
-                    :caption: ``docs/source/examples/fortran/diffusion.f90``
+                .. literalinclude:: tutorial_code/checkpointing_diffusion_partial.f90
+                    :caption: ``docs/source/tutorial_code/checkpointing_diffusion_partial.f90``
                     :language: fortran
-
-                TODO
+                    :diff: examples/fortran/diffusion.f90
 
     .. group-tab:: Generic template
 
@@ -896,23 +928,19 @@ full contents of the files in the git repository.
                     :language: python
                     :diff: templates/instance.py
 
-            ..
-                group-tab:: C++
+            .. group-tab:: C++
 
-                .. literalinclude:: templates/instance.cpp
-                    :caption: ``docs/source/templates/instance.cpp``
+                .. literalinclude:: tutorial_code/checkpointing_instance_partial.cpp
+                    :caption: ``docs/source/tutorial_code/checkpointing_instance_partial.cpp``
                     :language: c++
+                    :diff: templates/instance.cpp
 
-                TODO
-
-            ..
-                group-tab:: Fortran
+            .. group-tab:: Fortran
 
-                .. literalinclude:: templates/instance.f90
-                    :caption: ``docs/source/templates/instance.f90``
+                .. literalinclude:: tutorial_code/checkpointing_instance_partial.f90
+                    :caption: ``docs/source/tutorial_code/checkpointing_instance_partial.f90``
                     :language: fortran
-
-                TODO
+                    :diff: templates/instance.f90
 
 
 Step 3: Implement resume
@@ -954,10 +982,19 @@ See :ref:`Example: implemented checkpoint hooks and resume` for example
 implementations in the reaction-diffusion models and the component template.
 
 .. seealso::
-    Python API documentation: :py:meth:`~libmuscle.Instance.resuming`,
-    :py:meth:`~libmuscle.Instance.load_snapshot`,
-    :py:meth:`~libmuscle.Instance.should_init`.
 
+    - Python API documentation:
+      :py:meth:`~libmuscle.Instance.resuming`,
+      :py:meth:`~libmuscle.Instance.load_snapshot`,
+      :py:meth:`~libmuscle.Instance.should_init`.
+    - C++ API documentation:
+      :cpp:func:`~libmuscle::impl::Instance::resuming`,
+      :cpp:func:`~libmuscle::impl::Instance::load_snapshot`,
+      :cpp:func:`~libmuscle::impl::Instance::should_init`.
+    - Fortran API documentation:
+      :f:func:`LIBMUSCLE_Instance_resuming`,
+      :f:func:`LIBMUSCLE_Instance_load_snapshot`,
+      :f:func:`LIBMUSCLE_Instance_should_init`.
 
 Reload settings when resuming
 '''''''''''''''''''''''''''''
@@ -1000,23 +1037,19 @@ full contents of the files in the git repository.
                     :language: python
                     :diff: examples/python/reaction.py
 
-            ..
-                group-tab:: C++
+            .. group-tab:: C++
 
-                .. literalinclude:: examples/cpp/reaction.cpp
-                    :caption: ``docs/source/examples/cpp/reaction.cpp``
+                .. literalinclude:: examples/cpp/checkpointing_reaction.cpp
+                    :caption: ``docs/source/examples/cpp/checkpointing_reaction.cpp``
                     :language: c++
+                    :diff: examples/cpp/reaction.cpp
 
-                TODO
-
-            ..
-                group-tab:: Fortran
+            .. group-tab:: Fortran
 
-                .. literalinclude:: examples/fortran/reaction.f90
-                    :caption: ``docs/source/examples/fortran/reaction.f90``
+                .. literalinclude:: examples/fortran/checkpointing_reaction.f90
+                    :caption: ``docs/source/examples/fortran/checkpointing_reaction.f90``
                     :language: fortran
-
-                TODO
+                    :diff: examples/fortran/reaction.f90
 
     .. group-tab:: Diffusion model
 
@@ -1035,23 +1068,19 @@ full contents of the files in the git repository.
                     :language: python
                     :diff: examples/python/diffusion.py
 
-            ..
-                group-tab:: C++
+            .. group-tab:: C++
 
-                .. literalinclude:: examples/cpp/diffusion.cpp
-                    :caption: ``docs/source/examples/cpp/diffusion.cpp``
+                .. literalinclude:: examples/cpp/checkpointing_diffusion.cpp
+                    :caption: ``docs/source/examples/cpp/checkpointing_diffusion.cpp``
                     :language: c++
+                    :diff: examples/cpp/diffusion.cpp
 
-                TODO
-
-            ..
-                group-tab:: Fortran
+            .. group-tab:: Fortran
 
-                .. literalinclude:: examples/fortran/diffusion.f90
-                    :caption: ``docs/source/examples/fortran/diffusion.f90``
+                .. literalinclude:: examples/fortran/checkpointing_diffusion.f90
+                    :caption: ``docs/source/examples/fortran/checkpointing_diffusion.f90``
                     :language: fortran
-
-                TODO
+                    :diff: examples/fortran/diffusion.f90
 
     .. group-tab:: Generic template
 
@@ -1064,23 +1093,19 @@ full contents of the files in the git repository.
                     :language: python
                     :diff: templates/instance.py
 
-            ..
-                group-tab:: C++
+            .. group-tab:: C++
 
-                .. literalinclude:: templates/instance.cpp
-                    :caption: ``docs/source/templates/instance.cpp``
+                .. literalinclude:: templates/checkpointing_instance.cpp
+                    :caption: ``docs/source/templates/checkpointing_instance.cpp``
                     :language: c++
+                    :diff: templates/instance.cpp
 
-                TODO
-
-            ..
-                group-tab:: Fortran
+            .. group-tab:: Fortran
 
-                .. literalinclude:: templates/instance.f90
-                    :caption: ``docs/source/templates/instance.f90``
+                .. literalinclude:: templates/checkpointing_instance.f90
+                    :caption: ``docs/source/templates/checkpointing_instance.f90``
                     :language: fortran
-
-                TODO
+                    :diff: templates/instance.f90
 
 
 Components that do not keep state between reuse
@@ -1096,7 +1121,7 @@ on an ``F_INIT`` port and sending the converted data on an ``O_F`` port.
 
 If you indicate to libmuscle that your component does not keep state between
 reuse, libmuscle automatically provides checkpointing for your component. You do
-this by providing the :attr:`~InstanceFlags.KEEPS_NO_STATE_FOR_NEXT_USE` flag
+this by providing the ``~InstanceFlags.KEEPS_NO_STATE_FOR_NEXT_USE`` flag
 when creating the instance. See the below example for a variant of the example
 reaction model.
 
@@ -1109,26 +1134,25 @@ reaction model.
             :language: python
             :diff: examples/python/reaction.py
 
-    ..
-        group-tab:: C++
+    .. group-tab:: C++
 
-        .. literalinclude:: examples/cpp/reaction.cpp
-            :caption: ``docs/source/examples/cpp/reaction.cpp``
+        .. literalinclude:: examples/cpp/reaction_no_state_for_next_use.cpp
+            :caption: ``docs/source/examples/cpp/reaction_no_state_for_next_use.cpp``
             :language: c++
+            :diff: examples/cpp/reaction.cpp
 
-        TODO
+    .. group-tab:: Fortran
 
-    ..
-        group-tab:: Fortran
-
-        .. literalinclude:: examples/fortran/reaction.f90
-            :caption: ``docs/source/examples/fortran/reaction.f90``
+        .. literalinclude:: examples/fortran/reaction_no_state_for_next_use.f90
+            :caption: ``docs/source/examples/fortran/reaction_no_state_for_next_use.f90``
             :language: fortran
-
-        TODO
+            :diff: examples/fortran/reaction.f90
 
 .. seealso::
-    Python API documentation: :py:class:`libmuscle.Instance`.
+
+    - Python API documentation: :py:class:`~libmuscle.InstanceFlags`.
+    - C++ API documentation :cpp:enum:`~libmuscle::impl::InstanceFlags`.
+    - Fortran API documentation :f:type:`LIBMUSCLE_InstanceFlags`.
 
 
 Builtin validation
@@ -1141,5 +1165,42 @@ issues with the checkpointing implementation. When MUSCLE3 detects a problem, an
 error is raised to indicate what went wrong and point you in the right direction
 for fixing the problem.
 
-..
-    TODO: create an overview of the validation rules
+
+Checkpointing in MPI-enabled components
+```````````````````````````````````````
+
+Checkpionting in MPI-enabled components works in the same way as for non-MPI components.
+The main difference is that some API methods must be called by all processes, while
+others can only be called from the root process.
+
+- ``resuming()`` must be called simultaneously in all processes.
+- ``load_snapshot()`` may only be called on the root process. It is up to the model code
+  to scatter or broadcast the snapshot state to the non-root processes, if necessary.
+- ``should_init()`` must be called simultaneously in all processes.
+- ``should_save_snapshot()`` and ``should_save_final_snapshot()`` must be called
+  simultaneously in all processes.
+- ``save_snapshot()`` and ``save_final_snapshot()`` may only be called on the root
+  process. It is therefore up to the model code to gather the necessary state from the
+  non-root processes before saving the snapshot.
+
+.. seealso::
+
+    - C++ API documentation:
+
+      - :cpp:func:`~libmuscle::impl::Instance::resuming`
+      - :cpp:func:`~libmuscle::impl::Instance::load_snapshot`
+      - :cpp:func:`~libmuscle::impl::Instance::should_init`
+      - :cpp:func:`~libmuscle::impl::Instance::should_save_final_snapshot`
+      - :cpp:func:`~libmuscle::impl::Instance::save_final_snapshot`
+      - :cpp:func:`~libmuscle::impl::Instance::should_save_snapshot`
+      - :cpp:func:`~libmuscle::impl::Instance::save_snapshot`
+
+    - Fortran API documentation:
+
+      - :f:func:`LIBMUSCLE_Instance_resuming`
+      - :f:func:`LIBMUSCLE_Instance_load_snapshot`
+      - :f:func:`LIBMUSCLE_Instance_should_init`
+      - :f:func:`LIBMUSCLE_Instance_should_save_final_snapshot`
+      - :f:func:`LIBMUSCLE_Instance_save_final_snapshot`
+      - :f:func:`LIBMUSCLE_Instance_should_save_snapshot`
+      - :f:func:`LIBMUSCLE_Instance_save_snapshot`
diff --git a/docs/source/examples/Makefile b/docs/source/examples/Makefile
index 8507eb52..1364012c 100644
--- a/docs/source/examples/Makefile
+++ b/docs/source/examples/Makefile
@@ -109,12 +109,19 @@ clean:
 .PHONY: test_python
 test_python: base
 	. python/build/venv/bin/activate && DONTPLOT=1 muscle_manager --start-all rd_implementations.ymmsl rd_python.ymmsl rd_settings.ymmsl
-	. python/build/venv/bin/activate && DONTPLOT=1 muscle_manager --start-all rd_implementations.ymmsl rd_checkpoints.ymmsl rd_settings.ymmsl
+	. python/build/venv/bin/activate && DONTPLOT=1 muscle_manager --start-all rd_implementations.ymmsl rd_checkpoints_python.ymmsl rd_settings.ymmsl
+	# test restarting, sleep 1 second to prevent reusing the same run directory
+	. python/build/venv/bin/activate && sleep 1 && DONTPLOT=1 muscle_manager --start-all rd_implementations.ymmsl rd_checkpoints_python.ymmsl rd_settings.ymmsl \
+		$$(ls $$(ls -d run_checkpointing_reaction_diffusion_python* | tail -n1)/snapshots/*.ymmsl | head -n1)
 	make -C python test
 
 .PHONY: test_cpp
 test_cpp: base cpp
 	. python/build/venv/bin/activate && muscle_manager --start-all rd_implementations.ymmsl rd_cpp.ymmsl rd_settings.ymmsl
+	. python/build/venv/bin/activate && muscle_manager --start-all rd_implementations.ymmsl rd_checkpoints_cpp.ymmsl rd_settings.ymmsl
+	# test restarting, sleep 1 second to prevent reusing the same run directory
+	. python/build/venv/bin/activate && sleep 1 && muscle_manager --start-all rd_implementations.ymmsl rd_checkpoints_cpp.ymmsl rd_settings.ymmsl \
+		$$(ls $$(ls -d run_checkpointing_reaction_diffusion_cpp* | tail -n1)/snapshots/*.ymmsl | head -n1)
 	. python/build/venv/bin/activate && muscle_manager --start-all rd_implementations.ymmsl rd_python_cpp.ymmsl rd_settings.ymmsl
 	. python/build/venv/bin/activate && muscle_manager --start-all rd_implementations.ymmsl rdmc_cpp.ymmsl rdmc_settings.ymmsl
 
@@ -125,6 +132,10 @@ test_cpp_mpi: base cpp_mpi
 .PHONY: test_fortran
 test_fortran: base fortran
 	. python/build/venv/bin/activate && muscle_manager --start-all rd_implementations.ymmsl rd_fortran.ymmsl rd_settings.ymmsl
+	. python/build/venv/bin/activate && muscle_manager --start-all rd_implementations.ymmsl rd_checkpoints_fortran.ymmsl rd_settings.ymmsl
+	# test restarting, sleep 1 second to prevent reusing the same run directory
+	. python/build/venv/bin/activate && sleep 1 && muscle_manager --start-all rd_implementations.ymmsl rd_checkpoints_fortran.ymmsl rd_settings.ymmsl \
+		$$(ls $$(ls -d run_checkpointing_reaction_diffusion_fortran* | tail -n1)/snapshots/*.ymmsl | head -n1)
 	. python/build/venv/bin/activate && muscle_manager --start-all rd_implementations.ymmsl rd_python_fortran.ymmsl rd_settings.ymmsl
 	. python/build/venv/bin/activate && muscle_manager --start-all rd_implementations.ymmsl rdmc_fortran.ymmsl rdmc_settings.ymmsl
 
diff --git a/docs/source/examples/cpp/build/Makefile b/docs/source/examples/cpp/build/Makefile
index c5c46c9c..f706b875 100644
--- a/docs/source/examples/cpp/build/Makefile
+++ b/docs/source/examples/cpp/build/Makefile
@@ -5,7 +5,7 @@ MPI_CXXFLAGS := -std=c++14 -g $(shell pkg-config --cflags libmuscle_mpi ymmsl)
 MPI_LDFLAGS := $(shell pkg-config --libs libmuscle_mpi ymmsl)
 
 
-binaries := reaction diffusion mc_driver load_balancer
+binaries := reaction diffusion mc_driver load_balancer checkpointing_reaction checkpointing_diffusion
 mpi_binaries := reaction_mpi
 
 
diff --git a/docs/source/examples/cpp/checkpointing_diffusion.cpp b/docs/source/examples/cpp/checkpointing_diffusion.cpp
new file mode 100644
index 00000000..26d87fdc
--- /dev/null
+++ b/docs/source/examples/cpp/checkpointing_diffusion.cpp
@@ -0,0 +1,147 @@
+#include <cmath>
+#include <cstdlib>
+#include <iostream>
+#include <ostream>
+#include <vector>
+
+#include <libmuscle/libmuscle.hpp>
+#include <ymmsl/ymmsl.hpp>
+
+using libmuscle::Data;
+using libmuscle::Instance;
+using libmuscle::InstanceFlags;
+using libmuscle::Message;
+using ymmsl::Operator;
+
+
+/* Calculates the Laplacian of vector Z.
+ *
+ * @param Z A vector representing a series of samples along a line.
+ * @param dx The spacing between the samples.
+ */
+std::vector<double> laplacian(std::vector<double> const & Z, double dx) {
+    std::vector<double> result(Z.size() - 2);
+    for (std::size_t i = 0u; i < result.size(); ++i)
+        result[i] = (Z[i] + Z[i+2] - 2.0 * Z[i+1]) / (dx * dx);
+    return result;
+}
+
+
+/** Utility function packing data in a message for snapshotting
+ */
+Message create_state_message(
+        double t_cur, std::vector<std::vector<double>> const & Us)
+{
+    Data data = Data::nils(Us.size());
+    for (std::size_t i = 0; i < data.size(); ++i)
+        data[i] = Data::grid(Us[i].data(), {Us[i].size()});
+    return Message(t_cur, data);
+}
+
+
+/** A simple diffusion model on a 1d grid.
+ *
+ * The state of this model is a 1D grid of concentrations. It sends out the
+ * state on each timestep on 'state_out', and can receive an updated state
+ * on 'state_in' at each state update.
+ */
+void diffusion(int argc, char * argv[]) {
+    Instance instance(argc, argv, {
+            {Operator::O_I, {"state_out"}},
+            {Operator::S, {"state_in"}},
+            {Operator::O_F, {"final_state_out"}}},
+            InstanceFlags::USES_CHECKPOINT_API);
+
+    while (instance.reuse_instance()) {
+        // F_INIT
+        double t_max = instance.get_setting_as<double>("t_max");
+        double dt = instance.get_setting_as<double>("dt");
+        double x_max = instance.get_setting_as<double>("x_max");
+        double dx = instance.get_setting_as<double>("dx");
+        double d = instance.get_setting_as<double>("d");
+
+        double t_cur;
+        std::vector<std::vector<double>> Us;
+        std::vector<double> U(lrint(x_max / dx), 1e-20);
+
+        if (instance.resuming()) {
+            auto msg = instance.load_snapshot();
+            for (int i = 0; i < msg.data().size(); ++i) {
+                auto const & data_i = msg.data()[i];
+                if (data_i.shape().size() != 1u || data_i.size() != U.size()) {
+                    auto err_msg = "Received state of incorrect shape or size!";
+                    instance.error_shutdown(err_msg);
+                    throw std::runtime_error(err_msg);
+                }
+                std::copy_n(data_i.elements<double>(), data_i.size(), U.begin());
+                Us.push_back(U);
+            }
+            t_cur = msg.timestamp();
+        }
+
+        if (instance.should_init()) {
+            U = std::vector<double>(lrint(x_max / dx), 1e-20);
+            U[25] = 2.0;
+            U[50] = 2.0;
+            U[75] = 2.0;
+
+            Us.push_back(U);
+
+            t_cur = 0.0;
+        }
+
+        while (t_cur + dt <= t_max) {
+            std::cerr << "t_cur: " << t_cur << ", t_max: " << t_max << std::endl;
+            // O_I
+            auto data = Data::grid(U.data(), {U.size()}, {"x"});
+            Message cur_state_msg(t_cur, data);
+            double t_next = t_cur + dt;
+            if (t_next + dt <= t_max)
+                cur_state_msg.set_next_timestamp(t_next);
+            instance.send("state_out", cur_state_msg);
+
+            // S
+            auto msg = instance.receive("state_in", cur_state_msg);
+            if (msg.data().shape().size() != 1u || msg.data().size() != U.size()) {
+                auto msg = "Received state of incorrect shape or size!";
+                instance.error_shutdown(msg);
+                throw std::runtime_error(msg);
+            }
+            std::copy_n(msg.data().elements<double>(), msg.data().size(), U.begin());
+
+            std::vector<double> dU(U.size());
+            auto lpl = laplacian(U, dx);
+            for (std::size_t i = 1u; i < dU.size() - 1; ++i)
+                dU[i] = d * lpl[i-1] * dt;
+            dU[0] = dU[1];
+            dU[dU.size() - 1] = dU[dU.size() - 2];
+
+            for (std::size_t i = 0u; i < dU.size(); ++i)
+                U[i] += dU[i];
+
+            Us.push_back(U);
+            t_cur += dt;
+
+            if (instance.should_save_snapshot(t_cur)) {
+                instance.save_snapshot(create_state_message(t_cur, Us));
+            }
+        }
+
+        // O_F
+        auto data = Data::grid(U.data(), {U.size()}, {"x"});
+        instance.send("final_state_out", Message(t_cur, data));
+        std::cerr << "All done" << std::endl;
+
+
+        if (instance.should_save_final_snapshot()) {
+            instance.save_snapshot(create_state_message(t_cur, Us));
+        }
+    }
+}
+
+
+int main(int argc, char * argv[]) {
+    diffusion(argc, argv);
+    return EXIT_SUCCESS;
+}
+
diff --git a/docs/source/examples/cpp/checkpointing_reaction.cpp b/docs/source/examples/cpp/checkpointing_reaction.cpp
new file mode 100644
index 00000000..d1783f77
--- /dev/null
+++ b/docs/source/examples/cpp/checkpointing_reaction.cpp
@@ -0,0 +1,84 @@
+#include <cstdlib>
+#include <vector>
+
+#include <libmuscle/libmuscle.hpp>
+#include <ymmsl/ymmsl.hpp>
+
+
+using libmuscle::Data;
+using libmuscle::DataConstRef;
+using libmuscle::Instance;
+using libmuscle::InstanceFlags;
+using libmuscle::Message;
+using ymmsl::Operator;
+
+
+/** A simple exponential reaction model on a 1D grid.
+ */
+void reaction(int argc, char * argv[]) {
+    Instance instance(argc, argv, {
+            {Operator::F_INIT, {"initial_state"}},  // 1D Grid
+            {Operator::O_F, {"final_state"}}},      // 1D Grid
+            InstanceFlags::USES_CHECKPOINT_API);
+
+    while (instance.reuse_instance()) {
+
+        // F_INIT
+        double t_max = instance.get_setting_as<double>("t_max");
+        double dt = instance.get_setting_as<double>("dt");
+        double k = instance.get_setting_as<double>("k");
+        double t_stop, t_cur;
+        std::vector<double> U;
+
+        if (instance.resuming()) {
+            auto msg = instance.load_snapshot();
+            if (!msg.data().is_nil()) {
+                // A final snapshot does not have data in it, but that's fine: we
+                // will do the F_INIT step inside `should_init()` below.
+                auto data_ptr = msg.data()[0].elements<double>();
+                U = std::vector<double>(data_ptr, data_ptr + msg.data()[0].size());
+                t_cur = msg.timestamp();
+                t_stop = msg.data()[1].as<double>();
+            }
+        }
+
+        if (instance.should_init()) {
+            auto msg = instance.receive("initial_state");
+            auto data_ptr = msg.data().elements<double>();
+            U = std::vector<double>(data_ptr, data_ptr + msg.data().size());
+            t_cur = msg.timestamp();
+            t_stop = msg.timestamp() + t_max;
+        }
+
+        while (t_cur + dt < t_stop) {
+            // O_I
+
+            // S
+            for (double & u : U)
+                u += k * u * dt;
+            t_cur += dt;
+
+            if (instance.should_save_snapshot(t_cur)) {
+                Message msg(t_cur,
+                        Data::list(Data::grid(U.data(), {U.size()}, {"x"}), t_stop));
+                instance.save_snapshot(msg);
+            }
+        }
+
+        // O_F
+        auto result = Data::grid(U.data(), {U.size()}, {"x"});
+        instance.send("final_state", Message(t_cur, result));
+
+        if (instance.should_save_final_snapshot()) {
+            Message msg(t_cur);
+            instance.save_final_snapshot(msg);
+        }
+    }
+}
+
+
+int main(int argc, char * argv[]) {
+    reaction(argc, argv);
+    return EXIT_SUCCESS;
+}
+
diff --git a/docs/source/examples/cpp/reaction_no_state_for_next_use.cpp b/docs/source/examples/cpp/reaction_no_state_for_next_use.cpp
new file mode 100644
index 00000000..7250dfcb
--- /dev/null
+++ b/docs/source/examples/cpp/reaction_no_state_for_next_use.cpp
@@ -0,0 +1,56 @@
+#include <cstdlib>
+#include <vector>
+
+#include <libmuscle/libmuscle.hpp>
+#include <ymmsl/ymmsl.hpp>
+
+
+using libmuscle::Data;
+using libmuscle::DataConstRef;
+using libmuscle::Instance;
+using libmuscle::InstanceFlags;
+using libmuscle::Message;
+using ymmsl::Operator;
+
+
+/** A simple exponential reaction model on a 1D grid.
+ */
+void reaction(int argc, char * argv[]) {
+    Instance instance(argc, argv, {
+            {Operator::F_INIT, {"initial_state"}},  // 1D Grid
+            {Operator::O_F, {"final_state"}}},      // 1D Grid
+            InstanceFlags::KEEPS_NO_STATE_FOR_NEXT_USE);
+
+    while (instance.reuse_instance()) {
+
+        // F_INIT
+        double t_max = instance.get_setting_as<double>("t_max");
+        double dt = instance.get_setting_as<double>("dt");
+        double k = instance.get_setting_as<double>("k");
+
+        auto msg = instance.receive("initial_state");
+        auto data_ptr = msg.data().elements<double>();
+        std::vector<double> U(data_ptr, data_ptr + msg.data().size());
+
+        double t_cur = msg.timestamp();
+        while (t_cur + dt < msg.timestamp() + t_max) {
+            // O_I
+
+            // S
+            for (double & u : U)
+                u += k * u * dt;
+            t_cur += dt;
+        }
+
+        // O_F
+        auto result = Data::grid(U.data(), {U.size()}, {"x"});
+        instance.send("final_state", Message(t_cur, result));
+    }
+}
+
+
+int main(int argc, char * argv[]) {
+    reaction(argc, argv);
+    return EXIT_SUCCESS;
+}
+
diff --git a/docs/source/examples/fortran/build/Makefile b/docs/source/examples/fortran/build/Makefile
index 2d859c3f..02855262 100644
--- a/docs/source/examples/fortran/build/Makefile
+++ b/docs/source/examples/fortran/build/Makefile
@@ -17,7 +17,7 @@ else
 endif
 
 
-binaries := reaction diffusion mc_driver load_balancer
+binaries := reaction diffusion mc_driver load_balancer checkpointing_reaction checkpointing_diffusion
 mpi_binaries := reaction_mpi
 
 
diff --git a/docs/source/examples/fortran/checkpointing_diffusion.f90 b/docs/source/examples/fortran/checkpointing_diffusion.f90
new file mode 100644
index 00000000..09a40dc3
--- /dev/null
+++ b/docs/source/examples/fortran/checkpointing_diffusion.f90
@@ -0,0 +1,173 @@
+! A simple diffusion model on a 1D grid.
+!
+! The state of this model is a 1D grid of concentrations. It sends out the
+! state on each timestep on 'state_out', and can receive an updated state
+! on 'state_in' at each state update.
+
+program diffusion
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+
+    type(LIBMUSCLE_Message) :: rmsg
+    type(LIBMUSCLE_DataConstRef) :: rdata, item
+    integer (LIBMUSCLE_size), dimension(2) :: shp
+
+    type(LIBMUSCLE_Message) :: smsg
+    type(LIBMUSCLE_Data) :: sdata, sitem
+
+    real (selected_real_kind(15)) :: t_cur, t_next, t_max, dt, x_max, dx, d
+    integer (LIBMUSCLE_size) :: U_size, n_steps, iteration
+    real (selected_real_kind(15)), dimension(:), allocatable :: U, dU
+    real (selected_real_kind(15)), dimension(:, :), allocatable :: Us
+
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_I, 'state_out')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_S, 'state_in')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_F, 'final_state_out')
+    instance = LIBMUSCLE_Instance_create(ports, &
+            LIBMUSCLE_InstanceFlags(USES_CHECKPOINT_API=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while (LIBMUSCLE_Instance_reuse_instance(instance))
+        ! F_INIT
+        t_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't_max')
+        dt = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dt')
+        x_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'x_max')
+        dx = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dx')
+        d = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'd')
+
+        if (LIBMUSCLE_Instance_resuming(instance)) then
+            rmsg = LIBMUSCLE_Instance_load_snapshot(instance)
+            rdata = LIBMUSCLE_Message_get_data(rmsg)
+
+            item = LIBMUSCLE_DataConstRef_get_item(rdata, 1_LIBMUSCLE_size)
+            call LIBMUSCLE_DataConstRef_shape(item, shp)
+            U_size = shp(1)
+            n_steps = shp(2)
+            allocate (Us(shp(1), shp(2)))
+            call LIBMUSCLE_DataConstRef_elements(item, Us)
+            call LIBMUSCLE_DataConstRef_free(item)
+
+            item = LIBMUSCLE_DataConstRef_get_item(rdata, 2_LIBMUSCLE_size)
+            iteration = LIBMUSCLE_DataConstRef_as_int(item)
+            call LIBMUSCLE_DataConstRef_free(item)
+
+            U = Us(:, iteration)
+            allocate(dU(U_size))
+            t_cur = LIBMUSCLE_Message_timestamp(rmsg)
+
+            call LIBMUSCLE_DataConstRef_free(rdata)
+            call LIBMUSCLE_Message_free(rmsg)
+        end if
+
+        if (LIBMUSCLE_Instance_should_init(instance)) then
+            U_size = nint(x_max / dx)
+            allocate (U(U_size), dU(U_size))
+            U = 1e-20
+            U(26) = 2.0
+            U(51) = 2.0
+            U(76) = 2.0
+
+            n_steps = int(t_max / dt)
+            allocate (Us(U_size, n_steps))
+
+            iteration = 1
+            Us(:, iteration) = U
+
+            t_cur = 0.0
+        end if
+
+        do while (t_cur + dt < t_max)
+            print *, 't_cur: ', t_cur, 't_max: ', t_max
+            ! O_I
+            sdata = LIBMUSCLE_Data_create_grid(U, 'x')
+            smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+            call LIBMUSCLE_Data_free(sdata)
+            t_next = t_cur + dt
+            if (t_next + dt <= t_max) then
+                call LIBMUSCLE_Message_set_next_timestamp(smsg, t_next)
+            end if
+            call LIBMUSCLE_Instance_send(instance, 'state_out', smsg)
+
+            ! S
+            rmsg = LIBMUSCLE_Instance_receive(instance, 'state_in', smsg)
+            rdata = LIBMUSCLE_Message_get_data(rmsg)
+            call LIBMUSCLE_DataConstRef_elements(rdata, U)
+            call LIBMUSCLE_DataConstRef_free(rdata)
+            call LIBMUSCLE_Message_free(rmsg)
+            call LIBMUSCLE_Message_free(smsg)
+
+            dU(2:U_size-1) = d * laplacian(U, dx) * dt
+            dU(1) = dU(2)
+            dU(U_size) = dU(U_size - 1)
+
+            U = U + dU
+            iteration = iteration + 1
+            Us(:, iteration) = U
+
+            t_cur = t_cur + dt
+
+            if (LIBMUSCLE_Instance_should_save_snapshot(instance, t_cur)) then
+                sdata = LIBMUSCLE_Data_create_nils(2_LIBMUSCLE_size)
+                sitem = LIBMUSCLE_Data_create_grid(Us)
+                call LIBMUSCLE_Data_set_item(sdata, 1_LIBMUSCLE_size, sitem)
+                call LIBMUSCLE_Data_set_item(sdata, 2_LIBMUSCLE_size, iteration)
+                smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+                call LIBMUSCLE_Instance_save_snapshot(instance, smsg)
+                call LIBMUSCLE_Message_free(smsg)
+                call LIBMUSCLE_Data_free(sdata)
+                call LIBMUSCLE_Data_free(sitem)
+            end if
+        end do
+
+        ! O_F
+        sdata = LIBMUSCLE_Data_create_grid(U, 'x')
+        smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+        call LIBMUSCLE_Instance_send(instance, 'final_state_out', smsg)
+        call LIBMUSCLE_Message_free(smsg)
+        call LIBMUSCLE_Data_free(sdata)
+
+        if (LIBMUSCLE_Instance_should_save_final_snapshot(instance)) then
+            sdata = LIBMUSCLE_Data_create_nils(2_LIBMUSCLE_size)
+            sitem = LIBMUSCLE_Data_create_grid(Us)
+            call LIBMUSCLE_Data_set_item(sdata, 1_LIBMUSCLE_size, sitem)
+            call LIBMUSCLE_Data_set_item(sdata, 2_LIBMUSCLE_size, iteration)
+            smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+            call LIBMUSCLE_Instance_save_snapshot(instance, smsg)
+            call LIBMUSCLE_Message_free(smsg)
+            call LIBMUSCLE_Data_free(sdata)
+            call LIBMUSCLE_Data_free(sitem)
+        end if
+
+        deallocate (U, dU, Us)
+        print *, 'All done'
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+contains
+
+    ! Calculates the Laplacian of vector Z.
+    !
+    ! @param Z A vector representing a series of samples along a line.
+    ! @param dx The spacing between the samples.
+
+    function laplacian(Z, dx)
+        real (selected_real_kind(15)), dimension(:), intent(in) :: Z
+        real (selected_real_kind(15)), intent(in) :: dx
+        real (selected_real_kind(15)), allocatable, dimension(:) :: laplacian
+        integer :: n
+
+        n = size(Z)
+        allocate(laplacian(size(Z) - 2))
+        laplacian = (Z(1:n-2) + Z(3:n) - 2.0d0 * Z(2:n-1)) / (dx * dx)
+
+    end function laplacian
+
+end program diffusion
+
diff --git a/docs/source/examples/fortran/checkpointing_reaction.f90 b/docs/source/examples/fortran/checkpointing_reaction.f90
new file mode 100644
index 00000000..32749bd3
--- /dev/null
+++ b/docs/source/examples/fortran/checkpointing_reaction.f90
@@ -0,0 +1,104 @@
+program reaction
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+
+    type(LIBMUSCLE_Message) :: rmsg
+    type(LIBMUSCLE_DataConstRef) :: rdata, item
+
+
+    type(LIBMUSCLE_Message) :: smsg
+    type(LIBMUSCLE_Data) :: sdata, sitem
+
+    real (selected_real_kind(15)) :: t_cur, t_max, dt, k
+    real (selected_real_kind(15)), dimension(:), allocatable :: U
+
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_F_INIT, 'initial_state')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_F, 'final_state')
+    instance = LIBMUSCLE_Instance_create(ports, &
+            LIBMUSCLE_InstanceFlags(USES_CHECKPOINT_API=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while (LIBMUSCLE_Instance_reuse_instance(instance))
+        ! F_INIT
+        t_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't_max')
+        dt = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dt')
+        k = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'k')
+
+        if (LIBMUSCLE_Instance_resuming(instance)) then
+            rmsg = LIBMUSCLE_Instance_load_snapshot(instance)
+            rdata = LIBMUSCLE_Message_get_data(rmsg)
+            if (.not. LIBMUSCLE_DataConstRef_is_nil(rdata)) then
+                ! A final snapshot does not have data in it, but that's fine: we
+                ! will do the F_INIT step inside `should_init` below.
+                item = LIBMUSCLE_DataConstRef_get_item(rdata, 1_LIBMUSCLE_size)
+                allocate (U(LIBMUSCLE_DataConstRef_size(item)))
+                call LIBMUSCLE_DataConstRef_elements(item, U)
+                call LIBMUSCLE_DataConstRef_free(item)
+
+                t_cur = LIBMUSCLE_Message_timestamp(rmsg)
+
+                item = LIBMUSCLE_DataConstRef_get_item(rdata, 2_LIBMUSCLE_size)
+                t_max = LIBMUSCLE_DataConstRef_as_real8(item)
+                call LIBMUSCLE_DataConstRef_free(item)
+            end if
+            call LIBMUSCLE_DataConstRef_free(rdata)
+            call LIBMUSCLE_Message_free(rmsg)
+        end if
+
+        if (LIBMUSCLE_Instance_should_init(instance)) then
+            rmsg = LIBMUSCLE_Instance_receive(instance, 'initial_state')
+            rdata = LIBMUSCLE_Message_get_data(rmsg)
+            allocate (U(LIBMUSCLE_DataConstRef_size(rdata)))
+            call LIBMUSCLE_DataConstRef_elements(rdata, U)
+            call LIBMUSCLE_DataConstRef_free(rdata)
+
+            t_cur = LIBMUSCLE_Message_timestamp(rmsg)
+            t_max = LIBMUSCLE_Message_timestamp(rmsg) + t_max
+            call LIBMUSCLE_Message_free(rmsg)
+        end if
+
+        do while (t_cur + dt < t_max)
+            ! O_I
+
+            ! S
+            U = k * U * dt
+            t_cur = t_cur + dt
+
+            if (LIBMUSCLE_Instance_should_save_snapshot(instance, t_cur)) then
+                sdata = LIBMUSCLE_Data_create_nils(2_LIBMUSCLE_size)
+                sitem = LIBMUSCLE_Data_create_grid(U, 'x')
+                call LIBMUSCLE_Data_set_item(sdata, 1_LIBMUSCLE_size, sitem)
+                call LIBMUSCLE_Data_set_item(sdata, 2_LIBMUSCLE_size, t_max)
+                smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+                call LIBMUSCLE_Instance_save_snapshot(instance, smsg)
+                call LIBMUSCLE_Message_free(smsg)
+                call LIBMUSCLE_Data_free(sitem)
+                call LIBMUSCLE_Data_free(sdata)
+            end if
+        end do
+
+        ! O_F
+        sdata = LIBMUSCLE_Data_create_grid(U, 'x')
+        smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+        call LIBMUSCLE_Instance_send(instance, 'final_state', smsg)
+        call LIBMUSCLE_Message_free(smsg)
+        call LIBMUSCLE_Data_free(sdata)
+        deallocate (U)
+
+        if (LIBMUSCLE_Instance_should_save_final_snapshot(instance)) then
+            smsg = LIBMUSCLE_Message_create(t_cur)
+            call LIBMUSCLE_Instance_save_final_snapshot(instance, smsg)
+            call LIBMUSCLE_Message_free(smsg)
+        end if
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+end program reaction
+
diff --git a/docs/source/examples/fortran/reaction_no_state_for_next_use.f90 b/docs/source/examples/fortran/reaction_no_state_for_next_use.f90
new file mode 100644
index 00000000..8979c265
--- /dev/null
+++ b/docs/source/examples/fortran/reaction_no_state_for_next_use.f90
@@ -0,0 +1,63 @@
+program reaction
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+
+    type(LIBMUSCLE_Message) :: rmsg
+    type(LIBMUSCLE_DataConstRef) :: rdata, item
+
+
+    type(LIBMUSCLE_Message) :: smsg
+    type(LIBMUSCLE_Data) :: sdata
+
+    real (selected_real_kind(15)) :: t_cur, t_max, dt, k
+    real (selected_real_kind(15)), dimension(:), allocatable :: U
+
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_F_INIT, 'initial_state')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_F, 'final_state')
+    instance = LIBMUSCLE_Instance_create(ports, &
+            LIBMUSCLE_InstanceFlags(KEEPS_NO_STATE_FOR_NEXT_USE=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while (LIBMUSCLE_Instance_reuse_instance(instance))
+        ! F_INIT
+        t_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't_max')
+        dt = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dt')
+        k = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'k')
+
+        rmsg = LIBMUSCLE_Instance_receive(instance, 'initial_state')
+        rdata = LIBMUSCLE_Message_get_data(rmsg)
+        allocate (U(LIBMUSCLE_DataConstRef_size(rdata)))
+        call LIBMUSCLE_DataConstRef_elements(rdata, U)
+        call LIBMUSCLE_DataConstRef_free(rdata)
+
+        t_cur = LIBMUSCLE_Message_timestamp(rmsg)
+        t_max = LIBMUSCLE_Message_timestamp(rmsg) + t_max
+        call LIBMUSCLE_Message_free(rmsg)
+
+        do while (t_cur + dt < t_max)
+            ! O_I
+
+            ! S
+            U = k * U * dt
+            t_cur = t_cur + dt
+        end do
+
+        ! O_F
+        sdata = LIBMUSCLE_Data_create_grid(U, 'x')
+        smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+        call LIBMUSCLE_Instance_send(instance, 'final_state', smsg)
+        call LIBMUSCLE_Message_free(smsg)
+        call LIBMUSCLE_Data_free(sdata)
+        deallocate (U)
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+end program reaction
+
diff --git a/docs/source/examples/python/reaction_no_state_for_next_use.py b/docs/source/examples/python/reaction_no_state_for_next_use.py
index 473d99e3..076514cb 100644
--- a/docs/source/examples/python/reaction_no_state_for_next_use.py
+++ b/docs/source/examples/python/reaction_no_state_for_next_use.py
@@ -30,7 +30,7 @@ def reaction() -> None:
             t_cur += dt
 
         # O_F
-        instance.send('final_state', Message(t_cur, None, Grid(U, ['x'])))
+        instance.send('final_state', Message(t_cur, data=Grid(U, ['x'])))
 
 
 if __name__ == '__main__':
diff --git a/docs/source/examples/rd_checkpoints_cpp.ymmsl b/docs/source/examples/rd_checkpoints_cpp.ymmsl
new file mode 100644
index 00000000..86215f61
--- /dev/null
+++ b/docs/source/examples/rd_checkpoints_cpp.ymmsl
@@ -0,0 +1,33 @@
+ymmsl_version: v0.1
+
+model:
+  name: checkpointing_reaction_diffusion_cpp
+
+  components:
+    macro:
+      implementation: checkpointing_diffusion_cpp
+      ports:
+        o_i: state_out
+        s: state_in
+
+    micro:
+      implementation: checkpointing_reaction_cpp
+      ports:
+        f_init: initial_state
+        o_f: final_state
+
+  conduits:
+    macro.state_out: micro.initial_state
+    micro.final_state: macro.state_in
+
+resources:
+  macro:
+    threads: 1
+  micro:
+    threads: 1
+
+# Developer note: below three lines are explicitly mentioned in checkpointing.rst.
+# Don't forget to update the documentation when changing this file.
+checkpoints:
+  simulation_time:
+  - every: 2.0e-05
diff --git a/docs/source/examples/rd_checkpoints_fortran.ymmsl b/docs/source/examples/rd_checkpoints_fortran.ymmsl
new file mode 100644
index 00000000..372d2cb9
--- /dev/null
+++ b/docs/source/examples/rd_checkpoints_fortran.ymmsl
@@ -0,0 +1,33 @@
+ymmsl_version: v0.1
+
+model:
+  name: checkpointing_reaction_diffusion_fortran
+
+  components:
+    macro:
+      implementation: checkpointing_diffusion_fortran
+      ports:
+        o_i: state_out
+        s: state_in
+
+    micro:
+      implementation: checkpointing_reaction_fortran
+      ports:
+        f_init: initial_state
+        o_f: final_state
+
+  conduits:
+    macro.state_out: micro.initial_state
+    micro.final_state: macro.state_in
+
+resources:
+  macro:
+    threads: 1
+  micro:
+    threads: 1
+
+# Developer note: below three lines are explicitly mentioned in checkpointing.rst.
+# Don't forget to update the documentation when changing this file.
+checkpoints:
+  simulation_time:
+  - every: 2.0e-05
diff --git a/docs/source/examples/rd_checkpoints.ymmsl b/docs/source/examples/rd_checkpoints_python.ymmsl
similarity index 78%
rename from docs/source/examples/rd_checkpoints.ymmsl
rename to docs/source/examples/rd_checkpoints_python.ymmsl
index 931d66bb..272d49fb 100644
--- a/docs/source/examples/rd_checkpoints.ymmsl
+++ b/docs/source/examples/rd_checkpoints_python.ymmsl
@@ -26,8 +26,8 @@ resources:
   micro:
     threads: 1
 
-# Note: below three lines are explicitly mentioned in checkpointing.rst. Do not
-# forget to update that literalinclude when the line numbers change!
+# Developer note: below three lines are explicitly mentioned in checkpointing.rst.
+# Don't forget to update the documentation when changing this file.
 checkpoints:
   simulation_time:
   - every: 2.0e-05
diff --git a/docs/source/examples/rd_implementations.ymmsl.in b/docs/source/examples/rd_implementations.ymmsl.in
index 92cc2b8d..05822d52 100644
--- a/docs/source/examples/rd_implementations.ymmsl.in
+++ b/docs/source/examples/rd_implementations.ymmsl.in
@@ -68,7 +68,27 @@ implementations:
     executable: python
     args: MUSCLE3_EXAMPLES/python/checkpointing_reaction.py
 
+  checkpointing_reaction_cpp:
+    env:
+      +LD_LIBRARY_PATH: :MUSCLE3_HOME/lib
+    executable: MUSCLE3_EXAMPLES/cpp/build/checkpointing_reaction
+
+  checkpointing_reaction_fortran:
+    env:
+      +LD_LIBRARY_PATH: :MUSCLE3_HOME/lib
+    executable: MUSCLE3_EXAMPLES/fortran/build/checkpointing_reaction
+
   checkpointing_diffusion_python:
     virtual_env: MUSCLE3_EXAMPLES/python/build/venv
     executable: python
     args: MUSCLE3_EXAMPLES/python/checkpointing_diffusion.py
+
+  checkpointing_diffusion_cpp:
+    env:
+      +LD_LIBRARY_PATH: :MUSCLE3_HOME/lib
+    executable: MUSCLE3_EXAMPLES/cpp/build/checkpointing_diffusion
+
+  checkpointing_diffusion_fortran:
+    env:
+      +LD_LIBRARY_PATH: :MUSCLE3_HOME/lib
+    executable: MUSCLE3_EXAMPLES/fortran/build/checkpointing_diffusion
diff --git a/docs/source/fortran_api.rst b/docs/source/fortran_api.rst
index 90bb433b..c68a1b0f 100644
--- a/docs/source/fortran_api.rst
+++ b/docs/source/fortran_api.rst
@@ -2116,6 +2116,149 @@ LIBMUSCLE_Instance
     :r message: The received message.
     :rtype message: LIBMUSCLE_Message
 
+.. f:function:: LIBMUSCLE_Instance_resuming()
+
+    Check if this instance is resuming from a snapshot.
+
+    Must be used by submodels that implement the checkpointing API. You'll
+    get a RuntimeError if you don't call this method in an iteration of the
+    reuse loop.
+
+    This method returns True for the first iteration of the reuse loop after
+    resuming from a previously taken snapshot. When resuming from a
+    snapshot, the submodel must load its state from the snapshot returned by
+    :f:func:`LIBMUSCLE_Instance_load_snapshot`.
+
+    MPI-based components must call this function in all processes
+    simultaneously.
+
+    :r resuming: ``.true.`` iff the submodel must resume from a snapshot.
+    :rtype resuming: logical
+
+.. f:function:: LIBMUSCLE_Instance_should_init()
+
+    Check if this instance should initialize.
+
+    Must be used by submodels that implement the checkpointing API.
+
+    When resuming from a previous snapshot, instances need not always
+    execute the F_INIT phase of the submodel execution loop. Use this method
+    before attempting to receive data on F_INIT ports.
+
+    MPI-based components must call this function in all processes
+    simultaneously.
+
+    :r should_init: ``.true.`` iff the submomdel must execute the F_INIT step.
+    :rtype should_init: logical
+
+.. f:function:: LIBMUSCLE_Instance_load_snapshot()
+
+    Load a snapshot.
+
+    Must only be called when Instance::resuming returns True.
+
+    MPI-based components may only call this from the root process. An error
+    is raised when attempting to call this method in any other process. It
+    is therefore up to the model code to scatter or broadcast the snapshot
+    state to the non-root processes, if necessary.
+
+    :r message: Message containing the state as saved in a previous run through
+            :f:func:`LIBMUSCLE_Instance_save_snapshot` or
+            :f:func:`LIBMUSCLE_Instance_save_final_snapshot`.
+    :rtype message: LIBMUSCLE_Message
+
+.. f:function:: LIBMUSCLE_Instance_should_save_snapshot(timestamp)
+
+    Check if a snapshot should be saved after the S Operator of the submodel.
+
+    This method checks if a snapshot should be saved right now, based on the
+    provided timestamp and elapsed wallclock time.
+
+    If this method returns true, then the submodel must also save a snapshot
+    through Instance::save_snapshot. A runtime error will be generated if
+    this is not done.
+
+    See also :f:func:`LIBMUSCLE_Instance_should_save_final_snapshot` for the
+    variant that must be called at the end of the reuse loop.
+
+    MPI-based components must call this function in all processes
+    simultaneously.
+
+    :p LIBMUSCLE_real8 timestamp: The current timestamp of the submodel.
+    :r should_save_snapshot: ``.true.`` iff a snapshot should be taken by the
+            submodel according to the checkpoint rules provided in the ymmsl
+            configuration.
+    :rtype should_save_snapshot: logical
+
+.. f:function:: LIBMUSCLE_Instance_save_snapshot(message)
+
+    Save a snapshot after the S Operator of the submodel.
+
+    Before saving a snapshot, you should check using
+    :f:func:`LIBMUSCLE_Instance_should_save_snapshot` if a snapshot should
+    be saved according to the checkpoint rules specified in the ymmsl
+    configuration. You should use the same timestamp in the provided Message
+    object as used to query :f:func:`LIBMUSCLE_Instance_should_save_snapshot`.
+
+    MPI-based components may only call this from the root process. An error
+    is raised when attempting to call this method in any other process. It
+    is therefore up to the model code to gather the necessary state from
+    the non-root processes before saving the snapshot.
+
+    :p LIBMUSCLE_Message message: Message object that is saved as snapshot. The message
+            timestamp attribute should be the same as passed to
+            :f:func:`LIBMUSCLE_Instance_should_save_snapshot`. The data attribute can
+            be used to store the internal state of the submodel.
+
+.. f:function:: LIBMUSCLE_Instance_should_save_final_snapshot()
+
+    Check if a snapshot should be saved at the end of the reuse loop.
+
+    This method checks if a snapshot should be saved at the end of the reuse
+    loop. All your communication on O_F ports must be finished before calling
+    this method, otherwise your simulation may deadlock.
+
+    When this method returns true, the submodel must also save a snapshot
+    through :f:func:`LIBMUSCLE_Instance_save_final_snapshot`. An error will be
+    generated if this is not done.
+
+    See also :f:func:`LIBMUSCLE_Instance_should_save_snapshot` for the variant
+    that may be called inside of a time-integration loop of the submodel.
+
+    MPI-based components must call this function in all processes
+    simultaneously.
+
+    .. note::
+
+        This method will block until it can determine whether a final
+        snapshot should be taken, because it must determine if this
+        instance is reused.
+
+    :r should_save_final_snapshot: ``.true.`` iff a final snapshot should be taken
+            by the submodel according to the checkpoint rules provided in the ymmsl
+            configuration.
+    :rtype should_save_final_snapshot: logical
+
+.. f:function:: LIBMUSCLE_Instance_save_final_snapshot(message)
+
+    Save a snapshot at the end of the reuse loop.
+
+    Before saving a snapshot, you should check using
+    :f:func:`LIBMUSCLE_Instance_should_save_final_snapshot` if a snapshot should
+    be saved according to the checkpoint rules specified in the ymmsl
+    configuration.
+
+    See also :f:func:`LIBMUSCLE_Instance_save_snapshot` for the variant that may
+    be called after each S Operator of the submodel.
+
+    MPI-based components may only call this from the root process. An error
+    is raised when attempting to call this method in any other process. It
+    is therefore up to the model code to gather the necessary state from
+    the non-root processes before saving the snapshot.
+
+    :p LIBMUSCLE_Message message: Message object that is saved as snapshot. The data
+            attribute can be used to store the internal state of the submodel.
+
 LIBMUSCLE_InstanceFlags
 ```````````````````````
 
diff --git a/docs/source/templates/checkpointing_instance.cpp b/docs/source/templates/checkpointing_instance.cpp
new file mode 100644
index 00000000..f0e2cb1f
--- /dev/null
+++ b/docs/source/templates/checkpointing_instance.cpp
@@ -0,0 +1,77 @@
+#include <cstdlib>
+#include <vector>
+
+#include <libmuscle/libmuscle.hpp>
+#include <ymmsl/ymmsl.hpp>
+
+
+using libmuscle::Data;
+using libmuscle::Instance;
+using libmuscle::InstanceFlags;
+using libmuscle::Message;
+using ymmsl::Operator;
+
+
+/**
+ * MUSCLE3 C++ component template.
+ *
+ * Note that this template is not executable as is, please have a look at the
+ * examples in ``docs/source/examples`` to see working components.
+ */
+int main(int argc, char * argv[]) {
+    Instance instance(argc, argv, {
+            {Operator::F_INIT, {"F_INIT_Port"}},
+            {Operator::O_I, {"O_I_Port"}},
+            {Operator::S, {"S_Port"}},
+            {Operator::O_F, {"O_F_Port"}}},
+            InstanceFlags::USES_CHECKPOINT_API);
+
+    while (instance.reuse_instance()) {
+        // F_INIT
+        auto setting = instance.get_setting("setting");
+        // ...
+
+        if (instance.resuming()) {
+            auto msg = instance.load_snapshot();
+            // ... restore state from message
+        }
+
+        if (instance.should_init()) {
+            instance.receive("F_INIT_Port");
+            // ...
+        }
+
+        while (t_cur <= t_max) {
+            // O_I
+            Message msg(t_cur, data);
+            if(t_cur + dt <= t_max)
+                msg.set_next_timestamp(t_cur + dt);
+            instance.send("O_I_Port", msg);
+            // ...
+
+            // S
+            instance.receive("S_Port");
+            // ...
+
+            t_cur += dt;
+
+            if (instance.should_save_snapshot(t_cur)) {
+                Data state; // collect state
+                Message msg(t_cur, state);
+                instance.save_snapshot(msg);
+            }
+        }
+
+        // O_F
+        instance.send("final_state", Message(t_cur, data));
+
+        if (instance.should_save_final_snapshot()) {
+            Data state; // collect state
+            Message msg(t_cur, state);
+            instance.save_final_snapshot(msg);
+        }
+    }
+
+    return EXIT_SUCCESS;
+}
+
diff --git a/docs/source/templates/checkpointing_instance.f90 b/docs/source/templates/checkpointing_instance.f90
new file mode 100644
index 00000000..0bcf2b76
--- /dev/null
+++ b/docs/source/templates/checkpointing_instance.f90
@@ -0,0 +1,92 @@
+program instance
+    ! MUSCLE3 Fortran component template.
+    !
+    ! Note that this template is not executable as is, please have a look at the
+    ! examples in ``docs/source/examples`` to see working components.
+
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+
+    type(LIBMUSCLE_Message) :: rmsg, smsg
+    type(LIBMUSCLE_Data) :: data
+
+    real (selected_real_kind(15)) :: t_cur, t_max, dt
+
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_F_INIT, 'F_INIT_Port')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_I, 'O_I_Port')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_S, 'S_Port')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_F, 'O_F_Port')
+    instance = LIBMUSCLE_Instance_create(ports, &
+            LIBMUSCLE_InstanceFlags(USES_CHECKPOINT_API=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while (LIBMUSCLE_Instance_reuse_instance(instance))
+        ! F_INIT
+        setting = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'setting')
+        ! ...
+
+
+        if (LIBMUSCLE_Instance_resuming(instance)) then
+            rmsg = LIBMUSCLE_Instance_load_snapshot(instance)
+            ! ... restore state from message
+            call LIBMUSCLE_Message_free(rmsg)
+        end if
+
+        if (LIBMUSCLE_Instance_should_init(instance)) then
+            rmsg = LIBMUSCLE_Instance_receive(instance, 'F_INIT')
+            call LIBMUSCLE_Message_free(rmsg)
+            ! ...
+        end if
+
+        do while (t_cur <= t_max)
+            ! O_I
+            smsg = LIBMUSCLE_Message_create(t_cur, data)
+            if (t_cur + dt <= t_max) then
+                call LIBMUSCLE_Message_set_next_timestamp(smsg, t_cur + dt)
+            end if
+            call LIBMUSCLE_Instance_send(instance, 'O_I', smsg)
+            call LIBMUSCLE_Message_free(smsg)
+            ! ...
+
+            ! S
+            rmsg = LIBMUSCLE_Instance_receive(instance, 'S')
+            call LIBMUSCLE_Message_free(rmsg)
+            ! ...
+
+            t_cur = t_cur + dt
+
+            if (LIBMUSCLE_Instance_should_save_snapshot(instance, t_cur)) then
+                sdata = LIBMUSCLE_Data_create() ! collect state
+                smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+                call LIBMUSCLE_Instance_save_snapshot(instance, smsg)
+                call LIBMUSCLE_Message_free(smsg)
+                call LIBMUSCLE_Data_free(sdata)
+            end if
+        end do
+
+        ! O_F
+        smsg = LIBMUSCLE_Message_create(t_cur, data)
+        call LIBMUSCLE_Instance_send(instance, 'final_state', smsg)
+        call LIBMUSCLE_Message_free(smsg)
+        ! ...
+
+        if (LIBMUSCLE_Instance_should_save_final_snapshot(instance)) then
+            sdata = LIBMUSCLE_Data_create() ! collect state
+            smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+            call LIBMUSCLE_Instance_save_final_snapshot(instance, smsg)
+            call LIBMUSCLE_Message_free(smsg)
+            call LIBMUSCLE_Data_free(sdata)
+        end if
+
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+end program reaction
+
diff --git a/docs/source/templates/instance.cpp b/docs/source/templates/instance.cpp
index 4913f9a1..ea200aaf 100644
--- a/docs/source/templates/instance.cpp
+++ b/docs/source/templates/instance.cpp
@@ -20,7 +20,7 @@ int main(int argc, char * argv[]) {
     Instance instance(argc, argv, {
             {Operator::F_INIT, {"F_INIT_Port"}},
             {Operator::O_I, {"O_I_Port"}},
-            {Operator::S, {"O_S_Port"}},
+            {Operator::S, {"S_Port"}},
             {Operator::O_F, {"O_F_Port"}}});
 
     while (instance.reuse_instance()) {
diff --git a/docs/source/tutorial_code/checkpointing_diffusion_partial.cpp b/docs/source/tutorial_code/checkpointing_diffusion_partial.cpp
new file mode 100644
index 00000000..0ffc4e29
--- /dev/null
+++ b/docs/source/tutorial_code/checkpointing_diffusion_partial.cpp
@@ -0,0 +1,126 @@
+#include <cmath>
+#include <cstdlib>
+#include <iostream>
+#include <ostream>
+#include <vector>
+
+#include <libmuscle/libmuscle.hpp>
+#include <ymmsl/ymmsl.hpp>
+
+using libmuscle::Data;
+using libmuscle::Instance;
+using libmuscle::InstanceFlags;
+using libmuscle::Message;
+using ymmsl::Operator;
+
+
+/* Calculates the Laplacian of vector Z.
+ *
+ * @param Z A vector representing a series of samples along a line.
+ * @param dx The spacing between the samples.
+ */
+std::vector<double> laplacian(std::vector<double> const & Z, double dx) {
+    std::vector<double> result(Z.size() - 2);
+    for (std::size_t i = 0u; i < result.size(); ++i)
+        result[i] = (Z[i] + Z[i+2] - 2.0 * Z[i+1]) / (dx * dx);
+    return result;
+}
+
+
+/** Utility function packing data in a message for snapshotting
+ */
+Message create_state_message(
+        double t_cur, std::vector<std::vector<double>> const & Us)
+{
+    Data data = Data::nils(Us.size());
+    for (std::size_t i = 0; i < data.size(); ++i)
+        data[i] = Data::grid(Us[i].data(), {Us[i].size()});
+    return Message(t_cur, data);
+}
+
+
+/** A simple diffusion model on a 1d grid.
+ *
+ * The state of this model is a 1D grid of concentrations. It sends out the
+ * state on each timestep on 'state_out', and can receive an updated state
+ * on 'state_in' at each state update.
+ */
+void diffusion(int argc, char * argv[]) {
+    Instance instance(argc, argv, {
+            {Operator::O_I, {"state_out"}},
+            {Operator::S, {"state_in"}},
+            {Operator::O_F, {"final_state_out"}}},
+            InstanceFlags::USES_CHECKPOINT_API);
+
+    while (instance.reuse_instance()) {
+        // F_INIT
+        double t_max = instance.get_setting_as<double>("t_max");
+        double dt = instance.get_setting_as<double>("dt");
+        double x_max = instance.get_setting_as<double>("x_max");
+        double dx = instance.get_setting_as<double>("dx");
+        double d = instance.get_setting_as<double>("d");
+
+        std::vector<double> U(lrint(x_max / dx), 1e-20);
+        U[25] = 2.0;
+        U[50] = 2.0;
+        U[75] = 2.0;
+
+        std::vector<std::vector<double>> Us;
+        Us.push_back(U);
+
+        double t_cur = 0.0;
+        while (t_cur + dt <= t_max) {
+            std::cerr << "t_cur: " << t_cur << ", t_max: " << t_max << std::endl;
+            // O_I
+            auto data = Data::grid(U.data(), {U.size()}, {"x"});
+            Message cur_state_msg(t_cur, data);
+            double t_next = t_cur + dt;
+            if (t_next + dt <= t_max)
+                cur_state_msg.set_next_timestamp(t_next);
+            instance.send("state_out", cur_state_msg);
+
+            // S
+            auto msg = instance.receive("state_in", cur_state_msg);
+            if (msg.data().shape().size() != 1u || msg.data().size() != U.size()) {
+                auto msg = "Received state of incorrect shape or size!";
+                instance.error_shutdown(msg);
+                throw std::runtime_error(msg);
+            }
+            std::copy_n(msg.data().elements<double>(), msg.data().size(), U.begin());
+
+            std::vector<double> dU(U.size());
+            auto lpl = laplacian(U, dx);
+            for (std::size_t i = 1u; i < dU.size() - 1; ++i)
+                dU[i] = d * lpl[i-1] * dt;
+            dU[0] = dU[1];
+            dU[dU.size() - 1] = dU[dU.size() - 2];
+
+            for (std::size_t i = 0u; i < dU.size(); ++i)
+                U[i] += dU[i];
+
+            Us.push_back(U);
+            t_cur += dt;
+
+            if (instance.should_save_snapshot(t_cur)) {
+                instance.save_snapshot(create_state_message(t_cur, Us));
+            }
+        }
+
+        // O_F
+        auto data = Data::grid(U.data(), {U.size()}, {"x"});
+        instance.send("final_state_out", Message(t_cur, data));
+        std::cerr << "All done" << std::endl;
+
+
+        if (instance.should_save_final_snapshot()) {
+            instance.save_snapshot(create_state_message(t_cur, Us));
+        }
+    }
+}
+
+
+int main(int argc, char * argv[]) {
+    diffusion(argc, argv);
+    return EXIT_SUCCESS;
+}
+
diff --git a/docs/source/tutorial_code/checkpointing_diffusion_partial.f90 b/docs/source/tutorial_code/checkpointing_diffusion_partial.f90
new file mode 100644
index 00000000..4b94e5a9
--- /dev/null
+++ b/docs/source/tutorial_code/checkpointing_diffusion_partial.f90
@@ -0,0 +1,146 @@
+! A simple diffusion model on a 1D grid.
+!
+! The state of this model is a 1D grid of concentrations. It sends out the
+! state on each timestep on 'state_out', and can receive an updated state
+! on 'state_in' at each state update.
+
+program diffusion
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+
+    type(LIBMUSCLE_Message) :: rmsg
+    type(LIBMUSCLE_DataConstRef) :: rdata, item
+    integer (LIBMUSCLE_size), dimension(2) :: shp
+
+    type(LIBMUSCLE_Message) :: smsg
+    type(LIBMUSCLE_Data) :: sdata, sitem
+
+    real (selected_real_kind(15)) :: t_cur, t_next, t_max, dt, x_max, dx, d
+    integer (LIBMUSCLE_size) :: U_size, n_steps, iteration
+    real (selected_real_kind(15)), dimension(:), allocatable :: U, dU
+    real (selected_real_kind(15)), dimension(:, :), allocatable :: Us
+
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_I, 'state_out')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_S, 'state_in')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_F, 'final_state_out')
+    instance = LIBMUSCLE_Instance_create(ports, &
+            LIBMUSCLE_InstanceFlags(USES_CHECKPOINT_API=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while (LIBMUSCLE_Instance_reuse_instance(instance))
+       ! F_INIT
+        t_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't_max')
+        dt = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dt')
+        x_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'x_max')
+        dx = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dx')
+        d = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'd')
+
+        U_size = nint(x_max / dx)
+        allocate (U(U_size), dU(U_size))
+        U = 1e-20
+        U(26) = 2.0
+        U(51) = 2.0
+        U(76) = 2.0
+
+        n_steps = int(t_max / dt)
+        allocate (Us(U_size, n_steps))
+
+        iteration = 1
+        Us(:, iteration) = U
+
+        t_cur = 0.0
+        do while (t_cur + dt < t_max)
+            print *, 't_cur: ', t_cur, 't_max: ', t_max
+            ! O_I
+            sdata = LIBMUSCLE_Data_create_grid(U, 'x')
+            smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+            call LIBMUSCLE_Data_free(sdata)
+            t_next = t_cur + dt
+            if (t_next + dt <= t_max) then
+                call LIBMUSCLE_Message_set_next_timestamp(smsg, t_next)
+            end if
+            call LIBMUSCLE_Instance_send(instance, 'state_out', smsg)
+
+            ! S
+            rmsg = LIBMUSCLE_Instance_receive(instance, 'state_in', smsg)
+            rdata = LIBMUSCLE_Message_get_data(rmsg)
+            call LIBMUSCLE_DataConstRef_elements(rdata, U)
+            call LIBMUSCLE_DataConstRef_free(rdata)
+            call LIBMUSCLE_Message_free(rmsg)
+            call LIBMUSCLE_Message_free(smsg)
+
+            dU(2:U_size-1) = d * laplacian(U, dx) * dt
+            dU(1) = dU(2)
+            dU(U_size) = dU(U_size - 1)
+
+            U = U + dU
+            iteration = iteration + 1
+            Us(:, iteration) = U
+
+            t_cur = t_cur + dt
+
+            if (LIBMUSCLE_Instance_should_save_snapshot(instance, t_cur)) then
+                sdata = LIBMUSCLE_Data_create_nils(2_LIBMUSCLE_size)
+                sitem = LIBMUSCLE_Data_create_grid(Us)
+                call LIBMUSCLE_Data_set_item(sdata, 1_LIBMUSCLE_size, sitem)
+                call LIBMUSCLE_Data_set_item(sdata, 2_LIBMUSCLE_size, iteration)
+                smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+                call LIBMUSCLE_Instance_save_snapshot(instance, smsg)
+                call LIBMUSCLE_Message_free(smsg)
+                call LIBMUSCLE_Data_free(sdata)
+                call LIBMUSCLE_Data_free(sitem)
+            end if
+        end do
+
+        ! O_F
+        sdata = LIBMUSCLE_Data_create_grid(U, 'x')
+        smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+        call LIBMUSCLE_Instance_send(instance, 'final_state_out', smsg)
+        call LIBMUSCLE_Message_free(smsg)
+        call LIBMUSCLE_Data_free(sdata)
+
+        if (LIBMUSCLE_Instance_should_save_final_snapshot(instance)) then
+            sdata = LIBMUSCLE_Data_create_nils(2_LIBMUSCLE_size)
+            sitem = LIBMUSCLE_Data_create_grid(Us)
+            call LIBMUSCLE_Data_set_item(sdata, 1_LIBMUSCLE_size, sitem)
+            call LIBMUSCLE_Data_set_item(sdata, 2_LIBMUSCLE_size, iteration)
+            smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+            call LIBMUSCLE_Instance_save_snapshot(instance, smsg)
+            call LIBMUSCLE_Message_free(smsg)
+            call LIBMUSCLE_Data_free(sdata)
+            call LIBMUSCLE_Data_free(sitem)
+        end if
+
+        deallocate (U, dU, Us)
+        print *, 'All done'
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+contains
+
+    ! Calculates the Laplacian of vector Z.
+    !
+    ! @param Z A vector representing a series of samples along a line.
+    ! @param dx The spacing between the samples.
+
+    function laplacian(Z, dx)
+        real (selected_real_kind(15)), dimension(:), intent(in) :: Z
+        real (selected_real_kind(15)), intent(in) :: dx
+        real (selected_real_kind(15)), allocatable, dimension(:) :: laplacian
+        integer :: n
+
+        n = size(Z)
+        allocate(laplacian(size(Z) - 2))
+        laplacian = (Z(1:n-2) + Z(3:n) - 2.0d0 * Z(2:n-1)) / (dx * dx)
+
+    end function laplacian
+
+end program diffusion
+
diff --git a/docs/source/tutorial_code/checkpointing_instance_partial.cpp b/docs/source/tutorial_code/checkpointing_instance_partial.cpp
new file mode 100644
index 00000000..480a4705
--- /dev/null
+++ b/docs/source/tutorial_code/checkpointing_instance_partial.cpp
@@ -0,0 +1,70 @@
+#include <cstdlib>
+#include <vector>
+
+#include <libmuscle/libmuscle.hpp>
+#include <ymmsl/ymmsl.hpp>
+
+
+using libmuscle::Data;
+using libmuscle::Instance;
+using libmuscle::InstanceFlags;
+using libmuscle::Message;
+using ymmsl::Operator;
+
+
+/**
+ * MUSCLE3 C++ component template.
+ *
+ * Note that this template is not executable as is, please have a look at the
+ * examples in ``docs/source/examples`` to see working components.
+ */
+int main(int argc, char * argv[]) {
+    Instance instance(argc, argv, {
+            {Operator::F_INIT, {"F_INIT_Port"}},
+            {Operator::O_I, {"O_I_Port"}},
+            {Operator::S, {"S_Port"}},
+            {Operator::O_F, {"O_F_Port"}}},
+            InstanceFlags::USES_CHECKPOINT_API);
+
+    while (instance.reuse_instance()) {
+        // F_INIT
+        auto setting = instance.get_setting("setting");
+        // ...
+
+        instance.receive("F_INIT_Port");
+        // ...
+
+        while (t_cur <= t_max) {
+            // O_I
+            Message msg(t_cur, data);
+            if(t_cur + dt <= t_max)
+                msg.set_next_timestamp(t_cur + dt);
+            instance.send("O_I_Port", msg);
+            // ...
+
+            // S
+            instance.receive("S_Port");
+            // ...
+
+            t_cur += dt;
+
+            if (instance.should_save_snapshot(t_cur)) {
+                Data state; // collect state
+                Message msg(t_cur, state);
+                instance.save_snapshot(msg);
+            }
+        }
+
+        // O_F
+        instance.send("final_state", Message(t_cur, data));
+
+        if (instance.should_save_final_snapshot()) {
+            Data state; // collect state
+            Message msg(t_cur, state);
+            instance.save_final_snapshot(msg);
+        }
+    }
+
+    return EXIT_SUCCESS;
+}
+
diff --git a/docs/source/tutorial_code/checkpointing_instance_partial.f90 b/docs/source/tutorial_code/checkpointing_instance_partial.f90
new file mode 100644
index 00000000..0e6de9dc
--- /dev/null
+++ b/docs/source/tutorial_code/checkpointing_instance_partial.f90
@@ -0,0 +1,83 @@
+program instance
+    ! MUSCLE3 Fortran component template.
+    !
+    ! Note that this template is not executable as is, please have a look at the
+    ! examples in ``docs/source/examples`` to see working components.
+
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+
+    type(LIBMUSCLE_Message) :: rmsg, smsg
+    type(LIBMUSCLE_Data) :: data
+
+    real (selected_real_kind(15)) :: t_cur, t_max, dt
+
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_F_INIT, 'F_INIT_Port')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_I, 'O_I_Port')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_S, 'S_Port')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_F, 'O_F_Port')
+    instance = LIBMUSCLE_Instance_create(ports, &
+            LIBMUSCLE_InstanceFlags(USES_CHECKPOINT_API=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while (LIBMUSCLE_Instance_reuse_instance(instance))
+        ! F_INIT
+        setting = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'setting')
+        ! ...
+
+        rmsg = LIBMUSCLE_Instance_receive(instance, 'F_INIT')
+        call LIBMUSCLE_Message_free(rmsg)
+        ! ...
+
+        do while (t_cur <= t_max)
+            ! O_I
+            smsg = LIBMUSCLE_Message_create(t_cur, data)
+            if (t_cur + dt <= t_max) then
+                call LIBMUSCLE_Message_set_next_timestamp(smsg, t_cur + dt)
+            end if
+            call LIBMUSCLE_Instance_send(instance, 'O_I', smsg)
+            call LIBMUSCLE_Message_free(smsg)
+            ! ...
+
+            ! S
+            rmsg = LIBMUSCLE_Instance_receive(instance, 'S')
+            call LIBMUSCLE_Message_free(rmsg)
+            ! ...
+
+            t_cur = t_cur + dt
+
+            if (LIBMUSCLE_Instance_should_save_snapshot(instance, t_cur)) then
+                sdata = LIBMUSCLE_Data_create() ! collect state
+                smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+                call LIBMUSCLE_Instance_save_snapshot(instance, smsg)
+                call LIBMUSCLE_Message_free(smsg)
+                call LIBMUSCLE_Data_free(sdata)
+            end if
+        end do
+
+        ! O_F
+        smsg = LIBMUSCLE_Message_create(t_cur, data)
+        call LIBMUSCLE_Instance_send(instance, 'final_state', smsg)
+        call LIBMUSCLE_Message_free(smsg)
+        ! ...
+
+        if (LIBMUSCLE_Instance_should_save_final_snapshot(instance)) then
+            sdata = LIBMUSCLE_Data_create() ! collect state
+            smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+            call LIBMUSCLE_Instance_save_final_snapshot(instance, smsg)
+            call LIBMUSCLE_Message_free(smsg)
+            call LIBMUSCLE_Data_free(sdata)
+        end if
+
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+end program reaction
+
diff --git a/docs/source/tutorial_code/checkpointing_reaction_partial.cpp b/docs/source/tutorial_code/checkpointing_reaction_partial.cpp
new file mode 100644
index 00000000..e55010de
--- /dev/null
+++ b/docs/source/tutorial_code/checkpointing_reaction_partial.cpp
@@ -0,0 +1,68 @@
+#include <cstdlib>
+#include <vector>
+
+#include <libmuscle/libmuscle.hpp>
+#include <ymmsl/ymmsl.hpp>
+
+
+using libmuscle::Data;
+using libmuscle::DataConstRef;
+using libmuscle::Instance;
+using libmuscle::InstanceFlags;
+using libmuscle::Message;
+using ymmsl::Operator;
+
+
+/** A simple exponential reaction model on a 1D grid.
+ */
+void reaction(int argc, char * argv[]) {
+    Instance instance(argc, argv, {
+            {Operator::F_INIT, {"initial_state"}},  // 1D Grid
+            {Operator::O_F, {"final_state"}}},      // 1D Grid
+            InstanceFlags::USES_CHECKPOINT_API);
+
+    while (instance.reuse_instance()) {
+
+        // F_INIT
+        double t_max = instance.get_setting_as<double>("t_max");
+        double dt = instance.get_setting_as<double>("dt");
+        double k = instance.get_setting_as<double>("k");
+
+        auto msg = instance.receive("initial_state");
+        auto data_ptr = msg.data().elements<double>();
+        std::vector<double> U(data_ptr, data_ptr + msg.data().size());
+
+        double t_cur = msg.timestamp();
+        double t_stop = msg.timestamp() + t_max;
+        while (t_cur + dt < t_stop) {
+            // O_I
+
+            // S
+            for (double & u : U)
+                u += k * u * dt;
+            t_cur += dt;
+
+            if (instance.should_save_snapshot(t_cur)) {
+                Message msg(t_cur,
+                        Data::list(Data::grid(U.data(), {U.size()}, {"x"}), t_stop));
+                instance.save_snapshot(msg);
+            }
+        }
+
+        // O_F
+        auto result = Data::grid(U.data(), {U.size()}, {"x"});
+        instance.send("final_state", Message(t_cur, result));
+
+        if (instance.should_save_final_snapshot()) {
+            Message msg(t_cur);
+            instance.save_final_snapshot(msg);
+        }
+    }
+}
+
+
+int main(int argc, char * argv[]) {
+    reaction(argc, argv);
+    return EXIT_SUCCESS;
+}
+
diff --git a/docs/source/tutorial_code/checkpointing_reaction_partial.f90 b/docs/source/tutorial_code/checkpointing_reaction_partial.f90
new file mode 100644
index 00000000..7bbae539
--- /dev/null
+++ b/docs/source/tutorial_code/checkpointing_reaction_partial.f90
@@ -0,0 +1,81 @@
+program reaction
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+
+    type(LIBMUSCLE_Message) :: rmsg
+    type(LIBMUSCLE_DataConstRef) :: rdata, item
+
+
+    type(LIBMUSCLE_Message) :: smsg
+    type(LIBMUSCLE_Data) :: sdata, sitem
+
+    real (selected_real_kind(15)) :: t_cur, t_max, dt, k
+    real (selected_real_kind(15)), dimension(:), allocatable :: U
+
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_F_INIT, 'initial_state')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_F, 'final_state')
+    instance = LIBMUSCLE_Instance_create(ports, &
+            LIBMUSCLE_InstanceFlags(USES_CHECKPOINT_API=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while (LIBMUSCLE_Instance_reuse_instance(instance))
+        ! F_INIT
+        t_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't_max')
+        dt = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dt')
+        k = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'k')
+
+        rmsg = LIBMUSCLE_Instance_receive(instance, 'initial_state')
+        rdata = LIBMUSCLE_Message_get_data(rmsg)
+        allocate (U(LIBMUSCLE_DataConstRef_size(rdata)))
+        call LIBMUSCLE_DataConstRef_elements(rdata, U)
+        call LIBMUSCLE_DataConstRef_free(rdata)
+
+        t_cur = LIBMUSCLE_Message_timestamp(rmsg)
+        t_max = LIBMUSCLE_Message_timestamp(rmsg) + t_max
+        call LIBMUSCLE_Message_free(rmsg)
+
+        do while (t_cur + dt < t_max)
+            ! O_I
+
+            ! S
+            U = k * U * dt
+            t_cur = t_cur + dt
+
+            if (LIBMUSCLE_Instance_should_save_snapshot(instance, t_cur)) then
+                sdata = LIBMUSCLE_Data_create_nils(2_LIBMUSCLE_size)
+                sitem = LIBMUSCLE_Data_create_grid(U, 'x')
+                call LIBMUSCLE_Data_set_item(sdata, 1_LIBMUSCLE_size, sitem)
+                call LIBMUSCLE_Data_set_item(sdata, 2_LIBMUSCLE_size, t_max)
+                smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+                call LIBMUSCLE_Instance_save_snapshot(instance, smsg)
+                call LIBMUSCLE_Message_free(smsg)
+                call LIBMUSCLE_Data_free(sitem)
+                call LIBMUSCLE_Data_free(sdata)
+            end if
+        end do
+
+        ! O_F
+        sdata = LIBMUSCLE_Data_create_grid(U, 'x')
+        smsg = LIBMUSCLE_Message_create(t_cur, sdata)
+        call LIBMUSCLE_Instance_send(instance, 'final_state', smsg)
+        call LIBMUSCLE_Message_free(smsg)
+        call LIBMUSCLE_Data_free(sdata)
+        deallocate (U)
+
+        if (LIBMUSCLE_Instance_should_save_final_snapshot(instance)) then
+            smsg = LIBMUSCLE_Message_create(t_cur)
+            call LIBMUSCLE_Instance_save_final_snapshot(instance, smsg)
+            call LIBMUSCLE_Message_free(smsg)
+        end if
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+end program reaction
+
diff --git a/libmuscle/cpp/build/libmuscle/libmuscle.version b/libmuscle/cpp/build/libmuscle/libmuscle.version
index b86357c8..48de674d 100644
--- a/libmuscle/cpp/build/libmuscle/libmuscle.version
+++ b/libmuscle/cpp/build/libmuscle/libmuscle.version
@@ -33,6 +33,13 @@
             libmuscle::impl::Instance::receive_with_settings*;
             libmuscle::impl::Instance::receive*;
             libmuscle::impl::Instance::send*;
+            libmuscle::impl::Instance::resuming*;
+            libmuscle::impl::Instance::load_snapshot*;
+            libmuscle::impl::Instance::should_init*;
+            libmuscle::impl::Instance::should_save_snapshot*;
+            libmuscle::impl::Instance::save_snapshot*;
+            libmuscle::impl::Instance::should_save_final_snapshot*;
+            libmuscle::impl::Instance::save_final_snapshot*;
 
             libmuscle::impl::Message::*;
 
@@ -354,6 +361,13 @@
         LIBMUSCLE_Instance_receive_with_settings_pd_;
         LIBMUSCLE_Instance_receive_with_settings_ps_;
         LIBMUSCLE_Instance_receive_with_settings_psd_;
+        LIBMUSCLE_Instance_resuming_;
+        LIBMUSCLE_Instance_should_init_;
+        LIBMUSCLE_Instance_load_snapshot_;
+        LIBMUSCLE_Instance_should_save_snapshot_;
+        LIBMUSCLE_Instance_save_snapshot_;
+        LIBMUSCLE_Instance_should_save_final_snapshot_;
+        LIBMUSCLE_Instance_save_final_snapshot_;
         LIBMUSCLE_InstanceFlags_to_int_;
         LIBMUSCLE_IMPL_BINDINGS_CmdLineArgs_create_;
         LIBMUSCLE_IMPL_BINDINGS_CmdLineArgs_free_;
diff --git a/libmuscle/cpp/build/libmuscle/libmuscle.version.in b/libmuscle/cpp/build/libmuscle/libmuscle.version.in
index f7a0c318..8eb49544 100644
--- a/libmuscle/cpp/build/libmuscle/libmuscle.version.in
+++ b/libmuscle/cpp/build/libmuscle/libmuscle.version.in
@@ -33,6 +33,13 @@
             libmuscle::impl::Instance::receive_with_settings*;
             libmuscle::impl::Instance::receive*;
             libmuscle::impl::Instance::send*;
+            libmuscle::impl::Instance::resuming*;
+            libmuscle::impl::Instance::load_snapshot*;
+            libmuscle::impl::Instance::should_init*;
+            libmuscle::impl::Instance::should_save_snapshot*;
+            libmuscle::impl::Instance::save_snapshot*;
+            libmuscle::impl::Instance::should_save_final_snapshot*;
+            libmuscle::impl::Instance::save_final_snapshot*;
 
             libmuscle::impl::Message::*;
 
diff --git a/libmuscle/cpp/build/libmuscle/libmuscle_mpi.version b/libmuscle/cpp/build/libmuscle/libmuscle_mpi.version
index b86357c8..48de674d 100644
--- a/libmuscle/cpp/build/libmuscle/libmuscle_mpi.version
+++ b/libmuscle/cpp/build/libmuscle/libmuscle_mpi.version
@@ -33,6 +33,13 @@
             libmuscle::impl::Instance::receive_with_settings*;
             libmuscle::impl::Instance::receive*;
             libmuscle::impl::Instance::send*;
+            libmuscle::impl::Instance::resuming*;
+            libmuscle::impl::Instance::load_snapshot*;
+            libmuscle::impl::Instance::should_init*;
+            libmuscle::impl::Instance::should_save_snapshot*;
+            libmuscle::impl::Instance::save_snapshot*;
+            libmuscle::impl::Instance::should_save_final_snapshot*;
+            libmuscle::impl::Instance::save_final_snapshot*;
 
             libmuscle::impl::Message::*;
 
@@ -354,6 +361,13 @@
         LIBMUSCLE_Instance_receive_with_settings_pd_;
         LIBMUSCLE_Instance_receive_with_settings_ps_;
         LIBMUSCLE_Instance_receive_with_settings_psd_;
+        LIBMUSCLE_Instance_resuming_;
+        LIBMUSCLE_Instance_should_init_;
+        LIBMUSCLE_Instance_load_snapshot_;
+        LIBMUSCLE_Instance_should_save_snapshot_;
+        LIBMUSCLE_Instance_save_snapshot_;
+        LIBMUSCLE_Instance_should_save_final_snapshot_;
+        LIBMUSCLE_Instance_save_final_snapshot_;
         LIBMUSCLE_InstanceFlags_to_int_;
         LIBMUSCLE_IMPL_BINDINGS_CmdLineArgs_create_;
         LIBMUSCLE_IMPL_BINDINGS_CmdLineArgs_free_;
diff --git a/libmuscle/cpp/src/libmuscle/api_guard.cpp b/libmuscle/cpp/src/libmuscle/api_guard.cpp
new file mode 100644
index 00000000..0c798ee0
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/api_guard.cpp
@@ -0,0 +1,178 @@
+#include <libmuscle/api_guard.hpp>
+
+#include <sstream>
+
+namespace libmuscle { namespace impl {
+
+APIGuard::APIGuard(bool uses_checkpointing, bool is_root)
+        : phase_(APIPhase::BEFORE_FIRST_REUSE_INSTANCE),
+          uses_checkpointing_(uses_checkpointing),
+          is_root_(is_root)
+{}
+
+void APIGuard::verify_reuse_instance() {
+    if (phase_ != APIPhase::BEFORE_REUSE_INSTANCE
+            && phase_ != APIPhase::BEFORE_FIRST_REUSE_INSTANCE) {
+        throw std::runtime_error(
+                "We reached the end of the reuse loop without checking"
+                " if a snapshot should be saved. Please add at least"
+                " a should_save_final_snapshot and save_final_snapshot.");
+    }
+}
+
+void APIGuard::reuse_instance_done(bool reusing) {
+    if (!reusing) {
+        phase_ = APIPhase::AFTER_REUSE_LOOP;
+    } else {
+        if (uses_checkpointing_) {
+            phase_ = APIPhase::BEFORE_RESUMING;
+        } else {
+            phase_ = APIPhase::BEFORE_REUSE_INSTANCE;
+        }
+    }
+}
+
+void APIGuard::verify_resuming() {
+    if (!uses_checkpointing_) {
+        throw std::runtime_error(
+                "Please add the flag"
+                " InstanceFlag::USES_CHECKPOINT_API to your"
+                " instance to use the MUSCLE3 checkpointing API.");
+    }
+    if (phase_ != APIPhase::BEFORE_RESUMING) {
+        throw std::runtime_error(
+                "Please call resuming() only as the first thing in the"
+                " reuse loop.");
+    }
+}
+
+void APIGuard::resuming_done(bool resuming) {
+    if (resuming && is_root_) {
+        phase_ = APIPhase::BEFORE_LOAD_SNAPSHOT;
+    } else {
+        phase_ = APIPhase::BEFORE_SHOULD_INIT;
+    }
+}
+
+void APIGuard::verify_load_snapshot() {
+    if (!is_root_)
+        throw std::runtime_error(
+                "load_snapshot may only be called from the root process");
+    if (phase_ != APIPhase::BEFORE_LOAD_SNAPSHOT) {
+        throw std::runtime_error(
+            "Please check that we are resuming by calling resuming()"
+            " before calling load_snapshot()");
+    }
+}
+
+void APIGuard::load_snapshot_done() {
+    phase_ = APIPhase::BEFORE_SHOULD_INIT;
+}
+
+void APIGuard::verify_should_init() {
+    if (phase_ != APIPhase::BEFORE_SHOULD_INIT) {
+        throw std::runtime_error(
+                "Please check whether to run f_init using should_init()"
+                " after resuming, and before trying to save a snapshot.");
+    }
+}
+
+void APIGuard::should_init_done() {
+    phase_ = APIPhase::BEFORE_SHOULD_SAVE_SNAPSHOT;
+}
+
+void APIGuard::verify_should_save_snapshot() {
+    if (phase_ != APIPhase::BEFORE_SHOULD_SAVE_SNAPSHOT) {
+        generic_error_messages_("should_save_snapshot");
+        throw std::runtime_error("Should be unreachable.");
+    }
+}
+
+void APIGuard::should_save_snapshot_done(bool should_save) {
+    if (should_save && is_root_) {
+        phase_ = APIPhase::BEFORE_SAVE_SNAPSHOT;
+    }
+}
+
+void APIGuard::verify_save_snapshot() {
+    if (!is_root_)
+        throw std::runtime_error(
+                "save_snapshot may only be called from the root process");
+    if (phase_ != APIPhase::BEFORE_SAVE_SNAPSHOT) {
+        generic_error_messages_("save_snapshot");
+        throw std::runtime_error("Should be unreachable.");
+    }
+}
+
+void APIGuard::save_snapshot_done() {
+    phase_ = APIPhase::BEFORE_SHOULD_SAVE_SNAPSHOT;
+}
+
+void APIGuard::verify_should_save_final_snapshot() {
+    if (phase_ != APIPhase::BEFORE_SHOULD_SAVE_SNAPSHOT) {
+        generic_error_messages_("should_save_final_snapshot");
+        throw std::runtime_error("Should be unreachable.");
+    }
+}
+
+void APIGuard::should_save_final_snapshot_done(bool should_save) {
+    if (should_save && is_root_) {
+        phase_ = APIPhase::BEFORE_SAVE_FINAL_SNAPSHOT;
+    } else {
+        phase_ = APIPhase::BEFORE_REUSE_INSTANCE;
+    }
+}
+
+void APIGuard::verify_save_final_snapshot() {
+    if (!is_root_)
+        throw std::runtime_error(
+                "save_final_snapshot may only be called from the root process");
+    if (phase_ != APIPhase::BEFORE_SAVE_FINAL_SNAPSHOT) {
+        generic_error_messages_("save_final_snapshot");
+        throw std::runtime_error("Should be unreachable.");
+    }
+}
+
+void APIGuard::save_final_snapshot_done() {
+    phase_ = APIPhase::BEFORE_REUSE_INSTANCE;
+}
+
+void APIGuard::generic_error_messages_(std::string const & verify_phase) {
+    std::ostringstream oss;
+    switch (phase_) {
+        case APIPhase::BEFORE_FIRST_REUSE_INSTANCE:
+        case APIPhase::AFTER_REUSE_LOOP:
+            oss << "Please only call " << verify_phase << " inside the reuse loop.";
+            break;
+        case APIPhase::BEFORE_REUSE_INSTANCE:
+            oss << "Please do not call " << verify_phase << " after";
+            oss << " should_save_final_snapshot. should_save_final_snapshot";
+            oss << " should be at the end of the reuse loop.";
+            break;
+        case APIPhase::BEFORE_RESUMING:
+            oss << "Inside the reuse loop you must call resuming first.";
+            break;
+        case APIPhase::BEFORE_LOAD_SNAPSHOT:
+            oss << "If resuming returns True, then you must call load_snapshot first.";
+            break;
+        case APIPhase::BEFORE_SHOULD_INIT:
+            oss << "After calling resuming, you must call should_init first.";
+            break;
+        case APIPhase::BEFORE_SHOULD_SAVE_SNAPSHOT:
+            oss << "You must call save_snapshot or save_final_snapshot first.";
+            break;
+        case APIPhase::BEFORE_SAVE_SNAPSHOT:
+            oss << "If should_save_snapshot returns True, then you must";
+            oss << " call save_snapshot first.";
+            break;
+        case APIPhase::BEFORE_SAVE_FINAL_SNAPSHOT:
+            oss << "If should_save_final_snapshot returns True, then you";
+            oss << " must call save_final_snapshot first.";
+            break;
+        default:
+            return;
+    }
+    throw std::runtime_error(oss.str());
+}
+
+} }
diff --git a/libmuscle/cpp/src/libmuscle/api_guard.hpp b/libmuscle/cpp/src/libmuscle/api_guard.hpp
new file mode 100644
index 00000000..099b9cdd
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/api_guard.hpp
@@ -0,0 +1,156 @@
+#pragma once
+
+#include <stdexcept>
+#include <string>
+
+namespace libmuscle { namespace impl {
+
+/** Different phases that the user code traverses.
+ *
+ * These values describe different regions that the model code can be
+ * in for the case where checkpointing is implemented. By tracking
+ * the phase that the model should be in, we can detect incorrect API
+ * usage.
+ *
+ * This does not match the yMMSL operators, as it is more
+ * fine-grained and concerns checkpointing, which is not represented
+ * in the SEL.
+ */
+enum class APIPhase {
+    /** Before the first time calling reuse_instance
+     */
+    BEFORE_FIRST_REUSE_INSTANCE,
+
+    /** Before calling reuse_instance
+     */
+    BEFORE_REUSE_INSTANCE,
+
+    /** Between reuse_instance and resuming
+     */
+    BEFORE_RESUMING,
+
+    /** Between resuming and load_snapshot
+     */
+    BEFORE_LOAD_SNAPSHOT,
+
+    /** After resuming, before should_init
+     */
+    BEFORE_SHOULD_INIT,
+
+    /** Between should_init and should_save*
+     */
+    BEFORE_SHOULD_SAVE_SNAPSHOT,
+
+    /** Between should_save_snapshot and save_snapshot
+     */
+    BEFORE_SAVE_SNAPSHOT,
+
+    /** Between should_save_final_snapshot and save_final_snapshot
+     */
+    BEFORE_SAVE_FINAL_SNAPSHOT,
+
+    /** After the final call to reuse_instance()
+     */
+    AFTER_REUSE_LOOP
+};
+
+/** Keeps track of and checks in which phase the model is.
+ *
+ * The verify_* functions are called when the corresponding function
+ * on Instance is called, to check that we're in the right phase. They
+ * raise a RuntimeError if there's a problem. The *_done functions are
+ * called to signal that the corresponding function finished
+ * successfully, and that we are moving on to the next phase.
+ */
+class APIGuard {
+    public:
+        /** Create an APIGuard
+         *
+         * This starts the tracker in the phase BEFORE_FIRST_REUSE_INSTANCE.
+         *
+         * @param uses_checkpointing Whether this instance wants to use checkpointing.
+         * @param is_root Whether this is the root process (relevant for MPI).
+         */
+        explicit APIGuard(bool uses_checkpointing, bool is_root);
+
+        /** Check reuse_instance()
+         */
+        void verify_reuse_instance();
+
+        /** Update phase on successful reuse_instance().
+         *
+         * @param reusing Whether we are reusing or not.
+         */
+        void reuse_instance_done(bool reusing);
+
+        /** Check resuming()
+         */
+        void verify_resuming();
+
+        /** Update phase on successful resuming().
+         *
+         * @param resuming Whether we're resuming or not.
+         */
+        void resuming_done(bool resuming);
+
+        /** Check load_snapshot()
+         */
+        void verify_load_snapshot();
+
+        /** Update phase on successful load_snapshot()
+         */
+        void load_snapshot_done();
+
+        /** Check should_init()
+         */
+        void verify_should_init();
+
+        /** Update phase on successful should_init()
+         */
+        void should_init_done();
+
+        /** Check should_save_snapshot()
+         */
+        void verify_should_save_snapshot();
+
+        /** Update phase on successful should_save_snapshot().
+         *
+         * @param should_save Whether we should save or not.
+         */
+        void should_save_snapshot_done(bool should_save);
+
+        /** Check save_snapshot()
+         */
+        void verify_save_snapshot();
+
+        /** Update phase on successful save_snapshot()
+         */
+        void save_snapshot_done();
+
+        /** Check should_save_final_snapshot().
+         */
+        void verify_should_save_final_snapshot();
+
+        /** Update phase on successful should_save_snapshot().
+         *
+         * @param should_save Whether we should save or not.
+         */
+        void should_save_final_snapshot_done(bool should_save);
+
+        /** Check should_save_final_snapshot()
+         */
+        void verify_save_final_snapshot();
+
+        /** Updates state on successful save_final_snapshot()
+         */
+        void save_final_snapshot_done();
+
+    private:
+        APIPhase phase_;
+        bool uses_checkpointing_;
+        bool is_root_;
+
+        void generic_error_messages_(std::string const & verify_phase);
+};
+
+} }
diff --git a/libmuscle/cpp/src/libmuscle/bindings/libmuscle_fortran_c.cpp b/libmuscle/cpp/src/libmuscle/bindings/libmuscle_fortran_c.cpp
index 34036c21..7b41a3d0 100644
--- a/libmuscle/cpp/src/libmuscle/bindings/libmuscle_fortran_c.cpp
+++ b/libmuscle/cpp/src/libmuscle/bindings/libmuscle_fortran_c.cpp
@@ -4701,6 +4701,50 @@ std::intptr_t LIBMUSCLE_Instance_receive_with_settings_psd_(std::intptr_t self,
     return 0;
 }
 
+bool LIBMUSCLE_Instance_resuming_(std::intptr_t self) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    bool result = self_p->resuming();
+    return result;
+}
+
+bool LIBMUSCLE_Instance_should_init_(std::intptr_t self) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    bool result = self_p->should_init();
+    return result;
+}
+
+std::intptr_t LIBMUSCLE_Instance_load_snapshot_(std::intptr_t self) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    Message * result = new Message(self_p->load_snapshot());
+    return reinterpret_cast<std::intptr_t>(result);
+}
+
+bool LIBMUSCLE_Instance_should_save_snapshot_(std::intptr_t self, double timestamp) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    bool result = self_p->should_save_snapshot(timestamp);
+    return result;
+}
+
+void LIBMUSCLE_Instance_save_snapshot_(std::intptr_t self, std::intptr_t message) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    Message * message_p = reinterpret_cast<Message *>(message);
+    self_p->save_snapshot(*message_p);
+    return;
+}
+
+bool LIBMUSCLE_Instance_should_save_final_snapshot_(std::intptr_t self) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    bool result = self_p->should_save_final_snapshot();
+    return result;
+}
+
+void LIBMUSCLE_Instance_save_final_snapshot_(std::intptr_t self, std::intptr_t message) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    Message * message_p = reinterpret_cast<Message *>(message);
+    self_p->save_final_snapshot(*message_p);
+    return;
+}
+
 std::intptr_t LIBMUSCLE_IMPL_BINDINGS_CmdLineArgs_create_(int count) {
     CmdLineArgs * result = new CmdLineArgs(count);
     return reinterpret_cast<std::intptr_t>(result);
diff --git a/libmuscle/cpp/src/libmuscle/bindings/libmuscle_mpi_fortran_c.cpp b/libmuscle/cpp/src/libmuscle/bindings/libmuscle_mpi_fortran_c.cpp
index b6170f3f..e6d38bf1 100644
--- a/libmuscle/cpp/src/libmuscle/bindings/libmuscle_mpi_fortran_c.cpp
+++ b/libmuscle/cpp/src/libmuscle/bindings/libmuscle_mpi_fortran_c.cpp
@@ -4705,6 +4705,50 @@ std::intptr_t LIBMUSCLE_Instance_receive_with_settings_psd_(std::intptr_t self,
     return 0;
 }
 
+bool LIBMUSCLE_Instance_resuming_(std::intptr_t self) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    bool result = self_p->resuming();
+    return result;
+}
+
+bool LIBMUSCLE_Instance_should_init_(std::intptr_t self) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    bool result = self_p->should_init();
+    return result;
+}
+
+std::intptr_t LIBMUSCLE_Instance_load_snapshot_(std::intptr_t self) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    Message * result = new Message(self_p->load_snapshot());
+    return reinterpret_cast<std::intptr_t>(result);
+}
+
+bool LIBMUSCLE_Instance_should_save_snapshot_(std::intptr_t self, double timestamp) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    bool result = self_p->should_save_snapshot(timestamp);
+    return result;
+}
+
+void LIBMUSCLE_Instance_save_snapshot_(std::intptr_t self, std::intptr_t message) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    Message * message_p = reinterpret_cast<Message *>(message);
+    self_p->save_snapshot(*message_p);
+    return;
+}
+
+bool LIBMUSCLE_Instance_should_save_final_snapshot_(std::intptr_t self) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    bool result = self_p->should_save_final_snapshot();
+    return result;
+}
+
+void LIBMUSCLE_Instance_save_final_snapshot_(std::intptr_t self, std::intptr_t message) {
+    Instance * self_p = reinterpret_cast<Instance *>(self);
+    Message * message_p = reinterpret_cast<Message *>(message);
+    self_p->save_final_snapshot(*message_p);
+    return;
+}
+
 std::intptr_t LIBMUSCLE_IMPL_BINDINGS_CmdLineArgs_create_(int count) {
     CmdLineArgs * result = new CmdLineArgs(count);
     return reinterpret_cast<std::intptr_t>(result);
diff --git a/libmuscle/cpp/src/libmuscle/checkpoint_triggers.cpp b/libmuscle/cpp/src/libmuscle/checkpoint_triggers.cpp
new file mode 100644
index 00000000..0c26e4e6
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/checkpoint_triggers.cpp
@@ -0,0 +1,262 @@
+#include <libmuscle/checkpoint_triggers.hpp>
+
+#include <algorithm>
+#include <cmath>
+
+namespace libmuscle { namespace impl {
+
+AtCheckpointTrigger::AtCheckpointTrigger(
+        std::vector<double> & at) : at_(at) {
+    std::sort(at_.begin(), at_.end());
+}
+
+Optional<double> AtCheckpointTrigger::next_checkpoint(double cur_time) {
+    if (cur_time >= at_.back())
+        return {};  // no future checkpoint left
+    auto iter = std::upper_bound(at_.begin(), at_.end(), cur_time);
+    return *iter;
+}
+
+Optional<double> AtCheckpointTrigger::previous_checkpoint(double cur_time) {
+    if (cur_time < at_.front())
+        return {};  // no future checkpoint left
+    auto iter = std::upper_bound(at_.begin(), at_.end(), cur_time);
+    return *std::prev(iter);
+}
+
+namespace {
+
+Optional<double> parse_optional_double(
+        DataConstRef const & map, std::string const & key) {
+    auto value = map[key];
+    if (value.is_nil())
+        return {};
+    return value.as<double>();
+}
+
+}
+
+RangeCheckpointTrigger::RangeCheckpointTrigger(
+        DataConstRef const & encoded_range_rule) {
+    start_ = parse_optional_double(encoded_range_rule, "start");
+    stop_ = parse_optional_double(encoded_range_rule, "stop");
+    auto every = parse_optional_double(encoded_range_rule, "every");
+    if (!every.is_set())
+        throw std::runtime_error("Received a nil value for every.");
+    every_ = every.get();
+
+    if (stop_.is_set()) {
+        double start = start_.is_set() ? start_.get() : 0;
+        double diff = stop_.get() - start;
+        last_ = start + std::floor(diff / every_) * every_;
+    } else {
+        last_ = {};
+    }
+}
+
+Optional<double> RangeCheckpointTrigger::next_checkpoint(double cur_time) {
+    if (start_.is_set() && cur_time < start_.get()) {
+        return start_;
+    }
+    if (last_.is_set() && cur_time >= last_.get()) {
+        return {};
+    }
+    double start = start_.is_set() ? start_.get() : 0;
+    double diff = cur_time - start;
+    return start + std::floor(diff / every_ + 1) * every_;
+}
+
+Optional<double> RangeCheckpointTrigger::previous_checkpoint(double cur_time) {
+    if (start_.is_set() && cur_time < start_.get()) {
+        return {};
+    }
+    if (last_.is_set() && cur_time >= last_.get()) {
+        return last_;
+    }
+    double start = start_.is_set() ? start_.get() : 0;
+    double diff = cur_time - start;
+    return start + std::floor(diff / every_) * every_;
+}
+
+CombinedCheckpointTriggers::CombinedCheckpointTriggers(
+        DataConstRef const & encoded_checkpoint_rules)
+        : triggers_() {
+    std::vector<double> at;
+    for (std::size_t i=0; i<encoded_checkpoint_rules.size(); ++i) {
+        auto const & rule = encoded_checkpoint_rules[i];
+        if (rule.size() == 1 && rule.key(0) == "at") {
+            auto const & at_list = rule["at"];
+            for (std::size_t j=0; j<at_list.size(); ++j) {
+                auto value = at_list[j];
+                if (value.is_a<double>())
+                    at.push_back(value.as<double>());
+                else
+                    at.push_back(value.as<int64_t>());
+            }
+        } else {
+            triggers_.push_back(
+                    std::make_unique<RangeCheckpointTrigger>(rule));
+        }
+    }
+    if (!at.empty()) {
+        triggers_.push_back(std::make_unique<AtCheckpointTrigger>(at));
+    }
+}
+
+Optional<double> CombinedCheckpointTriggers::next_checkpoint(double cur_time) {
+    Optional<double> retval;
+    for (auto & trigger : triggers_) {
+        auto checkpoint = trigger->next_checkpoint(cur_time);
+        if (checkpoint.is_set()) {
+            if (!retval.is_set() || retval.get() > checkpoint.get()) {
+                retval = checkpoint;
+            }
+        }
+    }
+    return retval;
+}
+
+Optional<double> CombinedCheckpointTriggers::previous_checkpoint(double cur_time) {
+    Optional<double> retval;
+    for (auto & trigger : triggers_) {
+        auto checkpoint = trigger->previous_checkpoint(cur_time);
+        if (checkpoint.is_set()) {
+            if (!retval.is_set() || retval.get() < checkpoint.get()) {
+                retval = checkpoint;
+            }
+        }
+    }
+    return retval;
+}
+
+bool CombinedCheckpointTriggers::has_rules() const {
+    return !triggers_.empty();
+}
+
+TriggerManager::TriggerManager()
+        : has_checkpoints_(false)
+        , last_triggers_()
+        , cpts_considered_until_(-std::numeric_limits<double>::infinity())
+        , simulation_epoch_()
+        , checkpoint_at_end_(false)
+        , wall_(Data::list())
+        , prevwall_(0)
+        , nextwall_(0)
+        , sim_(Data::list())
+        , prevsim_()
+        , nextsim_()
+{}
+
+void TriggerManager::set_checkpoint_info(
+        double elapsed, DataConstRef const & encoded_checkpoints) {
+    auto elapsed_as_duration =
+        std::chrono::duration_cast<std::chrono::steady_clock::duration>(
+            std::chrono::duration<double>(elapsed));
+    simulation_epoch_ = std::chrono::steady_clock::now() - elapsed_as_duration;
+
+    checkpoint_at_end_ = encoded_checkpoints["at_end"].as<bool>();
+
+    wall_ = CombinedCheckpointTriggers(
+            encoded_checkpoints["wallclock_time"]);
+    prevwall_ = 0.0;
+    nextwall_ = wall_.next_checkpoint(0.0);
+
+    sim_ = CombinedCheckpointTriggers(
+            encoded_checkpoints["simulation_time"]);
+    prevsim_ = {};
+    nextsim_ = {};
+
+    has_checkpoints_ = checkpoint_at_end_ || wall_.has_rules() || sim_.has_rules();
+}
+
+double TriggerManager::elapsed_walltime() {
+    auto duration = std::chrono::steady_clock::now() - simulation_epoch_;
+    return std::chrono::duration<double>(duration).count();
+}
+
+double TriggerManager::checkpoints_considered_until() {
+    return cpts_considered_until_;
+}
+
+void TriggerManager::harmonise_wall_time(double at_least) {
+    double cur = elapsed_walltime();
+    if (cur < at_least) {
+        auto duration = std::chrono::duration_cast<std::chrono::steady_clock::duration>(
+            std::chrono::duration<double>(at_least - cur));
+        simulation_epoch_ -= duration;
+    }
+}
+
+bool TriggerManager::should_save_snapshot(double timestamp) {
+    if (!has_checkpoints_) return false;
+
+    return should_save_(timestamp);
+}
+
+bool TriggerManager::should_save_final_snapshot(
+        bool do_reuse, Optional<double> f_init_max_timestamp) {
+    if (!has_checkpoints_) return false;
+
+    bool value = false;
+    if (!do_reuse) {
+        if (checkpoint_at_end_) {
+            value = true;
+            last_triggers_.push_back("at_end");
+        }
+    } else if (!f_init_max_timestamp.is_set()) {
+        //  No F_INIT messages received: reuse triggered on muscle_settings_in
+        // message.
+        // _logger.debug('Reuse triggered by muscle_settings_in.'
+        //               ' Not creating a snapshot.')
+    } else {
+        value = should_save_(f_init_max_timestamp.get());
+    }
+
+    return value;
+}
+
+void TriggerManager::update_checkpoints(double timestamp) {
+    prevwall_ = elapsed_walltime();
+    nextwall_ = wall_.next_checkpoint(prevwall_);
+
+    prevsim_ = timestamp;
+    nextsim_ = sim_.next_checkpoint(timestamp);
+}
+
+std::vector<std::string> TriggerManager::get_triggers() {
+    auto triggers = last_triggers_;
+    last_triggers_ = std::vector<std::string>();
+    return triggers;
+}
+
+bool TriggerManager::should_save_(double simulation_time) {
+    if (!nextsim_.is_set() && !prevsim_.is_set()) {
+        // we cannot make assumptions about the start time of a simulation,
+        // a t=-1000 could make sense if t represents years since CE
+        // and we should not disallow checkpointing for negative t
+        auto previous = sim_.previous_checkpoint(simulation_time);
+        if (previous.is_set()) {
+            // there is a checkpoint rule before the current moment, assume
+            // we should have taken a snapshot back then
+            nextsim_ = previous;
+        } else {
+            nextsim_ = sim_.next_checkpoint(simulation_time);
+        }
+    }
+
+    double walltime = elapsed_walltime();
+    cpts_considered_until_ = walltime;
+
+    last_triggers_.clear();
+    if (nextwall_.is_set() && walltime >= nextwall_.get()) {
+        last_triggers_.push_back(std::string("wallclock_time >= ") +
+                                 std::to_string(nextwall_.get()));
+    }
+    if (nextsim_.is_set() && simulation_time >= nextsim_.get()) {
+        last_triggers_.push_back(std::string("simulation_time >= ") +
+                                 std::to_string(nextsim_.get()));
+    }
+    return !last_triggers_.empty();
+}
+
+} }
diff --git a/libmuscle/cpp/src/libmuscle/checkpoint_triggers.hpp b/libmuscle/cpp/src/libmuscle/checkpoint_triggers.hpp
new file mode 100644
index 00000000..12c6225f
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/checkpoint_triggers.hpp
@@ -0,0 +1,169 @@
+#pragma once
+
+#include <libmuscle/data.hpp>
+#include <libmuscle/util.hpp>
+
+#include <chrono>
+#include <memory>
+#include <string>
+#include <vector>
+
+namespace libmuscle { namespace impl {
+
+/** Represents a trigger for creating snapshots
+ */
+class CheckpointTrigger {
+    public:
+        virtual ~CheckpointTrigger() = default;
+
+        /** Calculate the next checkpoint time
+         *
+         * @param cur_time The current time.
+         * @return Optional<double> The time when a next checkpoint should be taken, or
+         *      nil if this trigger has no checkpoint after cur_time.
+         */
+        virtual Optional<double> next_checkpoint(double cur_time) = 0;
+
+        /** Calculate the previous checkpoint time
+         *
+         * @param cur_time The current time.
+         * @return Optional<double> The time when a previous checkpoint should have been
+         *      taken, or nil if this trigger has no checkpoint after cur_time.
+         */
+        virtual Optional<double> previous_checkpoint(double cur_time) = 0;
+};
+
+/** Represents a trigger based on an "at" checkpoint rule
+ *
+ * This triggers at the specified times.
+ */
+class AtCheckpointTrigger : public CheckpointTrigger {
+    public:
+        /** Create an "at" checkpoint trigger
+         *
+         * @param at List of checkpoint moments
+         */
+        explicit AtCheckpointTrigger(std::vector<double> & at);
+
+        Optional<double> next_checkpoint(double cur_time) override;
+        Optional<double> previous_checkpoint(double cur_time) override;
+
+    private:
+        std::vector<double> at_;
+};
+
+/** Represents a trigger based on a "ranges" checkpoint rule
+ *
+ * This triggers at a range of checkpoint moments.
+ *
+ * Equivalent an "at" rule ``[start, start + step, start + 2*step, ...]`` for
+ * as long as ``start + i*step <= stop``.
+ *
+ * Stop may be omitted, in which case the range is infinite.
+ *
+ * Start may be omitted, in which case the range is equivalent to an "at" rule
+ * ``[..., -n*step, ..., -step, 0, step, 2*step, ...]`` for as long as
+ * ``i*step <= stop``.
+ */
+class RangeCheckpointTrigger : public CheckpointTrigger {
+    public:
+        /** Create a "range" checkpoint trigger
+         *
+         * @param encoded_range_rules YMMSL CheckpointRangeRule encoded in a
+         *      DataConstRef.
+         */
+        explicit RangeCheckpointTrigger(DataConstRef const & encoded_range_rule);
+
+        Optional<double> next_checkpoint(double cur_time) override;
+        Optional<double> previous_checkpoint(double cur_time) override;
+
+    private:
+        Optional<double> start_;
+        Optional<double> stop_;
+        double every_;
+        Optional<double> last_;
+};
+
+/** Checkpoint trigger based on a combination of "at" and "ranges"
+ */
+class CombinedCheckpointTriggers : public CheckpointTrigger {
+    public:
+        /** Create an "at" checkpoint trigger
+         *
+         * @param encoded_checkpoint_rules List of YMMSL CheckpointRule encoded in a
+         *      DataConstRef.
+         */
+        explicit CombinedCheckpointTriggers(DataConstRef const & encoded_checkpoint_rules);
+
+        Optional<double> next_checkpoint(double cur_time) override;
+        Optional<double> previous_checkpoint(double cur_time) override;
+
+        bool has_rules() const;
+    private:
+        std::vector<std::unique_ptr<CheckpointTrigger>> triggers_;
+};
+
+/** Manages all checkpoint triggers and checks if a snapshot must be saved.
+ */
+class TriggerManager {
+    public:
+        TriggerManager();
+
+        /** Register checkpoint info received from the muscle manager.
+         */
+        void set_checkpoint_info(
+                double elapsed, DataConstRef const & encoded_checkpoints);
+
+        /** Returns elapsed wallclock_time in seconds.
+         */
+        double elapsed_walltime();
+
+        /** Return elapsed time of last should_save*
+         */
+        double checkpoints_considered_until();
+
+        /** Returns whether checkpoints are defined
+         */
+        bool has_checkpoints() const { return has_checkpoints_; }
+
+        /** Ensure our elapsed time is at least the given value
+         */
+        void harmonise_wall_time(double at_least);
+
+        /** Handles instance.should_save_snapshot
+         */
+        bool should_save_snapshot(double timestamp);
+
+        /** Handles instance.should_save_final_snapshot
+         */
+        bool should_save_final_snapshot(
+                bool do_reuse, Optional<double> f_init_max_timestamp);
+
+        /** Update last and next checkpoint times when a snapshot is made.
+         *
+         * @param timestamp timestamp as reported by the instance (or from incoming
+         *      F_INIT messages for save_final_snapshot).
+         */
+        void update_checkpoints(double timestamp);
+
+        /** Get trigger description(s) for the current reason for checkpointing.
+         */
+        std::vector<std::string> get_triggers();
+
+    private:
+        bool has_checkpoints_;
+        std::vector<std::string> last_triggers_;
+        double cpts_considered_until_;
+        std::chrono::time_point<std::chrono::steady_clock> simulation_epoch_;
+        bool checkpoint_at_end_;
+        CombinedCheckpointTriggers wall_;
+        double prevwall_;
+        Optional<double> nextwall_;
+        CombinedCheckpointTriggers sim_;
+        Optional<double> prevsim_;
+        Optional<double> nextsim_;
+
+        bool should_save_(double simulation_time);
+};
+
+} }
diff --git a/libmuscle/cpp/src/libmuscle/communicator.cpp b/libmuscle/cpp/src/libmuscle/communicator.cpp
index 1e5f6ac7..5555ac7b 100644
--- a/libmuscle/cpp/src/libmuscle/communicator.cpp
+++ b/libmuscle/cpp/src/libmuscle/communicator.cpp
@@ -257,8 +257,6 @@ Message Communicator::receive_message(
     }
 
     int expected_message_number = port.get_num_messages(slot);
-    // TODO: handle f_init port counts for STATELESS and WEAKLY_STATEFUL
-    // components which didn't load a snapshot
     if (expected_message_number != mpp_message.message_number) {
         if (expected_message_number - 1 == mpp_message.message_number and
                 port.is_resuming(slot)) {
@@ -319,6 +317,39 @@ void Communicator::shutdown() {
         server->close();
 }
 
+Communicator::PortMessageCounts Communicator::get_message_counts() {
+    PortMessageCounts port_message_counts;
+    for(auto const & port_item : ports_)
+        port_message_counts[port_item.first] = port_item.second.get_message_counts();
+
+    assert(muscle_settings_in_.is_set());  // is always created by connect()
+    auto counts = muscle_settings_in_.get().get_message_counts();
+    port_message_counts["muscle_settings_in"] = counts;
+
+    return port_message_counts;
+}
+
+void Communicator::restore_message_counts(
+        Communicator::PortMessageCounts const & port_message_counts) {
+    for (auto const & item : port_message_counts) {
+        if (item.first == "muscle_settings_in") {
+            assert(muscle_settings_in_.is_set());  // is always created by connect()
+            muscle_settings_in_.get().restore_message_counts(item.second);
+        } else {
+            auto port_item = ports_.find(item.first);
+            if (port_item != ports_.end()) {
+                port_item->second.restore_message_counts(item.second);
+            } else {
+                throw std::runtime_error(
+                        "Unknown port " + item.first + " in snapshot."
+                        " Have your port definitions changed since"
+                        " the snapshot was taken?");
+            }
+        }
+    }
+}
+
+
 Reference Communicator::instance_id_() const {
     return kernel_ + index_;
 }
@@ -455,12 +486,14 @@ std::tuple<std::string, bool> Communicator::split_port_desc_(
     std::string port_name(port_desc);
     bool is_vector = false;
 
-    if (port_desc.rfind("[]") == (port_desc.size() - 2)) {
+    auto found = port_desc.rfind("[]");
+    if (found != std::string::npos && found == (port_desc.size() - 2)) {
         is_vector = true;
         port_name = port_desc.substr(0, port_desc.size() - 2);
     }
 
-    if (port_name.rfind("[]") == (port_name.size() - 2)) {
+    found = port_name.rfind("[]");
+    if (found != std::string::npos && found == (port_name.size() - 2)) {
         std::ostringstream oss;
         oss << "Port description '" << port_desc << "' is invalid: ports can";
         oss << " have at most one dimension.";
diff --git a/libmuscle/cpp/src/libmuscle/communicator.hpp b/libmuscle/cpp/src/libmuscle/communicator.hpp
index 2ae8e294..fd976fd5 100644
--- a/libmuscle/cpp/src/libmuscle/communicator.hpp
+++ b/libmuscle/cpp/src/libmuscle/communicator.hpp
@@ -33,6 +33,8 @@ namespace libmuscle { namespace impl {
  */
 class Communicator {
     public:
+        using PortMessageCounts = std::unordered_map<std::string, std::vector<int>>;
+
         /** Create a Communicator.
          *
          * The instance reference must start with one or more Identifiers,
@@ -170,6 +172,14 @@ class Communicator {
          */
         void shutdown();
 
+        /** Get message counts for all ports on the communicator.
+         */
+        PortMessageCounts get_message_counts();
+
+        /** Restore message counts on all ports.
+         */
+        void restore_message_counts(PortMessageCounts const & port_message_counts);
+
     private:
         using Ports_ = std::unordered_map<std::string, Port>;
 
diff --git a/libmuscle/cpp/src/libmuscle/instance.cpp b/libmuscle/cpp/src/libmuscle/instance.cpp
index 527713f1..7616ad27 100644
--- a/libmuscle/cpp/src/libmuscle/instance.cpp
+++ b/libmuscle/cpp/src/libmuscle/instance.cpp
@@ -1,5 +1,6 @@
 #include <libmuscle/instance.hpp>
 
+#include <libmuscle/api_guard.hpp>
 #include <libmuscle/communicator.hpp>
 #include <libmuscle/data.hpp>
 #include <libmuscle/mcp/data_pack.hpp>
@@ -9,6 +10,8 @@
 #include <libmuscle/profiler.hpp>
 #include <libmuscle/profiling.hpp>
 #include <libmuscle/settings_manager.hpp>
+#include <libmuscle/snapshot_manager.hpp>
+#include <libmuscle/checkpoint_triggers.hpp>
 
 #include <ymmsl/ymmsl.hpp>
 
@@ -96,11 +99,19 @@ class Instance::Impl {
                 Optional<int> slot,
                 Optional<Message> default_msg,
                 bool with_settings);
+        bool resuming();
+        bool should_init();
+        Message load_snapshot();
+        bool should_save_snapshot(double timestamp);
+        void save_snapshot(Message message);
+        bool should_save_final_snapshot();
+        void save_final_snapshot(Message message);
 
     private:
         ::ymmsl::Reference instance_name_;
         std::unique_ptr<MMPClient> manager_;
         std::unique_ptr<Logger> logger_;
+        std::unique_ptr<APIGuard> api_guard_;
         std::unique_ptr<Profiler> profiler_;
         std::unique_ptr<Communicator> communicator_;
 #ifdef MUSCLE_ENABLE_MPI
@@ -110,7 +121,12 @@ class Instance::Impl {
 #endif
         PortsDescription declared_ports_;
         SettingsManager settings_manager_;
-        bool first_run_;
+        std::unique_ptr<SnapshotManager> snapshot_manager_;
+        std::unique_ptr<TriggerManager> trigger_manager_;
+        Optional<bool> first_run_;
+        Optional<bool> do_reuse_;
+        bool do_resume_;
+        bool do_init_;
         std::unordered_map<::ymmsl::Reference, Message> f_init_cache_;
         bool is_shut_down_;
         InstanceFlags flags_;
@@ -118,6 +134,7 @@ class Instance::Impl {
         void register_();
         void connect_();
         void deregister_();
+        void setup_checkpointing_();
 
         ::ymmsl::Reference make_full_name_(int argc, char const * const argv[]) const;
         std::string extract_manager_location_(int argc, char const * const argv[]) const;
@@ -126,8 +143,15 @@ class Instance::Impl {
         std::vector<::ymmsl::Port> list_declared_ports_() const;
         void check_port_(std::string const & port_name);
         bool receive_settings_();
+        bool have_f_init_connections_();
+        bool pre_receive_();
         void pre_receive_(std::string const & port_name, Optional<int> slot);
         void pre_receive_f_init_();
+        Optional<double> f_init_max_timestamp_();
+        bool decide_reuse_instance_();
+        void save_snapshot_(
+                Optional<Message> message, bool final,
+                Optional<double> f_init_max_timestamp);
         void set_local_log_level_();
         void set_remote_log_level_();
         void apply_overlay_(Message const & message);
@@ -160,11 +184,22 @@ Instance::Impl::Impl(
 #endif
     , declared_ports_(ports)
     , settings_manager_()
-    , first_run_(true)
+    , first_run_()
+    , do_reuse_()
+    , do_resume_(false)
+    , do_init_(false)
     , f_init_cache_()
     , is_shut_down_(false)
     , flags_(flags)
 {
+    api_guard_.reset(new APIGuard(
+            !!(flags_ & InstanceFlags::USES_CHECKPOINT_API),
+#ifdef MUSCLE_ENABLE_MPI
+            mpi_barrier_.is_root()
+#else
+            true
+#endif
+            ));
 #ifdef MUSCLE_ENABLE_MPI
     MPI_Comm_dup(communicator, &mpi_comm_);
     if (mpi_barrier_.is_root()) {
@@ -180,8 +215,15 @@ Instance::Impl::Impl(
 
         communicator_.reset(
                 new Communicator(name_(), index_(), ports, *logger_, *profiler_));
+        snapshot_manager_.reset(new SnapshotManager(
+                instance_name_, *manager_, *communicator_, *logger_));
+        trigger_manager_.reset(new TriggerManager());
+
         register_();
         connect_();
+        // Note: setup_checkpointing_() needs to have the ports initialized
+        // so it comes after connect_()
+        setup_checkpointing_();
         set_local_log_level_();
         set_remote_log_level_();
 #ifdef MUSCLE_ENABLE_MPI
@@ -212,77 +254,41 @@ Instance::Impl::~Impl() {
 }
 
 bool Instance::Impl::reuse_instance() {
+    api_guard_->verify_reuse_instance();
+
     bool do_reuse;
+    if (do_reuse_.is_set()) {
+        // thank you, should_save_final_snapshot, for running this already
+        do_reuse = do_reuse_.get();
+        do_reuse_ = {};
+    } else {
+        do_reuse = decide_reuse_instance_();
+    }
+
+    // now first_run_, do_resume_ and do_init_ are also set correctly
 #ifdef MUSCLE_ENABLE_MPI
     if (mpi_barrier_.is_root()) {
 #endif
-        do_reuse = receive_settings_();
-
-        // TODO: f_init_cache_ should be empty here, or the user didn't receive
-        // something that was sent on the last go-around. At least emit a warning.
-        pre_receive_f_init_();
-
-        set_local_log_level_();
-        set_remote_log_level_();
-
-        auto ports = communicator_->list_ports();
-
-        bool f_init_not_connected = true;
-        if (ports.count(Operator::F_INIT) != 0)
-            for (auto const & port : ports.at(Operator::F_INIT))
-                if (communicator_->get_port(port).is_connected()) {
-                    f_init_not_connected = false;
-                    break;
-                }
-
-        bool no_settings_in = !communicator_->settings_in_connected();
-
-        if (f_init_not_connected && no_settings_in) {
-            do_reuse = first_run_;
-            first_run_ = false;
-        }
-        else {
-            for (auto const & ref_msg : f_init_cache_)
-                if (is_close_port(ref_msg.second.data()))
-                        do_reuse = false;
+        bool do_implicit_checkpoint = (
+                !first_run_.get() &&
+                !(InstanceFlags::USES_CHECKPOINT_API & flags_) &&
+                (!!(InstanceFlags::STATE_NOT_REQUIRED_FOR_NEXT_USE & flags_) or
+                 !!(InstanceFlags::KEEPS_NO_STATE_FOR_NEXT_USE & flags_)));
+
+        if (do_implicit_checkpoint) {
+            if (trigger_manager_->should_save_final_snapshot(
+                    do_reuse, f_init_max_timestamp_()))
+                save_snapshot_({}, true, f_init_max_timestamp_());
         }
 
 #ifdef MUSCLE_ENABLE_MPI
-        mpi_barrier_.signal();
-        int do_reuse_mpi = do_reuse;
-        MPI_Bcast(&do_reuse_mpi, 1, MPI_INT, mpi_root_, mpi_comm_);
-    }
-    else {
-        mpi_barrier_.wait();
-        int do_reuse_mpi;
-        MPI_Bcast(&do_reuse_mpi, 1, MPI_INT, mpi_root_, mpi_comm_);
-        do_reuse = do_reuse_mpi;
-    }
-#endif
-
-#ifdef MUSCLE_ENABLE_MPI
-    if (mpi_barrier_.is_root()) {
-        auto soverlay_data = Data(settings_manager_.overlay);
-        msgpack::sbuffer sbuf;
-        msgpack::pack(sbuf, soverlay_data);
-        int size = sbuf.size();
-        MPI_Bcast(&size, 1, MPI_INT, mpi_root_, mpi_comm_);
-        MPI_Bcast(sbuf.data(), size, MPI_CHAR, mpi_root_, mpi_comm_);
-    }
-    else {
-        int size;
-        MPI_Bcast(&size, 1, MPI_INT, mpi_root_, mpi_comm_);
-        std::vector<char> buf(size);
-        MPI_Bcast(&buf[0], size, MPI_CHAR, mpi_root_, mpi_comm_);
-        auto zone = std::make_shared<msgpack::zone>();
-        DataConstRef soverlay_data = mcp::unpack_data(zone, &buf[0], size);
-        settings_manager_.overlay = soverlay_data.as<Settings>();
     }
 #endif
 
     if (!do_reuse)
         shutdown_();
 
+    api_guard_->reuse_instance_done(do_reuse);
     return do_reuse;
 }
 
@@ -452,6 +458,38 @@ void Instance::Impl::deregister_() {
     logger_->info("Deregistered from the manager");
 }
 
+void Instance::Impl::setup_checkpointing_() {
+    auto checkpoint_info = manager_->get_checkpoint_info();
+
+    auto elapsed_time = std::get<0>(checkpoint_info);
+    auto checkpoints = std::get<1>(checkpoint_info);
+    trigger_manager_->set_checkpoint_info(elapsed_time, checkpoints);
+
+    auto checkpoint_support_mask = (
+            InstanceFlags::USES_CHECKPOINT_API |
+            InstanceFlags::KEEPS_NO_STATE_FOR_NEXT_USE |
+            InstanceFlags::STATE_NOT_REQUIRED_FOR_NEXT_USE);
+    if (trigger_manager_->has_checkpoints() && !(flags_ & checkpoint_support_mask)) {
+        std::string msg(
+                "The workflow has requested checkpoints, but this instance"
+                " does not support checkpointing. Please consult the"
+                " MUSCLE3 checkpointing documentation on how to add"
+                " checkpointing support.");
+        logger_->critical(msg);
+        shutdown_();
+        throw std::runtime_error(msg);
+    }
+
+    auto resume_snapshot = std::get<2>(checkpoint_info);
+    auto snapshot_dir = std::get<3>(checkpoint_info);
+    auto saved_at = snapshot_manager_->prepare_resume(resume_snapshot, snapshot_dir);
+    // resume settings overlay
+    settings_manager_.overlay = snapshot_manager_->resume_overlay();
+
+    if (saved_at.is_set())
+        trigger_manager_->update_checkpoints(saved_at.get());
+}
+
 Message Instance::Impl::receive_message(
                 std::string const & port_name,
                 Optional<int> slot,
@@ -536,6 +574,84 @@ Message Instance::Impl::receive_message(
     return result;
 }
 
+bool Instance::Impl::resuming() {
+    api_guard_->verify_resuming();
+    api_guard_->resuming_done(do_resume_);
+    return do_resume_;
+}
+
+bool Instance::Impl::should_init() {
+    api_guard_->verify_should_init();
+    api_guard_->should_init_done();
+    return do_init_;
+}
+
+Message Instance::Impl::load_snapshot() {
+    api_guard_->verify_load_snapshot();
+    auto result = snapshot_manager_->load_snapshot();
+    api_guard_->load_snapshot_done();
+    return result;
+}
+
+bool Instance::Impl::should_save_snapshot(double timestamp) {
+    api_guard_->verify_should_save_snapshot();
+    bool result;
+#ifdef MUSCLE_ENABLE_MPI
+    if (mpi_barrier_.is_root()) {
+#endif
+        result = trigger_manager_->should_save_snapshot(timestamp);
+#ifdef MUSCLE_ENABLE_MPI
+        mpi_barrier_.signal();
+        int result_mpi = result;
+        MPI_Bcast(&result_mpi, 1, MPI_INT, mpi_root_, mpi_comm_);
+    } else {
+        mpi_barrier_.wait();
+        int result_mpi;
+        MPI_Bcast(&result_mpi, 1, MPI_INT, mpi_root_, mpi_comm_);
+        result = result_mpi;
+    }
+#endif
+    api_guard_->should_save_snapshot_done(result);
+    return result;
+}
+
+void Instance::Impl::save_snapshot(Message message) {
+    api_guard_->verify_save_snapshot();  // API guard verifies we are mpi_root
+    save_snapshot_(message, false, {});
+    api_guard_->save_snapshot_done();
+}
+
+bool Instance::Impl::should_save_final_snapshot() {
+    api_guard_->verify_should_save_final_snapshot();
+
+    do_reuse_ = decide_reuse_instance_();
+    bool result;
+#ifdef MUSCLE_ENABLE_MPI
+    if (mpi_barrier_.is_root()) {
+#endif
+        result = trigger_manager_->should_save_final_snapshot(
+                do_reuse_.get(), f_init_max_timestamp_());
+#ifdef MUSCLE_ENABLE_MPI
+        mpi_barrier_.signal();
+        int result_mpi = result;
+        MPI_Bcast(&result_mpi, 1, MPI_INT, mpi_root_, mpi_comm_);
+    } else {
+        mpi_barrier_.wait();
+        int result_mpi;
+        MPI_Bcast(&result_mpi, 1, MPI_INT, mpi_root_, mpi_comm_);
+        result = result_mpi;
+    }
+#endif
+
+    api_guard_->should_save_final_snapshot_done(result);
+    return result;
+}
+
+void Instance::Impl::save_final_snapshot(Message message) {
+    api_guard_->verify_save_final_snapshot();  // API guard verifies we are mpi_root
+    save_snapshot_(message, true, f_init_max_timestamp_());
+    api_guard_->save_final_snapshot_done();
+}
 
 /* Returns instance name.
  *
@@ -668,6 +784,34 @@ bool Instance::Impl::receive_settings_() {
     return true;
 }
 
+/** Checks whether we have connected F_INIT ports.
+ *
+ * This includes muscle_settings_in, and any user-defined ports.
+ */
+bool Instance::Impl::have_f_init_connections_() {
+    auto ports = communicator_->list_ports();
+    if (ports.count(Operator::F_INIT) != 0)
+        for (auto const & port : ports.at(Operator::F_INIT))
+            if (communicator_->get_port(port).is_connected())
+                return true;
+    return communicator_->settings_in_connected();
+}
+
+/** Pre-receives on all ports.
+ *
+ * This includes muscle_settings_in and all user-defined ports.
+ *
+ * @return true iff no ClosePort messages were received.
+ */
+bool Instance::Impl::pre_receive_() {
+    bool all_ports_open = receive_settings_();
+    pre_receive_f_init_();
+    for (auto const & ref_msg : f_init_cache_)
+        if (is_close_port(ref_msg.second.data()))
+                all_ports_open = false;
+    return all_ports_open;
+}
+
 /* Pre-receive on the given port and slot, if any.
  */
 void Instance::Impl::pre_receive_(
@@ -713,6 +857,124 @@ void Instance::Impl::pre_receive_f_init_() {
     }
 }
 
+/** Return max timestamp of pre-received F_INIT messages
+ */
+Optional<double> Instance::Impl::f_init_max_timestamp_() {
+    Optional<double> result;
+    for (auto const & ref_msg : f_init_cache_) {
+        auto timestamp = ref_msg.second.timestamp();
+        if (!result.is_set() || result.get() < timestamp)
+            result = timestamp;
+    }
+    return result;
+}
+
+/** Decide whether and how to reuse the instance.
+ *
+ * This sets self._first_run, self._do_resume and self._do_init, and
+ * returns whether to reuse one more time. This is the real top of
+ * the reuse loop, and it gets called by reuse_instance and
+ * should_save_final_snapshot.
+ */
+bool Instance::Impl::decide_reuse_instance_() {
+    if (!first_run_.is_set())
+        first_run_ = true;
+    else
+        first_run_ = false;
+
+    bool do_reuse;
+
+#ifdef MUSCLE_ENABLE_MPI
+    if (mpi_barrier_.is_root()) {
+#endif
+        bool f_init_connected = have_f_init_connections_();
+        if (first_run_.get() && snapshot_manager_->resuming_from_intermediate()) {
+            // resume from intermediate
+            do_resume_ = true;
+            do_init_ = false;
+            do_reuse = true;
+        } else if (first_run_.get() && snapshot_manager_->resuming_from_final()) {
+            // resume from final
+            if (f_init_connected) {
+                bool got_f_init_messages = pre_receive_();
+                do_resume_ = true;
+                do_init_ = true;
+                do_reuse = got_f_init_messages;
+            } else {
+                do_resume_ = false;
+                do_init_ = false;
+                do_reuse = false;
+            }
+        } else {
+            // fresh start or resuming from implicit snapshot
+            do_resume_ = false;
+
+            if (!f_init_connected) {
+                // simple straight single run without resuming
+                do_init_ = first_run_.get();
+                do_reuse = first_run_.get();
+            } else {
+                // not resuming and f_init connected, run while we get messages
+                bool got_f_init_messages = pre_receive_();
+                do_init_ = got_f_init_messages;
+                do_reuse = got_f_init_messages;
+            }
+        }
+
+#ifdef MUSCLE_ENABLE_MPI
+        mpi_barrier_.signal();
+        int do_reuse_mpi[3] = {do_reuse, do_resume_, do_init_};
+        MPI_Bcast(do_reuse_mpi, 3, MPI_INT, mpi_root_, mpi_comm_);
+    } else {
+        mpi_barrier_.wait();
+        int do_reuse_mpi[3];
+        MPI_Bcast(do_reuse_mpi, 3, MPI_INT, mpi_root_, mpi_comm_);
+        do_reuse = do_reuse_mpi[0];
+        do_resume_ = do_reuse_mpi[1];
+        do_init_ = do_reuse_mpi[2];
+    }
+#endif
+
+#ifdef MUSCLE_ENABLE_MPI
+    if (mpi_barrier_.is_root()) {
+        auto soverlay_data = Data(settings_manager_.overlay);
+        msgpack::sbuffer sbuf;
+        msgpack::pack(sbuf, soverlay_data);
+        int size = sbuf.size();
+        MPI_Bcast(&size, 1, MPI_INT, mpi_root_, mpi_comm_);
+        MPI_Bcast(sbuf.data(), size, MPI_CHAR, mpi_root_, mpi_comm_);
+    }
+    else {
+        int size;
+        MPI_Bcast(&size, 1, MPI_INT, mpi_root_, mpi_comm_);
+        std::vector<char> buf(size);
+        MPI_Bcast(&buf[0], size, MPI_CHAR, mpi_root_, mpi_comm_);
+        auto zone = std::make_shared<msgpack::zone>();
+        DataConstRef soverlay_data = mcp::unpack_data(zone, &buf[0], size);
+        settings_manager_.overlay = soverlay_data.as<Settings>();
+    }
+#endif
+
+    return do_reuse;
+}
+
+/** Save a snapshot to disk and notify manager.
+ *
+ * @param message The data to save.
+ * @param final Whether this is a final snapshot or an intermediate one.
+ * @param f_init_max_timestamp Timestamp for final snapshots.
+ */
+void Instance::Impl::save_snapshot_(
+        Optional<Message> message, bool final,
+        Optional<double> f_init_max_timestamp) {
+    auto triggers = trigger_manager_->get_triggers();
+    auto walltime = trigger_manager_->elapsed_walltime();
+    auto timestamp = snapshot_manager_->save_snapshot(
+            message, final, triggers, walltime,
+            f_init_max_timestamp, settings_manager_.overlay);
+    trigger_manager_->update_checkpoints(timestamp);
+}
+
 /* Sets the level a log message must have to be printed locally.
  *
  * It gets this from the muscle_local_log_level setting.
@@ -1061,6 +1323,34 @@ Message Instance::receive_with_settings(
     return impl_()->receive_message(port_name, slot, default_msg, true);
 }
 
+bool Instance::resuming() {
+    return impl_()->resuming();
+}
+
+bool Instance::should_init() {
+    return impl_()->should_init();
+}
+
+Message Instance::load_snapshot() {
+    return impl_()->load_snapshot();
+}
+
+bool Instance::should_save_snapshot(double timestamp) {
+    return impl_()->should_save_snapshot(timestamp);
+}
+
+void Instance::save_snapshot(Message message) {
+    impl_()->save_snapshot(message);
+}
+
+bool Instance::should_save_final_snapshot() {
+    return impl_()->should_save_final_snapshot();
+}
+
+void Instance::save_final_snapshot(Message message) {
+    impl_()->save_final_snapshot(message);
+}
+
 Instance::Impl const * Instance::impl_() const {
     return pimpl_.get();
 }
diff --git a/libmuscle/cpp/src/libmuscle/instance.hpp b/libmuscle/cpp/src/libmuscle/instance.hpp
index 9b651b3d..95d885fc 100644
--- a/libmuscle/cpp/src/libmuscle/instance.hpp
+++ b/libmuscle/cpp/src/libmuscle/instance.hpp
@@ -629,6 +629,141 @@ class Instance {
                 std::string const & port_name, int slot,
                 Message const & default_msg);
 
+        /** Check if this instance is resuming from a snapshot.
+         *
+         * Must be used by submodels that implement the checkpointing API. You'll
+         * get a RuntimeError if you don't call this method in an iteration of the
+         * reuse loop.
+         *
+         * This method returns True for the first iteration of the reuse loop after
+         * resuming from a previously taken snapshot. When resuming from a
+         * snapshot, the submodel must load its state from the snapshot returned by
+         * Instance::load_snapshot.
+         *
+         * MPI-based components must call this function in all processes
+         * simultaneously.
+         *
+         * @return true iff the submodel must resume from a snapshot.
+         */
+        bool resuming();
+
+        /** Check if this instance should initialize.
+         *
+         * Must be used by submodels that implement the checkpointing API.
+         *
+         * When resuming from a previous snapshot, instances need not always
+         * execute the F_INIT phase of the submodel execution loop. Use this method
+         * before attempting to receive data on F_INIT ports.
+         *
+         * MPI-based components must call this function in all processes
+         * simultaneously.
+         *
+         * @return true if the submodel must execute the F_INIT step.
+         * @return false otherwise.
+         */
+        bool should_init();
+
+        /** Load a snapshot.
+         *
+         * Must only be called when Instance::resuming returns True.
+         *
+         * MPI-based components may only call this from the root process. An error
+         * is raised when attempting to call this method in any other process. It
+         * is therefore up to the model code to scatter or broadcast the snapshot
+         * state to the non-root processes, if necessary.
+         *
+         * @return Message containing the state as saved in a previous run
+         *      through Instance::save_snapshot or Instance::save_final_snapshot.
+         */
+        Message load_snapshot();
+
+        /** Check if a snapshot should be saved after the S Operator of the submodel.
+         *
+         * This method checks if a snapshot should be saved right now, based on the
+         * provided timestamp and elapsed wallclock time.
+         *
+         * If this method returns true, then the submodel must also save a snapshot
+         * through Instance::save_snapshot. A std::runtime_error will be generated if
+         * this is not done.
+         *
+         * See also Instance::should_save_final_snapshot for the variant that must be
+         * called at the end of the reuse loop.
+         *
+         * MPI-based components must call this function in all processes
+         * simultaneously.
+         *
+         * @param timestamp current timestamp of the submodel.
+         * @return true iff a snapshot should be taken by the submodel according to the
+         *      checkpoint rules provided in the ymmsl configuration.
+         */
+        bool should_save_snapshot(double timestamp);
+
+        /** Save a snapshot after the S Operator of the submodel.
+         *
+         * Before saving a snapshot, you should check using
+         * Instance::should_save_snapshot if a snapshot should be saved according to
+         * the checkpoint rules specified in the ymmsl configuration. You should
+         * use the same timestamp in the provided Message object as used to query
+         * Instance::should_save_snapshot.
+         *
+         * MPI-based components may only call this from the root process. An error
+         * is raised when attempting to call this method in any other process. It
+         * is therefore up to the model code to gather the necessary state from
+         * the non-root processes before saving the snapshot.
+         *
+         * @param message Message object that is saved as snapshot. The message
+         *      timestamp attribute should be the same as passed to
+         *      Instance::should_save_snapshot. The data attribute can be used to
+         *      store the internal state of the submodel.
+         */
+        void save_snapshot(Message message);
+
+        /** Check if a snapshot should be saved at the end of the reuse loop.
+         *
+         * This method checks if a snapshot should be saved at the end of the reuse
+         * loop. All your communication on O_F ports must be finished before calling
+         * this method, otherwise your simulation may deadlock.
+         *
+         * When this method returns true, the submodel must also save a snapshot
+         * through Instance::save_final_snapshot. A std::runtime_error will be
+         * generated if this is not done.
+         *
+         * See also Instance::should_save_snapshot for the variant that may be called
+         * inside of a time-integration loop of the submodel.
+         *
+         * MPI-based components must call this function in all processes
+         * simultaneously.
+         *
+         * \note
+         * This method will block until it can determine whether a final
+         * snapshot should be taken, because it must determine if this
+         * instance is reused.
+         *
+         * @return true iff a final snapshot should be taken by the submodel according
+         *      to the checkpoint rules provided in the ymmsl configuration.
+         */
+        bool should_save_final_snapshot();
+
+        /** Save a snapshot at the end of the reuse loop.
+         *
+         * Before saving a snapshot, you should check using
+         * Instance::should_save_final_snapshot if a snapshot should be saved
+         * according to the checkpoint rules specified in the ymmsl configuration.
+         *
+         * See also Instance::save_snapshot for the variant that may be called after
+         * each S Operator of the submodel.
+         *
+         * MPI-based components may only call this from the root process. An error
+         * is raised when attempting to call this method in any other process. It
+         * is therefore up to the model code to gather the necessary state from
+         * the non-root processes before saving the snapshot.
+         *
+         * @param message Message object that is saved as snapshot. The data
+         *      attribute can be used to store the internal state of the
+         *      submodel.
+         */
+        void save_final_snapshot(Message message);
+
     private:
         class Impl;
         std::unique_ptr<Impl> pimpl_;
diff --git a/libmuscle/cpp/src/libmuscle/mcp/protocol.hpp b/libmuscle/cpp/src/libmuscle/mcp/protocol.hpp
index 2693919c..15ac48d2 100644
--- a/libmuscle/cpp/src/libmuscle/mcp/protocol.hpp
+++ b/libmuscle/cpp/src/libmuscle/mcp/protocol.hpp
@@ -15,6 +15,8 @@ enum class RequestType {
     get_settings = 4,
     submit_log_message = 5,
     submit_profile_events = 6,
+    submit_snapshot = 7,
+    get_checkpoint_info = 8,
 
     // MUSCLE Peer Protocol
     get_next_message = 21
diff --git a/libmuscle/cpp/src/libmuscle/mmp_client.cpp b/libmuscle/cpp/src/libmuscle/mmp_client.cpp
index d468a21d..ef59f9a2 100644
--- a/libmuscle/cpp/src/libmuscle/mmp_client.cpp
+++ b/libmuscle/cpp/src/libmuscle/mmp_client.cpp
@@ -25,6 +25,7 @@ using libmuscle::impl::DataConstRef;
 using libmuscle::impl::mcp::unpack_data;
 using libmuscle::impl::Optional;
 using libmuscle::impl::ProfileEvent;
+using libmuscle::impl::SnapshotMetadata;
 using std::chrono::steady_clock;
 using ymmsl::Conduit;
 using ymmsl::Reference;
@@ -74,6 +75,14 @@ namespace {
         return encoded;
     }
 
+    template <typename T>
+    Data encode_vector(std::vector<T> const & value) {
+        auto retval = Data::nils(value.size());
+        for (std::size_t i = 0u; i < value.size(); ++i)
+            retval[i] = value[i];
+        return retval;
+    }
+
     Data encode_profile_event(ProfileEvent const & event) {
         if (!event.start_time.is_set() || !event.stop_time.is_set()) {
             throw std::runtime_error(
@@ -93,6 +102,31 @@ namespace {
                 encode_optional(event.slot), encode_optional(event.message_size),
                 encode_optional(event.message_timestamp));
     }
+
+    Data encode_snapshot_metadata(SnapshotMetadata const & snapshot_metadata) {
+        auto port_message_counts = Data::dict();
+        for(auto const & kv : snapshot_metadata.port_message_counts)
+            port_message_counts[kv.first] = encode_vector(kv.second);
+
+        auto metadata = Data::dict(
+                "triggers", encode_vector(snapshot_metadata.triggers),
+                "wallclock_time", snapshot_metadata.wallclock_time,
+                "timestamp", snapshot_metadata.timestamp,
+                "next_timestamp", encode_optional(snapshot_metadata.next_timestamp),
+                "port_message_counts", port_message_counts,
+                "is_final_snapshot", snapshot_metadata.is_final_snapshot,
+                "snapshot_filename", snapshot_metadata.snapshot_filename
+        );
+
+        return metadata;
+    }
+
+    template <typename T>
+    Optional<T> decode_optional(DataConstRef const & data) {
+        if (data.is_nil())
+            return {};
+        return data.as<T>();
+    }
 }
 
 namespace libmuscle { namespace impl {
@@ -133,14 +167,21 @@ void MMPClient::submit_profile_events(
     auto response = call_manager_(request);
 }
 
+void MMPClient::submit_snapshot_metadata(
+        SnapshotMetadata const & snapshot_metadata) {
+    auto request = Data::list(
+            static_cast<int>(RequestType::submit_snapshot),
+            static_cast<std::string>(instance_id_),
+            encode_snapshot_metadata(snapshot_metadata));
+
+    auto response = call_manager_(request);
+}
+
 void MMPClient::register_instance(
         std::vector<std::string> const & locations,
         std::vector<::ymmsl::Port> const & ports)
 {
-    auto encoded_locs = Data::nils(locations.size());
-    for (std::size_t i = 0u; i < locations.size(); ++i)
-        encoded_locs[i] = locations[i];
-
+    auto encoded_locs = encode_vector(locations);
     auto encoded_ports = Data::nils(ports.size());
     for (std::size_t i = 0u; i < ports.size(); ++i)
         encoded_ports[i] = encode_port(ports[i]);
@@ -171,6 +212,30 @@ ymmsl::Settings MMPClient::get_settings() {
     return settings;
 }
 
+auto MMPClient::get_checkpoint_info() ->
+        std::tuple<
+            double,
+            DataConstRef,
+            Optional<std::string>,
+            Optional<std::string>
+        >
+{
+    auto request = Data::list(
+            static_cast<int>(RequestType::get_checkpoint_info),
+            static_cast<std::string>(instance_id_));
+    auto response = call_manager_(request);
+
+    if (response[0].as<int>() != static_cast<int>(ResponseType::success)) {
+        throw std::runtime_error("Error getting checkpoint info from manager.");
+    }
+
+    return std::make_tuple(
+            response[1].as<double>(),
+            response[2],
+            decode_optional<std::string>(response[3]),
+            decode_optional<std::string>(response[4]));
+}
+
 auto MMPClient::request_peers() ->
         std::tuple<
             std::vector<::ymmsl::Conduit>,
diff --git a/libmuscle/cpp/src/libmuscle/mmp_client.hpp b/libmuscle/cpp/src/libmuscle/mmp_client.hpp
index 84aa776b..c4a33767 100644
--- a/libmuscle/cpp/src/libmuscle/mmp_client.hpp
+++ b/libmuscle/cpp/src/libmuscle/mmp_client.hpp
@@ -15,6 +15,8 @@
 #include <libmuscle/logging.hpp>
 #include <libmuscle/mcp/tcp_transport_client.hpp>
 #include <libmuscle/profiling.hpp>
+#include <libmuscle/snapshot.hpp>
+#include <libmuscle/util.hpp>
 #include <ymmsl/ymmsl.hpp>
 
 
@@ -56,12 +58,34 @@ class MMPClient {
          */
         void submit_profile_events(std::vector<ProfileEvent> const & events);
 
+        /** Send snapshot metadata to the manager.
+         *
+         * @param snapshot_metadata Snapshot metadata to supply to the manager.
+         */
+        void submit_snapshot_metadata(SnapshotMetadata const & snapshot_metadata);
+
         /** Get the global settings from the manager.
          *
          * @return A Settings object with the global settings.
          */
         ymmsl::Settings get_settings();
 
+        /** Get the checkpoint info from the manager.
+         *
+         * @return A tuple containing:
+         *      elapsed_time: current elapsed wallclock time
+         *      checkpoints: encoded checkpoint configuration
+         *      resume: optional path to the resume snapshot
+         *      snapshot_directory: optional path to store snapshots
+         */
+        auto get_checkpoint_info() ->
+            std::tuple<
+                double,
+                DataConstRef,
+                Optional<std::string>,
+                Optional<std::string>
+            >;
+
         /** Register a component instance with the manager.
          *
          * @param locations List of places where the instance can be reached.
@@ -78,7 +102,7 @@ class MMPClient {
          * peer_interval_min and peer_interval_max. From there on, intervals
          * are drawn randomly from that range.
          *
-         * @return A tuple containng a list of conduits that this instance is
+         * @return A tuple containing a list of conduits that this instance is
          *      attached to, a dictionary of peer dimensions, which is indexed
          *      by Reference to the peer kernel and specifies how many
          *      instances of the kernel there are, and a dictionary of peer
diff --git a/libmuscle/cpp/src/libmuscle/snapshot.cpp b/libmuscle/cpp/src/libmuscle/snapshot.cpp
new file mode 100644
index 00000000..77e19c52
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/snapshot.cpp
@@ -0,0 +1,168 @@
+#include <libmuscle/snapshot.hpp>
+
+#include <cmath>
+
+#include <msgpack.hpp>
+
+#include <libmuscle/mcp/data_pack.hpp>
+#include <libmuscle/mpp_message.hpp>
+
+
+namespace libmuscle { namespace impl {
+
+Snapshot::Snapshot(
+            std::vector<std::string> const & triggers,
+            double wallclock_time,
+            std::unordered_map<std::string, std::vector<int>> const & port_message_counts,
+            bool is_final_snapshot,
+            Optional<Message> const & message,
+            ::ymmsl::Settings const & settings_overlay
+            )
+        : triggers(triggers)
+        , wallclock_time(wallclock_time)
+        , port_message_counts(port_message_counts)
+        , is_final_snapshot(is_final_snapshot)
+        , message(message)
+        , settings_overlay(settings_overlay)
+    {}
+
+Snapshot Snapshot::from_bytes(DataConstRef const & data) {
+    // decode
+    auto zone = std::make_shared<msgpack::zone>();
+    DataConstRef dict = mcp::unpack_data(zone, data.as_byte_array(), data.size());
+
+    // convert lists/dicts to vectors/unordered_maps
+    std::vector<std::string> triggers;
+    auto data_triggers = dict["triggers"];
+    for (std::size_t i=0; i<data_triggers.size(); ++i) {
+        triggers.push_back(data_triggers[i].as<std::string>());
+    }
+
+    std::unordered_map<std::string, std::vector<int>> port_message_counts;
+    auto data_pmc = dict["port_message_counts"];
+    for (std::size_t i=0; i<data_pmc.size(); ++i) {
+        std::vector<int> counts;
+        for (std::size_t j=0; j<data_pmc.value(i).size(); ++j) {
+            counts.push_back(data_pmc.value(i)[j].as<int>());
+        }
+        port_message_counts[data_pmc.key(i)] = counts;
+    }
+
+    Optional<Message> message;
+    if (!dict["message"].is_nil()) {
+        auto mpp_message = MPPMessage::from_bytes(dict["message"]);
+        message = Message(mpp_message.timestamp, mpp_message.data);
+        if (mpp_message.next_timestamp.is_set()) {
+            message.get().set_next_timestamp(mpp_message.next_timestamp.get());
+        }
+        if (!mpp_message.settings_overlay.is_nil()) {
+            message.get().set_settings(
+                    mpp_message.settings_overlay.as<::ymmsl::Settings>());
+        }
+    }
+
+    return Snapshot(
+            triggers,
+            dict["wallclock_time"].as<double>(),
+            port_message_counts,
+            dict["is_final_snapshot"].as<bool>(),
+            message,
+            dict["settings_overlay"].as<::ymmsl::Settings>()
+            );
+}
+
+DataConstRef Snapshot::to_bytes() const {
+    Data d_triggers = Data::nils(triggers.size());
+    for (std::size_t i=0; i<triggers.size(); ++i) {
+        d_triggers[i] = triggers[i];
+    }
+
+    Data pmc = Data::dict();
+    for (const auto & kv : port_message_counts) {
+        Data counts = Data::nils(kv.second.size());
+        for (std::size_t i=0; i<kv.second.size(); ++i) {
+            counts[i] = kv.second[i];
+        }
+        pmc[kv.first] = counts;
+    }
+
+    Data dict;
+    // Note setting dict in two branches, to avoid a memcopy of the encoded MMPMessage
+    if (message.is_set()) {
+        auto msg = message.get();
+        MPPMessage mpp_msg(
+                "_",
+                "_",
+                {},
+                msg.timestamp(),
+                msg.has_next_timestamp() ? msg.next_timestamp() : Optional<double>(),
+                msg.has_settings() ? msg.settings() : Data(),
+                0,
+                -1.0,
+                msg.data());
+        // Initializing a Data::dict with a DataConstRef is allowed, but assignment
+        // after creation is not
+        dict = Data::dict(
+            "triggers", d_triggers,
+            "wallclock_time", wallclock_time,
+            "port_message_counts", pmc,
+            "is_final_snapshot", is_final_snapshot,
+            "message", mpp_msg.encoded(),
+            "settings_overlay", Data(settings_overlay));
+    } else {
+        dict = Data::dict(
+            "triggers", d_triggers,
+            "wallclock_time", wallclock_time,
+            "port_message_counts", pmc,
+            "is_final_snapshot", is_final_snapshot,
+            "message", Data(),
+            "settings_overlay", Data(settings_overlay));
+    }
+
+    msgpack::sbuffer sbuf;
+    msgpack::pack(sbuf, dict);
+
+    auto bytes = Data::byte_array(sbuf.size());
+    memcpy(bytes.as_byte_array(), sbuf.data(), sbuf.size());
+
+    return bytes;
+}
+
+SnapshotMetadata::SnapshotMetadata(
+            std::vector<std::string> const & triggers,
+            double wallclock_time,
+            double timestamp,
+            Optional<double> next_timestamp,
+            std::unordered_map<std::string, std::vector<int>> const & port_message_counts,
+            bool is_final_snapshot,
+            std::string const & snapshot_filename)
+        : triggers(triggers)
+        , wallclock_time(wallclock_time)
+        , timestamp(timestamp)
+        , next_timestamp(next_timestamp)
+        , port_message_counts(port_message_counts)
+        , is_final_snapshot(is_final_snapshot)
+        , snapshot_filename(snapshot_filename)
+    {}
+
+SnapshotMetadata SnapshotMetadata::from_snapshot(
+        Snapshot const & snapshot, std::string const & snapshot_filename) {
+    double timestamp = std::numeric_limits<double>::quiet_NaN();
+    Optional<double> next_timestamp;
+    if (snapshot.message.is_set()) {
+        timestamp = snapshot.message.get().timestamp();
+        if (snapshot.message.get().has_next_timestamp()) {
+            next_timestamp = snapshot.message.get().next_timestamp();
+        }
+    }
+    return SnapshotMetadata(
+            snapshot.triggers,
+            snapshot.wallclock_time,
+            timestamp,
+            next_timestamp,
+            snapshot.port_message_counts,
+            snapshot.is_final_snapshot,
+            snapshot_filename);
+}
+
+} }
diff --git a/libmuscle/cpp/src/libmuscle/snapshot.hpp b/libmuscle/cpp/src/libmuscle/snapshot.hpp
new file mode 100644
index 00000000..d78fde59
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/snapshot.hpp
@@ -0,0 +1,80 @@
+#pragma once
+
+#include <unordered_map>
+#include <string>
+#include <vector>
+
+#include <libmuscle/data.hpp>
+#include <libmuscle/message.hpp>
+#include <libmuscle/util.hpp>
+
+#include <ymmsl/ymmsl.hpp>
+
+namespace libmuscle { namespace impl {
+
+/** Snapshot data structure.
+ */
+class Snapshot {
+    public:
+        enum class VersionByte : char {
+            MESSAGEPACK = '1'
+        };
+
+        Snapshot(
+                std::vector<std::string> const & triggers,
+                double wallclock_time,
+                std::unordered_map<std::string, std::vector<int>> const & port_message_counts,
+                bool is_final_snapshot,
+                Optional<Message> const & message,
+                ::ymmsl::Settings const & settings_overlay);
+
+        /** Create a snapshot object from binary data.
+         *
+         * @param data binary data representing the snapshot. Note that this must
+         *      exclude the versioning byte.
+         */
+        static Snapshot from_bytes(DataConstRef const & data);
+
+        /** Convert the snapshot object to binary data.
+         *
+         * @return DataConstRef Binary data representing the snapshot. Note that this
+         *      excludes the versioning byte.
+         */
+        DataConstRef to_bytes() const;
+
+        std::vector<std::string> triggers;
+        double wallclock_time;
+        std::unordered_map<std::string, std::vector<int>> port_message_counts;
+        bool is_final_snapshot;
+        Optional<Message> message;
+        ::ymmsl::Settings settings_overlay;
+};
+
+/** Metadata of a snapshot for sending to the muscle_manager.
+ */
+class SnapshotMetadata {
+    public:
+        SnapshotMetadata(
+                std::vector<std::string> const & triggers,
+                double wallclock_time,
+                double timestamp,
+                Optional<double> next_timestamp,
+                std::unordered_map<std::string, std::vector<int>> const & port_message_counts,
+                bool is_final_snapshot,
+                std::string const & snapshot_filename);
+
+        /** Create snapshot metadata from the given snapshot and filename
+         */
+        static SnapshotMetadata from_snapshot(
+                Snapshot const & snapshot, std::string const & snapshot_filename);
+
+        std::vector<std::string> triggers;
+        double wallclock_time;
+        double timestamp;
+        Optional<double> next_timestamp;
+        std::unordered_map<std::string, std::vector<int>> port_message_counts;
+        bool is_final_snapshot;
+        std::string snapshot_filename;
+};
+
+} }
diff --git a/libmuscle/cpp/src/libmuscle/snapshot_manager.cpp b/libmuscle/cpp/src/libmuscle/snapshot_manager.cpp
new file mode 100644
index 00000000..f403e4ea
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/snapshot_manager.cpp
@@ -0,0 +1,255 @@
+#include <libmuscle/snapshot_manager.hpp>
+
+#include <libmuscle/data.hpp>
+
+#include <algorithm>
+#include <cmath>
+#include <fstream>
+#include <sstream>
+
+// Note: using POSIX for filesystem calls
+// Could be upgraded to std::filesystem when targeting C++17 or later
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <string.h>
+#include <unistd.h>
+
+#define MAX_FILE_EXISTS_CHECK 10000
+
+using namespace std::string_literals;
+
+namespace libmuscle { namespace impl {
+
+SnapshotManager::SnapshotManager(
+            ymmsl::Reference const & instance_id,
+            MMPClient & manager,
+            Communicator & communicator,
+            Logger & logger)
+        : instance_id_(instance_id)
+        , manager_(manager)
+        , communicator_(communicator)
+        , logger_(logger)
+        , resume_from_snapshot_()
+        , resume_overlay_()
+        , next_snapshot_num_(1)
+        , snapshot_directory_()
+{
+    // replace identifier[i] by identifier-i to use in snapshot file name
+    // using a dash (-) because that is not allowed in Identifiers
+    safe_id_ = static_cast<std::string>(instance_id_);
+    std::replace(safe_id_.begin(), safe_id_.end(), '[', '-');
+    safe_id_.erase(
+            std::remove(safe_id_.begin(), safe_id_.end(), ']'),
+            safe_id_.end());
+}
+
+namespace {
+
+std::string string_error(int err_num) {
+    char err_buf[1024];
+#ifdef _GNU_SOURCE
+    // g++ defines this by default, which uses the gnu extension for strerror_r
+    return strerror_r(err_num, err_buf, 1024);
+#else  // _GNU_SOURCE
+    // POSIX complient strerror_r
+    if (strerror_r(err_num, err_buf, 1024) == 0)
+        return err_buf;
+    return std::to_string(err_num);
+#endif  // _GNU_SOURCE
+}
+
+// Adapted from https://stackoverflow.com/a/2203853
+std::string get_working_path()
+{
+    char temp [ PATH_MAX ];
+
+    if ( getcwd(temp, PATH_MAX) != 0)
+        return std::string ( temp );
+
+    throw std::runtime_error(
+            "Error retrieving current working directory: " + string_error(errno));
+}
+
+}
+
+Optional<double> SnapshotManager::prepare_resume(
+        Optional<std::string> const & resume_snapshot,
+        Optional<std::string> const & snapshot_directory) {
+    Optional<double> result;
+
+    if (snapshot_directory.is_set()) {
+        snapshot_directory_ = snapshot_directory.get();
+    } else {
+        // store snapshots in current working directory
+        snapshot_directory_ = get_working_path();
+    }
+
+    if (resume_snapshot.is_set()) {
+        auto snapshot = load_snapshot_from_file(resume_snapshot.get());
+
+        if (snapshot.message.is_set()) {
+            // snapshot.message is not set for implicit snapshots
+            resume_from_snapshot_ = snapshot;
+            result = snapshot.message.get().timestamp();
+        }
+        resume_overlay_ = snapshot.settings_overlay;
+
+        communicator_.restore_message_counts(snapshot.port_message_counts);
+        // Store a copy of the snapshot in the current run directory
+        auto path = store_snapshot_(snapshot);
+        auto metadata = SnapshotMetadata::from_snapshot(snapshot, path);
+        manager_.submit_snapshot_metadata(metadata);
+    }
+
+    return result;
+}
+
+bool SnapshotManager::resuming_from_intermediate() {
+    return (resume_from_snapshot_.is_set() &&
+            !resume_from_snapshot_.get().is_final_snapshot);
+}
+
+bool SnapshotManager::resuming_from_final() {
+    return (resume_from_snapshot_.is_set() &&
+            resume_from_snapshot_.get().is_final_snapshot);
+}
+
+Message SnapshotManager::load_snapshot() {
+    if (!resume_from_snapshot_.is_set())
+        throw std::runtime_error("Error: no snapshot to load.");
+    return resume_from_snapshot_.get().message.get();
+}
+
+double SnapshotManager::save_snapshot(
+        Optional<Message> message, bool is_final,
+        std::vector<std::string> const & triggers, double wallclock_time,
+        Optional<double> f_init_max_timestamp,
+        ::ymmsl::Settings settings_overlay) {
+    auto port_message_counts = communicator_.get_message_counts();
+
+    if (is_final) {
+        // Decrease F_INIT port counts by one: F_INIT messages are already
+        // pre-received, but not yet processed by the user code. Therefore,
+        // the snapshot state should treat these as not-received.
+        auto all_ports = communicator_.list_ports();
+        auto ports = all_ports.find(::ymmsl::Operator::F_INIT);
+        if (ports != all_ports.end()) {
+            for (auto const & port_name : ports->second) {
+                for (auto & count : port_message_counts[port_name]) {
+                    --count;
+                }
+            }
+        }
+        if (communicator_.settings_in_connected()) {
+            for (auto & count : port_message_counts["muscle_settings_in"]) {
+                --count;
+            }
+        }
+    }
+
+    Snapshot snapshot(
+            triggers, wallclock_time, port_message_counts, is_final, message,
+            settings_overlay);
+
+    auto path = store_snapshot_(snapshot);
+    auto metadata = SnapshotMetadata::from_snapshot(snapshot, path);
+    manager_.submit_snapshot_metadata(metadata);
+
+    double timestamp = message.is_set() ? message.get().timestamp() : -INFINITY;
+    if (is_final && f_init_max_timestamp.is_set()) {
+        // For final snapshots f_init_max_snapshot is the reference time (see
+        // should_save_final_snapshot).
+        timestamp = f_init_max_timestamp.get();
+    }
+    return timestamp;
+}
+
+Snapshot SnapshotManager::load_snapshot_from_file(
+        std::string const & snapshot_location) {
+    logger_.debug("Loading snapshot from " + snapshot_location);
+
+    std::ifstream snapshot_file(snapshot_location, std::ios::binary);
+    if (!snapshot_file.good()) {
+        throw std::runtime_error(
+                "Unable to load snapshot: " + snapshot_location +
+                " cannot be opened. Please ensure this path exists and can be read.");
+    }
+
+    // get the size of the file
+    snapshot_file.seekg(0, std::ios::end);
+    std::size_t fsize = snapshot_file.tellg();
+    snapshot_file.seekg(0, std::ios::beg);
+
+    if (fsize < 1) {
+        throw std::runtime_error(
+                "Unable to load snapshot: " + snapshot_location + " is an empty file.");
+    }
+
+    char version;
+    Data data = Data::byte_array(fsize - 1);
+    snapshot_file.read(&version, 1);
+    snapshot_file.read(data.as_byte_array(), fsize - 1);
+    if (!snapshot_file.good()) {
+        throw std::runtime_error(
+                "Unable to load snapshot file " + snapshot_location +
+                ". I/O error while reading file.");
+    }
+
+    switch (static_cast<Snapshot::VersionByte>(version)) {
+        case Snapshot::VersionByte::MESSAGEPACK:
+            return Snapshot::from_bytes(data);
+        default:
+            throw std::runtime_error(
+                    "Unable to load snapshot file " + snapshot_location +
+                    ": unknown version of snapshot file. Was the file saved with a"
+                    " different version of libmuscle or edited?");
+    }
+}
+
+std::string SnapshotManager::store_snapshot_(Snapshot const & snapshot) {
+    logger_.debug("Saving snapshot to " + snapshot_directory_);
+    std::ofstream snapshot_file;
+    std::string fpath;
+    for (int i=0; i<MAX_FILE_EXISTS_CHECK; ++i) {
+        // Expectation is that muscle_snapshot_directory is empty initially
+        // and we succeed in the first loop. Still wrapping in a for-loop
+        // such that an existing filename doesn't immediately raise an error
+        std::ostringstream fname;
+        fname << snapshot_directory_ << "/";
+        fname << safe_id_ << "_" << next_snapshot_num_ << ".pack";
+        fpath = fname.str();
+        ++next_snapshot_num_;
+
+        // TODO: use std::ios::noreplace (from C++23) in fstream.open instead of this
+        int fd = ::open(fpath.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0777);
+        if (fd < 0) {
+            if (errno == EEXIST)
+                continue;  // file already exists: retry next available
+            throw std::runtime_error(string_error(errno));
+        }
+        ::close(fd);
+
+        snapshot_file.open(fpath, std::ios::binary);
+        if (snapshot_file.good()) {
+            break;
+        } else {
+            snapshot_file.close();
+        }
+    }
+    if (!snapshot_file.good()) {
+        throw std::runtime_error(
+                "Could not find an available filename for storing the next"
+                " snapshot: " + fpath + " cannot be opened for writing.");
+    }
+    snapshot_file << static_cast<char>(Snapshot::VersionByte::MESSAGEPACK);
+    auto data = snapshot.to_bytes();
+    snapshot_file.write(data.as_byte_array(), data.size());
+    snapshot_file.close();
+    if (!snapshot_file.good()) {
+        throw std::runtime_error("I/O error while writing snapshot to file " + fpath);
+    }
+    return fpath;
+}
+
+} }
diff --git a/libmuscle/cpp/src/libmuscle/snapshot_manager.hpp b/libmuscle/cpp/src/libmuscle/snapshot_manager.hpp
new file mode 100644
index 00000000..01282072
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/snapshot_manager.hpp
@@ -0,0 +1,114 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include <libmuscle/communicator.hpp>
+#include <libmuscle/logger.hpp>
+#include <libmuscle/mmp_client.hpp>
+#include <libmuscle/snapshot.hpp>
+#include <libmuscle/util.hpp>
+
+#include <ymmsl/ymmsl.hpp>
+
+namespace libmuscle { namespace impl {
+
+/** Manages information on snapshots for the Instance
+ *
+ * Implements the saving and loading of snapshots in the checkpointing API.
+ */
+class SnapshotManager {
+    public:
+        /** Create a new snapshot manager
+         *
+         * @param instance_id The id of this instance.
+         * @param manager The client used to submit data to the manager.
+         * @param commnicator The communicator belonging to this instance.
+         */
+        SnapshotManager(
+                ymmsl::Reference const & instance_id,
+                MMPClient & manager,
+                Communicator & communicator,
+                Logger & logger);
+
+        /** Apply checkpoint info received from the manager.
+         *
+         * If there is a snapshot to resume from, this loads it and does
+         * any resume work that libmuscle should do, including restoring
+         * message counts and storing the resumed-from snapshot again as
+         * our first snapshot.
+         *
+         * @param resume_snapshot Snapshot to resume from (or None if not resuming)
+         * @param snapshot_directory directory to save snapshots in
+         * @return Optional<double> Time at which the initial snapshot was saved,
+         *      if resuming.
+         */
+        Optional<double> prepare_resume(
+                Optional<std::string> const & resume_snapshot,
+                Optional<std::string> const & snapshot_directory);
+
+        /** Get the settings overlay to be used when resuming
+         */
+        ::ymmsl::Settings resume_overlay() const { return resume_overlay_; }
+
+        /** Check whether we have an intermediate snapshot.
+         *
+         * Doesn't say whether we should resume now, just that we were
+         * given an intermediate snapshot to resume from by the manager.
+         */
+        bool resuming_from_intermediate();
+
+        /** Check whether we have a final snapshot.
+         *
+         * Doesn't say whether we should resume now, just that we were
+         * given an intermediate snapshot to resume from by the manager.
+         */
+        bool resuming_from_final();
+
+        /** Get the Message to resume from.
+         */
+        Message load_snapshot();
+
+        /** Save a (final) snapshot.
+         *
+         * @param message Message object representing the snapshot.
+         * @param final True iff called from save_final_snapshot.
+         * @param triggers Description of checkpoints that triggered this.
+         * @param wallclock_time Wallclock time when saving.
+         * @param f_init_max_timestamp Timestamp for final snapshots.
+         * @param settings_overlay Current settings overlay.
+         * @return double Simulation time at which the snapshot was made.
+         */
+        double save_snapshot(
+                Optional<Message> message, bool is_final,
+                std::vector<std::string> const & triggers, double wallclock_time,
+                Optional<double> f_init_max_timestamp,
+                ::ymmsl::Settings settings_overlay);
+
+        /** Load a previously stored snapshot from the filesystem.
+         *
+         * @param snapshot_location path where the snapshot is stored.
+         * @return Snapshot
+         */
+        Snapshot load_snapshot_from_file(std::string const & snapshot_location);
+
+    private:
+        ymmsl::Reference const & instance_id_;
+        MMPClient & manager_;
+        Communicator & communicator_;
+        Logger & logger_;
+        Optional<Snapshot> resume_from_snapshot_;
+        ::ymmsl::Settings resume_overlay_;
+        int next_snapshot_num_;
+        std::string snapshot_directory_;
+        std::string safe_id_;
+
+        /** Store a snapshot on the filesystem.
+         *
+         * @param snapshot Snapshot to store.
+         * @return std::string Path where the snapshot is stored.
+         */
+        std::string store_snapshot_(Snapshot const & snapshot);
+};
+
+} }
diff --git a/libmuscle/fortran/src/libmuscle/libmuscle.f90 b/libmuscle/fortran/src/libmuscle/libmuscle.f90
index 433e816a..f1799728 100644
--- a/libmuscle/fortran/src/libmuscle/libmuscle.f90
+++ b/libmuscle/fortran/src/libmuscle/libmuscle.f90
@@ -465,6 +465,13 @@ module libmuscle
     public :: LIBMUSCLE_Instance_receive_with_settings_ps
     public :: LIBMUSCLE_Instance_receive_with_settings_psd
     public :: LIBMUSCLE_Instance_receive_with_settings_on_slot
+    public :: LIBMUSCLE_Instance_resuming
+    public :: LIBMUSCLE_Instance_should_init
+    public :: LIBMUSCLE_Instance_load_snapshot
+    public :: LIBMUSCLE_Instance_should_save_snapshot
+    public :: LIBMUSCLE_Instance_save_snapshot
+    public :: LIBMUSCLE_Instance_should_save_final_snapshot
+    public :: LIBMUSCLE_Instance_save_final_snapshot
     public :: LIBMUSCLE_InstanceFlags
     type LIBMUSCLE_InstanceFlags
         logical :: DONT_APPLY_OVERLAY = .false.
@@ -3484,6 +3491,64 @@ integer (c_intptr_t) function LIBMUSCLE_Instance_receive_with_settings_psd_( &
             integer (c_size_t), intent(out) :: err_msg_len
         end function LIBMUSCLE_Instance_receive_with_settings_psd_
 
+        logical (c_bool) function LIBMUSCLE_Instance_resuming_(self) &
+                bind(C, name="LIBMUSCLE_Instance_resuming_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+        end function LIBMUSCLE_Instance_resuming_
+
+        logical (c_bool) function LIBMUSCLE_Instance_should_init_(self) &
+                bind(C, name="LIBMUSCLE_Instance_should_init_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+        end function LIBMUSCLE_Instance_should_init_
+
+        integer (c_intptr_t) function LIBMUSCLE_Instance_load_snapshot_(self) &
+                bind(C, name="LIBMUSCLE_Instance_load_snapshot_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+        end function LIBMUSCLE_Instance_load_snapshot_
+
+        logical (c_bool) function LIBMUSCLE_Instance_should_save_snapshot_( &
+                self, &
+                timestamp) &
+                bind(C, name="LIBMUSCLE_Instance_should_save_snapshot_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+            real (c_double), value, intent(in) :: timestamp
+        end function LIBMUSCLE_Instance_should_save_snapshot_
+
+        subroutine LIBMUSCLE_Instance_save_snapshot_( &
+                self, &
+                message) &
+                bind(C, name="LIBMUSCLE_Instance_save_snapshot_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+            integer (c_intptr_t), value, intent(in) :: message
+        end subroutine LIBMUSCLE_Instance_save_snapshot_
+
+        logical (c_bool) function LIBMUSCLE_Instance_should_save_final_snapshot_(self) &
+                bind(C, name="LIBMUSCLE_Instance_should_save_final_snapshot_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+        end function LIBMUSCLE_Instance_should_save_final_snapshot_
+
+        subroutine LIBMUSCLE_Instance_save_final_snapshot_( &
+                self, &
+                message) &
+                bind(C, name="LIBMUSCLE_Instance_save_final_snapshot_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+            integer (c_intptr_t), value, intent(in) :: message
+        end subroutine LIBMUSCLE_Instance_save_final_snapshot_
+
     end interface
 
     interface LIBMUSCLE_DataConstRef_create
@@ -17861,6 +17926,103 @@ function LIBMUSCLE_Instance_receive_with_settings_psd( &
         LIBMUSCLE_Instance_receive_with_settings_psd%ptr = ret_val
     end function LIBMUSCLE_Instance_receive_with_settings_psd
 
+    function LIBMUSCLE_Instance_resuming( &
+            self)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        logical :: LIBMUSCLE_Instance_resuming
+
+        logical (c_bool) :: ret_val
+
+        ret_val = LIBMUSCLE_Instance_resuming_( &
+            self%ptr)
+
+        LIBMUSCLE_Instance_resuming = ret_val
+    end function LIBMUSCLE_Instance_resuming
+
+    function LIBMUSCLE_Instance_should_init( &
+            self)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        logical :: LIBMUSCLE_Instance_should_init
+
+        logical (c_bool) :: ret_val
+
+        ret_val = LIBMUSCLE_Instance_should_init_( &
+            self%ptr)
+
+        LIBMUSCLE_Instance_should_init = ret_val
+    end function LIBMUSCLE_Instance_should_init
+
+    function LIBMUSCLE_Instance_load_snapshot( &
+            self)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        type(LIBMUSCLE_Message) :: LIBMUSCLE_Instance_load_snapshot
+
+        integer (c_intptr_t) :: ret_val
+
+        ret_val = LIBMUSCLE_Instance_load_snapshot_( &
+            self%ptr)
+
+        LIBMUSCLE_Instance_load_snapshot%ptr = ret_val
+    end function LIBMUSCLE_Instance_load_snapshot
+
+    function LIBMUSCLE_Instance_should_save_snapshot( &
+            self, &
+            timestamp)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        real (LIBMUSCLE_real8), intent(in) :: timestamp
+        logical :: LIBMUSCLE_Instance_should_save_snapshot
+
+        logical (c_bool) :: ret_val
+
+        ret_val = LIBMUSCLE_Instance_should_save_snapshot_( &
+            self%ptr, &
+            timestamp)
+
+        LIBMUSCLE_Instance_should_save_snapshot = ret_val
+    end function LIBMUSCLE_Instance_should_save_snapshot
+
+    subroutine LIBMUSCLE_Instance_save_snapshot( &
+            self, &
+            message)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        type(LIBMUSCLE_Message), intent(in) :: message
+
+        call LIBMUSCLE_Instance_save_snapshot_( &
+            self%ptr, &
+            message%ptr)
+    end subroutine LIBMUSCLE_Instance_save_snapshot
+
+    function LIBMUSCLE_Instance_should_save_final_snapshot( &
+            self)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        logical :: LIBMUSCLE_Instance_should_save_final_snapshot
+
+        logical (c_bool) :: ret_val
+
+        ret_val = LIBMUSCLE_Instance_should_save_final_snapshot_( &
+            self%ptr)
+
+        LIBMUSCLE_Instance_should_save_final_snapshot = ret_val
+    end function LIBMUSCLE_Instance_should_save_final_snapshot
+
+    subroutine LIBMUSCLE_Instance_save_final_snapshot( &
+            self, &
+            message)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        type(LIBMUSCLE_Message), intent(in) :: message
+
+        call LIBMUSCLE_Instance_save_final_snapshot_( &
+            self%ptr, &
+            message%ptr)
+    end subroutine LIBMUSCLE_Instance_save_final_snapshot
+
     integer function LIBMUSCLE_InstanceFlags_to_int_(flags)
         implicit none
 
diff --git a/libmuscle/fortran/src/libmuscle/libmuscle_mpi.f90 b/libmuscle/fortran/src/libmuscle/libmuscle_mpi.f90
index 960620a6..0c24a3db 100644
--- a/libmuscle/fortran/src/libmuscle/libmuscle_mpi.f90
+++ b/libmuscle/fortran/src/libmuscle/libmuscle_mpi.f90
@@ -466,6 +466,13 @@ module libmuscle_mpi
     public :: LIBMUSCLE_Instance_receive_with_settings_ps
     public :: LIBMUSCLE_Instance_receive_with_settings_psd
     public :: LIBMUSCLE_Instance_receive_with_settings_on_slot
+    public :: LIBMUSCLE_Instance_resuming
+    public :: LIBMUSCLE_Instance_should_init
+    public :: LIBMUSCLE_Instance_load_snapshot
+    public :: LIBMUSCLE_Instance_should_save_snapshot
+    public :: LIBMUSCLE_Instance_save_snapshot
+    public :: LIBMUSCLE_Instance_should_save_final_snapshot
+    public :: LIBMUSCLE_Instance_save_final_snapshot
     public :: LIBMUSCLE_InstanceFlags
     type LIBMUSCLE_InstanceFlags
         logical :: DONT_APPLY_OVERLAY = .false.
@@ -3489,6 +3496,64 @@ integer (c_intptr_t) function LIBMUSCLE_Instance_receive_with_settings_psd_( &
             integer (c_size_t), intent(out) :: err_msg_len
         end function LIBMUSCLE_Instance_receive_with_settings_psd_
 
+        logical (c_bool) function LIBMUSCLE_Instance_resuming_(self) &
+                bind(C, name="LIBMUSCLE_Instance_resuming_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+        end function LIBMUSCLE_Instance_resuming_
+
+        logical (c_bool) function LIBMUSCLE_Instance_should_init_(self) &
+                bind(C, name="LIBMUSCLE_Instance_should_init_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+        end function LIBMUSCLE_Instance_should_init_
+
+        integer (c_intptr_t) function LIBMUSCLE_Instance_load_snapshot_(self) &
+                bind(C, name="LIBMUSCLE_Instance_load_snapshot_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+        end function LIBMUSCLE_Instance_load_snapshot_
+
+        logical (c_bool) function LIBMUSCLE_Instance_should_save_snapshot_( &
+                self, &
+                timestamp) &
+                bind(C, name="LIBMUSCLE_Instance_should_save_snapshot_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+            real (c_double), value, intent(in) :: timestamp
+        end function LIBMUSCLE_Instance_should_save_snapshot_
+
+        subroutine LIBMUSCLE_Instance_save_snapshot_( &
+                self, &
+                message) &
+                bind(C, name="LIBMUSCLE_Instance_save_snapshot_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+            integer (c_intptr_t), value, intent(in) :: message
+        end subroutine LIBMUSCLE_Instance_save_snapshot_
+
+        logical (c_bool) function LIBMUSCLE_Instance_should_save_final_snapshot_(self) &
+                bind(C, name="LIBMUSCLE_Instance_should_save_final_snapshot_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+        end function LIBMUSCLE_Instance_should_save_final_snapshot_
+
+        subroutine LIBMUSCLE_Instance_save_final_snapshot_( &
+                self, &
+                message) &
+                bind(C, name="LIBMUSCLE_Instance_save_final_snapshot_")
+
+            use iso_c_binding
+            integer (c_intptr_t), value, intent(in) :: self
+            integer (c_intptr_t), value, intent(in) :: message
+        end subroutine LIBMUSCLE_Instance_save_final_snapshot_
+
     end interface
 
     interface LIBMUSCLE_DataConstRef_create
@@ -17873,6 +17938,103 @@ function LIBMUSCLE_Instance_receive_with_settings_psd( &
         LIBMUSCLE_Instance_receive_with_settings_psd%ptr = ret_val
     end function LIBMUSCLE_Instance_receive_with_settings_psd
 
+    function LIBMUSCLE_Instance_resuming( &
+            self)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        logical :: LIBMUSCLE_Instance_resuming
+
+        logical (c_bool) :: ret_val
+
+        ret_val = LIBMUSCLE_Instance_resuming_( &
+            self%ptr)
+
+        LIBMUSCLE_Instance_resuming = ret_val
+    end function LIBMUSCLE_Instance_resuming
+
+    function LIBMUSCLE_Instance_should_init( &
+            self)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        logical :: LIBMUSCLE_Instance_should_init
+
+        logical (c_bool) :: ret_val
+
+        ret_val = LIBMUSCLE_Instance_should_init_( &
+            self%ptr)
+
+        LIBMUSCLE_Instance_should_init = ret_val
+    end function LIBMUSCLE_Instance_should_init
+
+    function LIBMUSCLE_Instance_load_snapshot( &
+            self)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        type(LIBMUSCLE_Message) :: LIBMUSCLE_Instance_load_snapshot
+
+        integer (c_intptr_t) :: ret_val
+
+        ret_val = LIBMUSCLE_Instance_load_snapshot_( &
+            self%ptr)
+
+        LIBMUSCLE_Instance_load_snapshot%ptr = ret_val
+    end function LIBMUSCLE_Instance_load_snapshot
+
+    function LIBMUSCLE_Instance_should_save_snapshot( &
+            self, &
+            timestamp)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        real (LIBMUSCLE_real8), intent(in) :: timestamp
+        logical :: LIBMUSCLE_Instance_should_save_snapshot
+
+        logical (c_bool) :: ret_val
+
+        ret_val = LIBMUSCLE_Instance_should_save_snapshot_( &
+            self%ptr, &
+            timestamp)
+
+        LIBMUSCLE_Instance_should_save_snapshot = ret_val
+    end function LIBMUSCLE_Instance_should_save_snapshot
+
+    subroutine LIBMUSCLE_Instance_save_snapshot( &
+            self, &
+            message)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        type(LIBMUSCLE_Message), intent(in) :: message
+
+        call LIBMUSCLE_Instance_save_snapshot_( &
+            self%ptr, &
+            message%ptr)
+    end subroutine LIBMUSCLE_Instance_save_snapshot
+
+    function LIBMUSCLE_Instance_should_save_final_snapshot( &
+            self)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        logical :: LIBMUSCLE_Instance_should_save_final_snapshot
+
+        logical (c_bool) :: ret_val
+
+        ret_val = LIBMUSCLE_Instance_should_save_final_snapshot_( &
+            self%ptr)
+
+        LIBMUSCLE_Instance_should_save_final_snapshot = ret_val
+    end function LIBMUSCLE_Instance_should_save_final_snapshot
+
+    subroutine LIBMUSCLE_Instance_save_final_snapshot( &
+            self, &
+            message)
+        implicit none
+        type(LIBMUSCLE_Instance), intent(in) :: self
+        type(LIBMUSCLE_Message), intent(in) :: message
+
+        call LIBMUSCLE_Instance_save_final_snapshot_( &
+            self%ptr, &
+            message%ptr)
+    end subroutine LIBMUSCLE_Instance_save_final_snapshot
+
     integer function LIBMUSCLE_InstanceFlags_to_int_(flags)
         implicit none
 
diff --git a/libmuscle/python/libmuscle/api_guard.py b/libmuscle/python/libmuscle/api_guard.py
index 6fa7ee51..34dbcc4b 100644
--- a/libmuscle/python/libmuscle/api_guard.py
+++ b/libmuscle/python/libmuscle/api_guard.py
@@ -12,14 +12,6 @@ class APIPhase(Enum):
     This does not match the yMMSL operators, as it is more
     fine-grained and concerns checkpointing, which is not represented
     in the SEL.
-
-    Note that AFTER_REUSE_INSTANCE and BEFORE_RESUMING refer to the
-    same place in the code. AFTER_REUSE_INSTANCE is used when we
-    don't know yet if the code has checkpointing support, and so we
-    don't know whether the next call is to resuming() or to
-    reuse_instance(). Once a checkpointing function has been called,
-    we know that we should expect resume() after reuse_instance() and
-    we use BEFORE_RESUMING accordingly.
     """
     BEFORE_FIRST_REUSE_INSTANCE = auto()
     """Before the first time calling reuse_instance"""
@@ -59,9 +51,9 @@ class APIGuard:
     successfully, and that we are moving on to the next phase.
     """
     def __init__(self, uses_checkpointing: bool) -> None:
-        """Create an APIPhaseTracker.
+        """Create an APIGuard.
 
-        This starts the tracker in BEFORE_FIRST_REUSE_INSTANCE.
+        This starts the tracker in the phase BEFORE_FIRST_REUSE_INSTANCE.
         """
         self._phase = APIPhase.BEFORE_FIRST_REUSE_INSTANCE
         self._uses_checkpointing = uses_checkpointing
@@ -73,7 +65,7 @@ def _generic_error_messages(self, verify_phase: str) -> None:
             msg = f'Please only call {verify_phase} inside the reuse loop.'
         elif self._phase == APIPhase.BEFORE_REUSE_INSTANCE:
             msg = (
-                    'Please do not call {verify_phase} after'
+                    f'Please do not call {verify_phase} after'
                     ' should_save_final_snapshot. should_save_final_snapshot'
                     ' should be at the end of the reuse loop.')
         elif self._phase == APIPhase.BEFORE_RESUMING:
@@ -140,7 +132,6 @@ def resuming_done(self, resuming: bool) -> None:
         Args:
             resuming: Whether we're resuming or not.
         """
-        self._uses_checkpointing = True
         if resuming:
             self._phase = APIPhase.BEFORE_LOAD_SNAPSHOT
         else:
diff --git a/libmuscle/python/libmuscle/checkpoint_triggers.py b/libmuscle/python/libmuscle/checkpoint_triggers.py
index 5d47c0d3..887a4d54 100644
--- a/libmuscle/python/libmuscle/checkpoint_triggers.py
+++ b/libmuscle/python/libmuscle/checkpoint_triggers.py
@@ -78,9 +78,6 @@ class RangeCheckpointTrigger(CheckpointTrigger):
     Start may be omitted, in which case the range is equivalent to an "at" rule
     ``[..., -n*step, ..., -step, 0, step, 2*step, ...]`` for as long as
     ``i*step <= stop``.
-
-    Note: the "every" rule is a special case of a range with start and stop
-    omitted, and is handled by this class as well
     """
 
     def __init__(self, range: CheckpointRangeRule) -> None:
@@ -118,7 +115,7 @@ def previous_checkpoint(self, cur_time: float) -> Optional[float]:
 
 
 class CombinedCheckpointTriggers(CheckpointTrigger):
-    """Checkpoint trigger based on a combination of "every", "at" and "ranges"
+    """Checkpoint trigger based on a combination of "at" and "ranges"
     """
 
     def __init__(self, checkpoint_rules: List[CheckpointRule]) -> None:
diff --git a/libmuscle/python/libmuscle/communicator.py b/libmuscle/python/libmuscle/communicator.py
index 933fee75..4edde7dc 100644
--- a/libmuscle/python/libmuscle/communicator.py
+++ b/libmuscle/python/libmuscle/communicator.py
@@ -412,7 +412,7 @@ def shutdown(self) -> None:
 
     def restore_message_counts(self, port_message_counts: Dict[str, List[int]]
                                ) -> None:
-        """Restore message counts on all ports
+        """Restore message counts on all ports.
         """
         for port_name, num_messages in port_message_counts.items():
             if port_name == "muscle_settings_in":
@@ -425,7 +425,7 @@ def restore_message_counts(self, port_message_counts: Dict[str, List[int]]
                                    ' the snapshot was taken?')
 
     def get_message_counts(self) -> Dict[str, List[int]]:
-        """Get message counts for all ports on the communicator
+        """Get message counts for all ports on the communicator.
         """
         port_message_counts = {port_name: port.get_message_counts()
                                for port_name, port in self._ports.items()}
diff --git a/libmuscle/python/libmuscle/instance.py b/libmuscle/python/libmuscle/instance.py
index 84c948bd..13b7fd41 100644
--- a/libmuscle/python/libmuscle/instance.py
+++ b/libmuscle/python/libmuscle/instance.py
@@ -180,32 +180,9 @@ def __init__(
 
         self._register()
         self._connect()
-
-        # Note: get_checkpoint_info needs to have the ports initialized
+        # Note: self._setup_checkpointing() needs to have the ports initialized
         # so it comes after self._connect()
-        checkpoint_info = self.__manager.get_checkpoint_info(self._instance_id)
-
-        elapsed_time, checkpoints = checkpoint_info[0:2]
-        self._trigger_manager.set_checkpoint_info(elapsed_time, checkpoints)
-
-        if checkpoints and not (self._flags & _CHECKPOINT_SUPPORT_MASK):
-            raise RuntimeError(
-                    'The workflow has requested checkpoints, but this instance'
-                    ' does not support checkpointing. Please consult the'
-                    ' MUSCLE3 checkpointing documentation how to add'
-                    ' checkpointing support.')
-
-        resume_snapshot, snapshot_dir = checkpoint_info[2:4]
-        saved_at = self._snapshot_manager.prepare_resume(
-                resume_snapshot, snapshot_dir)
-        # Resume settings overlay
-        overlay = self._snapshot_manager._resume_overlay
-        if overlay is not None:
-            self._settings_manager.overlay = overlay
-
-        if saved_at is not None:
-            self._trigger_manager.update_checkpoints(saved_at)
-
+        self._setup_checkpointing()
         self._set_local_log_level()
         self._set_remote_log_level()
 
@@ -533,8 +510,7 @@ def resuming(self) -> bool:
         by :meth:`load_snapshot`.
 
         Returns:
-            True iff the submodel must resume from a snapshot instead of the
-            usual F_INIT step during this iteration of the reuse loop.
+            True iff the submodel must resume from a snapshot.
         """
         self._api_guard.verify_resuming()
         self._api_guard.resuming_done(self._do_resume)
@@ -563,7 +539,7 @@ def load_snapshot(self) -> Message:
 
         Returns:
             Message object containing the state as saved in a previous run
-            through :meth:`save_snapshot` or :meth:`save_final_snapshot`
+            through :meth:`save_snapshot` or :meth:`save_final_snapshot`.
 
         Raises:
             RuntimeError: if not resuming from a snapshot.
@@ -581,14 +557,14 @@ def should_save_snapshot(self, timestamp: float) -> bool:
         provided timestamp and passed wallclock time.
 
         When this method returns True, the submodel must also save a snapshot
-        through :meth:`save_snapshot`. A RuntimeError will be generated when not
-        doing so.
+        through :meth:`save_snapshot`. A RuntimeError will be generated if this
+        is not done.
 
         See also :meth:`should_save_final_snapshot` for the variant that must be
         called at the end of the reuse loop.
 
         Args:
-            timestamp: current timestamp of the submodel
+            timestamp: current timestamp of the submodel.
 
         Returns:
             True iff a snapshot should be taken by the submodel according to the
@@ -608,12 +584,6 @@ def save_snapshot(self, message: Message) -> None:
         use the same timestamp in the provided Message object as used to query
         `should_save_snapshot`.
 
-        Although it is allowed to save a snapshot even when
-        :meth:`should_save_snapshot` returns False, you should avoid this: this
-        situation is not likely to lead to a consistent snapshot over all
-        submodels of the run (and therefore it is not useful to restart from).
-        It could also lead to a lot of snapshot files clogging your file system.
-
         See also :meth:`save_final_snapshot` for the variant that must be called
         at the end of the reuse loop.
 
@@ -632,18 +602,20 @@ def save_snapshot(self, message: Message) -> None:
     def should_save_final_snapshot(self) -> bool:
         """Check if a snapshot should be saved at the end of the reuse loop.
 
-        This method checks if a snapshot should be saved now.
+        This method checks if a snapshot should be saved at the end of the reuse
+        loop. All your communication on O_F ports must be finished before calling
+        this method, otherwise your simulation may deadlock.
 
         When this method returns True, the submodel must also save a snapshot
         through :meth:`save_final_snapshot`. A :class:`RuntimeError` will be
-        generated when not doing so.
+        generated if this is not done.
 
         See also :meth:`should_save_snapshot` for the variant that may be called
         inside of a time-integration loop of the submodel.
 
         .. note::
             This method will block until it can determine whether a final
-            snapshot should be taken. This means it must also determine if this
+            snapshot should be taken, because it must determine if this
             instance is reused.
 
         Returns:
@@ -666,12 +638,6 @@ def save_final_snapshot(self, message: Message) -> None:
         :meth:`should_save_final_snapshot` if a snapshot should be saved
         according to the checkpoint rules specified in the ymmsl configuration.
 
-        Although it is allowed to save a snapshot even when
-        :meth:`should_save_final_snapshot` returns False, you should avoid this:
-        this situation is not likely to lead to a consistent snapshot over all
-        submodels of the run (and therefore it is not useful to restart from).
-        It could also lead to a lot of snapshot files clogging your file system.
-
         See also :meth:`save_snapshot` for the variant that may be called after
         each S Operator of the submodel.
 
@@ -727,6 +693,34 @@ def _deregister(self) -> None:
         self._profiler.shutdown()
         _logger.info('Deregistered from the manager')
 
+    def _setup_checkpointing(self) -> None:
+        """Setup checkpointing.
+        """
+        checkpoint_info = self.__manager.get_checkpoint_info()
+
+        elapsed_time, checkpoints = checkpoint_info[0:2]
+        self._trigger_manager.set_checkpoint_info(elapsed_time, checkpoints)
+
+        if checkpoints and not (self._flags & _CHECKPOINT_SUPPORT_MASK):
+            err_msg = (
+                    'The workflow has requested checkpoints, but this instance'
+                    ' does not support checkpointing. Please consult the'
+                    ' MUSCLE3 checkpointing documentation how to add'
+                    ' checkpointing support.')
+            self.__shutdown(err_msg)
+            raise RuntimeError(err_msg)
+
+        resume_snapshot, snapshot_dir = checkpoint_info[2:4]
+        saved_at = self._snapshot_manager.prepare_resume(
+                resume_snapshot, snapshot_dir)
+        # Resume settings overlay
+        overlay = self._snapshot_manager.resume_overlay
+        if overlay is not None:
+            self._settings_manager.overlay = overlay
+
+        if saved_at is not None:
+            self._trigger_manager.update_checkpoints(saved_at)
+
     @staticmethod
     def __extract_manager_location() -> str:
         """Gets the manager network location from the command line.
diff --git a/libmuscle/python/libmuscle/manager/mmp_server.py b/libmuscle/python/libmuscle/manager/mmp_server.py
index eb4dba2e..4aa32b99 100644
--- a/libmuscle/python/libmuscle/manager/mmp_server.py
+++ b/libmuscle/python/libmuscle/manager/mmp_server.py
@@ -6,7 +6,7 @@
 import msgpack
 from ymmsl import (
         Conduit, Identifier, Operator, Port, Reference, PartialConfiguration,
-        Checkpoints)
+        Checkpoints, CheckpointRule, CheckpointAtRule, CheckpointRangeRule)
 
 import libmuscle
 from libmuscle.logging import LogLevel
@@ -44,12 +44,26 @@ def encode_conduit(conduit: Conduit) -> List[str]:
     return [str(conduit.sender), str(conduit.receiver)]
 
 
+def encode_checkpoint_rule(rule: CheckpointRule) -> Dict[str, Any]:
+    """Convert a CheckpointRule to a MsgPack-compatible value."""
+    if isinstance(rule, CheckpointAtRule):
+        return {'at': list(map(float, rule.at))}
+    if isinstance(rule, CheckpointRangeRule):
+        return {
+            'start': None if rule.start is None else float(rule.start),
+            'stop': None if rule.stop is None else float(rule.stop),
+            'every': float(rule.every)}
+    raise TypeError(f"Unknown checkpoint rule type: {type(rule)}.")
+
+
 def encode_checkpoints(checkpoints: Checkpoints) -> Dict[str, Any]:
     """Convert a Checkpoins to a MsgPack-compatible value."""
     return {
         "at_end": checkpoints.at_end,
-        "wallclock_time": [vars(rule) for rule in checkpoints.wallclock_time],
-        "simulation_time": [vars(rule) for rule in checkpoints.simulation_time]
+        "wallclock_time":
+            list(map(encode_checkpoint_rule, checkpoints.wallclock_time)),
+        "simulation_time":
+            list(map(encode_checkpoint_rule, checkpoints.simulation_time)),
     }
 
 
diff --git a/libmuscle/python/libmuscle/mmp_client.py b/libmuscle/python/libmuscle/mmp_client.py
index da21b547..3fe82a91 100644
--- a/libmuscle/python/libmuscle/mmp_client.py
+++ b/libmuscle/python/libmuscle/mmp_client.py
@@ -149,17 +149,15 @@ def submit_profile_events(self, events: Iterable[ProfileEvent]) -> None:
         self._call_manager(request)
 
     def submit_snapshot_metadata(
-                self, name: Reference, snapshot_metadata: SnapshotMetadata
-                ) -> None:
+            self, snapshot_metadata: SnapshotMetadata) -> None:
         """Send snapshot metadata to the manager.
 
         Args:
-            name: Name of the instance in the simulation.
             snapshot_metadata: Snapshot metadata to supply to the manager.
         """
         request = [
                 RequestType.SUBMIT_SNAPSHOT.value,
-                str(name),
+                str(self._instance_id),
                 dataclasses.asdict(snapshot_metadata)]
         self._call_manager(request)
 
@@ -173,7 +171,7 @@ def get_settings(self) -> Settings:
         response = self._call_manager(request)
         return Settings(response[1])
 
-    def get_checkpoint_info(self, name: Reference) -> _CheckpointInfoType:
+    def get_checkpoint_info(self) -> _CheckpointInfoType:
         """Get the checkpoint info from the manager.
 
         Returns:
@@ -182,7 +180,7 @@ def get_checkpoint_info(self, name: Reference) -> _CheckpointInfoType:
             resume: path to the resume snapshot
             snapshot_directory: path to store snapshots
         """
-        request = [RequestType.GET_CHECKPOINT_INFO.value, str(name)]
+        request = [RequestType.GET_CHECKPOINT_INFO.value, str(self._instance_id)]
         response = self._call_manager(request)
         return decode_checkpoint_info(*response[1:])
 
diff --git a/libmuscle/python/libmuscle/snapshot_manager.py b/libmuscle/python/libmuscle/snapshot_manager.py
index c4d1716e..9a0e0581 100644
--- a/libmuscle/python/libmuscle/snapshot_manager.py
+++ b/libmuscle/python/libmuscle/snapshot_manager.py
@@ -21,8 +21,7 @@
 class SnapshotManager:
     """Manages information on snapshots for the Instance
 
-    Implements the public checkpointing API with handoffs to
-    :class:`TriggerManager` for checkpoint triggers.
+    Implements the saving and loading of snapshots in the checkpointing API.
     """
 
     def __init__(self,
@@ -44,7 +43,7 @@ def __init__(self,
         self._manager = manager
 
         self._resume_from_snapshot: Optional[Snapshot] = None
-        self._resume_overlay = Settings()
+        self.resume_overlay = Settings()
         self._next_snapshot_num = 1
 
     def prepare_resume(
@@ -74,14 +73,14 @@ def prepare_resume(
                 # snapshot.message is None for implicit snapshots
                 self._resume_from_snapshot = snapshot
                 result = snapshot.message.timestamp
-            self._resume_overlay = snapshot.settings_overlay
+            self.resume_overlay = snapshot.settings_overlay
 
             self._communicator.restore_message_counts(
                 snapshot.port_message_counts)
             # Store a copy of the snapshot in the current run directory
             path = self.__store_snapshot(snapshot)
             metadata = SnapshotMetadata.from_snapshot(snapshot, str(path))
-            self._manager.submit_snapshot_metadata(self._instance_id, metadata)
+            self._manager.submit_snapshot_metadata(metadata)
 
         return result
 
@@ -125,9 +124,10 @@ def save_snapshot(
             triggers: Description of checkpoints that triggered this.
             wallclock_time: Wallclock time when saving.
             f_init_max_timestamp: Timestamp for final snapshots.
+            settings_overlay: Current settings overlay.
 
         Returns:
-            Simulation time at which the snapshot was made
+            Simulation time at which the snapshot was made.
         """
         port_message_counts = self._communicator.get_message_counts()
         if final:
@@ -148,7 +148,7 @@ def save_snapshot(
 
         path = self.__store_snapshot(snapshot)
         metadata = SnapshotMetadata.from_snapshot(snapshot, str(path))
-        self._manager.submit_snapshot_metadata(self._instance_id, metadata)
+        self._manager.submit_snapshot_metadata(metadata)
 
         timestamp = msg.timestamp if msg is not None else float('-inf')
         if final and f_init_max_timestamp is not None:
@@ -159,10 +159,10 @@ def save_snapshot(
 
     @staticmethod
     def load_snapshot_from_file(snapshot_location: Path) -> Snapshot:
-        """Load a previously stored snapshot from the filesystem
+        """Load a previously stored snapshot from the filesystem.
 
         Args:
-            snapshot_location: path where the snapshot is stored
+            snapshot_location: path where the snapshot is stored.
         """
         _logger.debug(f'Loading snapshot from {snapshot_location}')
         if not snapshot_location.is_file():
@@ -184,13 +184,13 @@ def load_snapshot_from_file(snapshot_location: Path) -> Snapshot:
                                ' edited?')
 
     def __store_snapshot(self, snapshot: Snapshot) -> Path:
-        """Store a snapshot on the filesystem
+        """Store a snapshot on the filesystem.
 
         Args:
-            snapshot: snapshot to store
+            snapshot: Snapshot to store.
 
         Returns:
-            Path where the snapshot is stored
+            Path where the snapshot is stored.
         """
         _logger.debug(f'Saving snapshot to {self._snapshot_directory}')
         for _ in range(_MAX_FILE_EXISTS_CHECK):
diff --git a/scripts/make_libmuscle_api.py b/scripts/make_libmuscle_api.py
index 933f1330..3c84cb09 100755
--- a/scripts/make_libmuscle_api.py
+++ b/scripts/make_libmuscle_api.py
@@ -1008,7 +1008,14 @@ def __copy__(self) -> 'Elements':
                'self_p->receive_with_settings({})'.format(
                kwargs['cpp_args']))),
     OverloadSet('receive_with_settings_on_slot',
-                ['receive_with_settings_ps', 'receive_with_settings_psd'])
+                ['receive_with_settings_ps', 'receive_with_settings_psd']),
+    MemFun(Bool(), 'resuming'),
+    MemFun(Bool(), 'should_init'),
+    MemFun(Obj('Message'), 'load_snapshot'),
+    MemFun(Bool(), 'should_save_snapshot', [Double('timestamp')]),
+    MemFun(Void(), 'save_snapshot', [Obj('Message', 'message')]),
+    MemFun(Bool(), 'should_save_final_snapshot'),
+    MemFun(Void(), 'save_final_snapshot', [Obj('Message', 'message')]),
     ]
 
 


Test patch:
diff --git a/integration_test/conftest.py b/integration_test/conftest.py
index 210659b7..b7c5a0d5 100644
--- a/integration_test/conftest.py
+++ b/integration_test/conftest.py
@@ -20,6 +20,10 @@
         'MUSCLE_TEST_PYTHON_ONLY' in os.environ,
         reason='Python-only tests requested')
 
+skip_if_no_mpi_cpp = pytest.mark.skipif(
+        'MUSCLE_ENABLE_CPP_MPI' not in os.environ,
+        reason='MPI support was not detected')
+
 
 @pytest.fixture
 def yatiml_log_warning():
@@ -50,10 +54,12 @@ def make_server_process(ymmsl_doc, tmpdir):
     process.start()
     control_pipe[1].close()
     # wait for start
-    yield control_pipe[0].recv()
-    control_pipe[0].send(True)
-    control_pipe[0].close()
-    process.join()
+    try:
+        yield control_pipe[0].recv()
+    finally:
+        control_pipe[0].send(True)
+        control_pipe[0].close()
+        process.join()
 
 
 def _python_wrapper(instance_name, muscle_manager, callable):
@@ -62,27 +68,37 @@ def _python_wrapper(instance_name, muscle_manager, callable):
     callable()
 
 
-def run_manager_with_actors(
-        ymmsl_text, tmpdir,
-        cpp_actors={}, fortran_actors={}, python_actors={}):
+def run_manager_with_actors(ymmsl_text, tmpdir, actors):
     """Start muscle_manager along with C++ and python actors.
 
-    C++ actors are a dict of instance->executable_path. Executable paths are
-    assumed to be relative to ../libmuscle/cpp/build/. LD_LIBRARY_PATH is
-    automatically updated to include the msgpack library path.
+    Args:
+        actors: a dictionary of lists containing details for each actor:
+            ``{"instance_name": ("language", "details", ...)}``.
+
+            Language can be ``"python"``, ``"cpp"``, ``"mpi_cpp"`` or ``"fortran"``.
+            Details differ per language.
+
+            For python actors, details is a single callable which is executed
+            in a ``multiprocessing.Process``.
 
-    Fortran actors are a dict of instance->executable_path. Executable paths are
-    assumed to be relative to ../libmuscle/fortran/build/. LD_LIBRARY_PATH is
-    automatically updated to include the msgpack library path.
+            For cpp actors, details is an executable path with optional arguments.
+            The executable paths are assumed to be relative to
+            ``../libmuscle/cpp/build/libmuscle/tests``.
 
-    Python actors are a dict of instance->callable, where the callable
-    implements the python actor.
+            For mpi cpp actors, details is an executable path (see cpp), then number of
+            processes and optionally arguments passed to the executable.
+
+            For fortran actors, details is an executable path. Executable paths are
+            assumed to be relative to ``../libmuscle/fortran/build/libmuscle/tests``.
+
+            For both cpp and Fortran actors, LD_LIBRARY_PATH is automatically updated
+            to include the msgpack library path.
     """
     env = os.environ.copy()
     ymmsl_doc = ymmsl.load(ymmsl_text)
     libmuscle_dir = Path(__file__).parents[1] / 'libmuscle'
-    cpp_build_dir = libmuscle_dir / 'cpp' / 'build'
-    fortran_build_dir = libmuscle_dir / 'fortran' / 'build'
+    cpp_build_dir = libmuscle_dir / 'cpp' / 'build' / 'libmuscle' / 'tests'
+    fortran_build_dir = libmuscle_dir / 'fortran' / 'build' / 'libmuscle' / 'tests'
 
     with ExitStack() as stack:
         # start muscle_manager and extract manager location
@@ -96,28 +112,38 @@ def run_manager_with_actors(
             env['LD_LIBRARY_PATH'] = ':'.join(map(str, lib_paths))
 
         native_processes = []
-        # start native actors
-        for actors, build_dir in ((cpp_actors, cpp_build_dir),
-                                  (fortran_actors, fortran_build_dir)):
-            for instance_name, executable_path in actors.items():
-                executable = build_dir / executable_path
-                f_out = stack.enter_context(
-                        (tmpdir / f'{instance_name}_stdout.txt').open('w'))
-                f_err = stack.enter_context(
-                        (tmpdir / f'{instance_name}_stderr.txt').open('w'))
-                native_processes.append(subprocess.Popen(
-                        [str(executable), f'--muscle-instance={instance_name}'],
-                        env=env, stdout=f_out, stderr=f_err))
-
-        # start python actors
         python_processes = []
-        for instance_name, callable in python_actors.items():
-            proc = mp.Process(
-                    target=_python_wrapper,
-                    args=(instance_name, env['MUSCLE_MANAGER'], callable),
-                    name=instance_name)
-            proc.start()
-            python_processes.append(proc)
+        # start actors
+        for instance_name, (language, actor, *args) in actors.items():
+            if language == "python":
+                # start python actor
+                proc = mp.Process(
+                        target=_python_wrapper,
+                        args=(instance_name, env['MUSCLE_MANAGER'], actor),
+                        name=instance_name)
+                proc.start()
+                python_processes.append(proc)
+                continue
+            elif language == "cpp":
+                executable = cpp_build_dir / actor
+            elif language == "mpicpp":
+                assert len(args) > 0, "must provide at least number of mpi instances"
+                executable = 'mpirun'
+                out_file = tmpdir / f'mpi_{instance_name}.log'
+                args = ('-np', args[0], mpirun_outfile_arg(), str(out_file),
+                        str(cpp_build_dir / actor), *args[1:])
+            elif language == "fortran":
+                executable = fortran_build_dir / actor
+            else:
+                raise ValueError(f"Unknown language: {language}")
+            # start native code actor
+            f_out = stack.enter_context(
+                    (tmpdir / f'{instance_name}_stdout.txt').open('w'))
+            f_err = stack.enter_context(
+                    (tmpdir / f'{instance_name}_stderr.txt').open('w'))
+            native_processes.append(subprocess.Popen(
+                    [str(executable), *args, f'--muscle-instance={instance_name}'],
+                    env=env, stdout=f_out, stderr=f_err))
 
         # check results
         for proc in native_processes:
@@ -212,7 +238,6 @@ def log_file_in_tmpdir(tmpdir):
     os.chdir(old_workdir)
 
 
-@pytest.fixture
 def mpi_is_intel():
     if 'MUSCLE_ENABLE_CPP_MPI' not in os.environ:
         return None
@@ -222,17 +247,16 @@ def mpi_is_intel():
     return 'Intel' in result.stdout.decode('utf-8')
 
 
-@pytest.fixture
-def mpirun_outfile_arg(mpi_is_intel):
-    if mpi_is_intel:
+def mpirun_outfile_arg():
+    if mpi_is_intel():
         return '-outfile-pattern'
     else:
         return '--output-filename'
 
 
 @pytest.fixture
-def mpi_exec_model(mpi_is_intel):
-    if mpi_is_intel:
+def mpi_exec_model():
+    if mpi_is_intel():
         return 'intelmpi'
     else:
         return 'openmpi'
diff --git a/integration_test/test_cpp_macro_micro.py b/integration_test/test_cpp_macro_micro.py
index 10e60828..4d06dde3 100644
--- a/integration_test/test_cpp_macro_micro.py
+++ b/integration_test/test_cpp_macro_micro.py
@@ -1,4 +1,3 @@
-from pathlib import Path
 import sqlite3
 
 import numpy as np
@@ -72,8 +71,7 @@ def test_cpp_macro_micro(mmp_server_config_simple, tmp_path):
     run_manager_with_actors(
             mmp_server_config_simple,
             tmp_path,
-            {'micro': Path('libmuscle') / 'tests' / 'micro_model_test'},
-            {},
-            {'macro': macro})
+            {'micro': ('cpp', 'micro_model_test'),
+             'macro': ('python', macro)})
 
     check_profile_output(tmp_path)
diff --git a/integration_test/test_fortran_macro_micro.py b/integration_test/test_fortran_macro_micro.py
index 0717891c..c8c5bbcd 100644
--- a/integration_test/test_fortran_macro_micro.py
+++ b/integration_test/test_fortran_macro_micro.py
@@ -1,5 +1,3 @@
-from pathlib import Path
-
 import numpy as np
 
 from libmuscle import Instance, Message
@@ -44,6 +42,5 @@ def test_fortran_macro_micro(mmp_server_config_simple, tmp_path):
     run_manager_with_actors(
             mmp_server_config_simple,
             tmp_path,
-            {},
-            {'micro': Path('libmuscle') / 'tests' / 'fortran_micro_model_test'},
-            {'macro': macro})
+            {'micro': ('fortran', 'fortran_micro_model_test'),
+             'macro': ('python', macro)})
diff --git a/integration_test/test_mpi_macro_micro.py b/integration_test/test_mpi_macro_micro.py
index 2e9eb39a..7cd5138a 100644
--- a/integration_test/test_mpi_macro_micro.py
+++ b/integration_test/test_mpi_macro_micro.py
@@ -1,20 +1,7 @@
-import multiprocessing as mp
-import os
-from pathlib import Path
-import pytest
-import subprocess
-import sys
-
 from libmuscle import Instance, Message
 from ymmsl import Operator
 
-from .conftest import skip_if_python_only
-
-
-def run_macro(instance_id: str, muscle_manager: str):
-    sys.argv.append('--muscle-instance={}'.format(instance_id))
-    sys.argv.append('--muscle-manager={}'.format(muscle_manager))
-    macro()
+from .conftest import run_manager_with_actors, skip_if_python_only, skip_if_no_mpi_cpp
 
 
 def macro():
@@ -37,38 +24,9 @@ def macro():
 
 
 @skip_if_python_only
-def test_mpi_macro_micro(
-        tmpdir, mmp_server_process_simple, mpirun_outfile_arg):
-    # only run this if MPI is enabled
-    if 'MUSCLE_ENABLE_CPP_MPI' not in os.environ:
-        pytest.skip('MPI support was not detected')
-
-    # create C++ micro model
-    # see libmuscle/cpp/src/libmuscle/tests/micro_model_test.cpp
-    cpp_build_dir = Path(__file__).parents[1] / 'libmuscle' / 'cpp' / 'build'
-    env = os.environ.copy()
-    lib_paths = [cpp_build_dir / 'msgpack' / 'msgpack' / 'lib']
-    if 'LD_LIBRARY_PATH' in env:
-        env['LD_LIBRARY_PATH'] += ':' + ':'.join(map(str, lib_paths))
-    else:
-        env['LD_LIBRARY_PATH'] = ':'.join(map(str, lib_paths))
-
-    env['MUSCLE_MANAGER'] = mmp_server_process_simple
-
-    cpp_test_dir = cpp_build_dir / 'libmuscle' / 'tests'
-    mpi_test_micro = cpp_test_dir / 'mpi_micro_model_test'
-    out_file = tmpdir + '/mpi_micro.log'
-    micro_result = subprocess.Popen(
-            ['mpirun', '-np', '2', mpirun_outfile_arg, out_file,
-             str(mpi_test_micro), '--muscle-instance=micro'], env=env)
-
-    # run macro model
-    macro_process = mp.Process(target=run_macro,
-                               args=('macro', mmp_server_process_simple))
-    macro_process.start()
-
-    # check results
-    micro_result.wait()
-    assert micro_result.returncode == 0
-    macro_process.join()
-    assert macro_process.exitcode == 0
+@skip_if_no_mpi_cpp
+def test_mpi_macro_micro(tmpdir, mmp_server_config_simple):
+    actors = {
+            'macro': ('python', macro),
+            'micro': ('mpicpp', 'mpi_micro_model_test', '2')}  # 2 processes
+    run_manager_with_actors(mmp_server_config_simple, tmpdir, actors)
diff --git a/integration_test/test_multicast_cpp.py b/integration_test/test_multicast_cpp.py
index 7daa62d3..51c657e5 100644
--- a/integration_test/test_multicast_cpp.py
+++ b/integration_test/test_multicast_cpp.py
@@ -1,5 +1,3 @@
-from pathlib import Path
-
 import ymmsl
 
 from libmuscle import Instance
@@ -38,6 +36,6 @@ def test_multicast_cpp(tmp_path):
     - receiver1.in
     - receiver2.in""",
         tmp_path,
-        {'multicast': Path('libmuscle') / 'tests' / 'component_test'},
-        {},
-        {'receiver1': receiver, 'receiver2': receiver})
+        {'multicast': ('cpp', 'component_test'),
+         'receiver1': ('python', receiver),
+         'receiver2': ('python', receiver)})
diff --git a/integration_test/test_snapshot_complex_coupling.py b/integration_test/test_snapshot_complex_coupling.py
index 55bb2b81..d7c05ad3 100644
--- a/integration_test/test_snapshot_complex_coupling.py
+++ b/integration_test/test_snapshot_complex_coupling.py
@@ -161,15 +161,14 @@ def config():
 
 
 def test_snapshot_complex_coupling(tmp_path, config):
-    actors = {'main': main_component}
+    actors = {'main': ('python', main_component)}
     for c in 'ABC':
-        actors['cache' + c] = cache_component
+        actors['cache' + c] = ('python', cache_component)
     for c in 'ABCD':
-        actors['calc' + c] = echo_component
+        actors['calc' + c] = ('python', echo_component)
 
     run_dir1 = RunDir(tmp_path / 'run1')
-    run_manager_with_actors(
-            dump(config), run_dir1.path, python_actors=actors)
+    run_manager_with_actors(dump(config), run_dir1.path, actors)
 
     assert len(ls_snapshots(run_dir1, 'main')) == 5  # 2.0/0.5, at_end
     assert len(ls_snapshots(run_dir1, 'cacheA')) == 5  # 2.0/0.5, at_end
diff --git a/integration_test/test_snapshot_dispatch.py b/integration_test/test_snapshot_dispatch.py
index d9082617..8e6dcea7 100644
--- a/integration_test/test_snapshot_dispatch.py
+++ b/integration_test/test_snapshot_dispatch.py
@@ -1,8 +1,7 @@
 import pytest
 from ymmsl import Operator, load, dump
 
-from libmuscle import (
-        Instance, Message, KEEPS_NO_STATE_FOR_NEXT_USE, USES_CHECKPOINT_API)
+from libmuscle import Instance, Message, USES_CHECKPOINT_API
 from libmuscle.manager.run_dir import RunDir
 
 from .conftest import run_manager_with_actors, ls_snapshots
@@ -44,28 +43,6 @@ def component():
             instance.save_final_snapshot(Message(t_cur, data=[i, t_stop]))
 
 
-def stateless_component():
-    instance = Instance({
-            Operator.F_INIT: ['f_i'],
-            Operator.O_F: ['o_f']},
-            KEEPS_NO_STATE_FOR_NEXT_USE)
-
-    while instance.reuse_instance():
-        dt = instance.get_setting('dt', 'float')
-        t_max = instance.get_setting('t_max', 'float')
-
-        msg = instance.receive('f_i', default=Message(0, data=0))
-        t_cur = msg.timestamp
-        i = msg.data
-        t_stop = t_cur + t_max
-
-        while t_cur < t_stop:
-            # faux time-integration for testing snapshots
-            t_cur += dt
-
-        instance.send('o_f', Message(t_cur, data=i))
-
-
 @pytest.fixture
 def dispatch_config():
     return load(f"""ymmsl_version: v0.1
@@ -96,10 +73,9 @@ def dispatch_config():
 
 
 def test_snapshot_dispatch(tmp_path, dispatch_config):
-    actors = {f'comp{i + 1}': component for i in range(5)}
+    actors = {f'comp{i + 1}': ('python', component) for i in range(5)}
     run_dir1 = RunDir(tmp_path / 'run1')
-    run_manager_with_actors(
-            dump(dispatch_config), run_dir1.path, python_actors=actors)
+    run_manager_with_actors(dump(dispatch_config), run_dir1.path, actors)
 
     assert len(ls_snapshots(run_dir1, 'comp1')) == 2  # t=0, at_end
     assert len(ls_snapshots(run_dir1, 'comp2')) == 5  # t=0, 2.5, 2.3, 2.8, at_end
@@ -119,8 +95,7 @@ def test_snapshot_dispatch(tmp_path, dispatch_config):
         'comp1': ls_snapshots(run_dir1, 'comp1')[1],
         'comp2': ls_snapshots(run_dir1, 'comp2')[1]}
 
-    run_manager_with_actors(
-            dump(dispatch_config), run_dir2.path, python_actors=actors)
+    run_manager_with_actors(dump(dispatch_config), run_dir2.path, actors)
 
     assert len(ls_snapshots(run_dir2, 'comp1')) == 1  # resume
     assert len(ls_snapshots(run_dir2, 'comp2')) == 4  # resume, t=2.5, 2.8, at_end
diff --git a/integration_test/test_snapshot_interact.py b/integration_test/test_snapshot_interact.py
index 34ca0e8a..dca2f16c 100644
--- a/integration_test/test_snapshot_interact.py
+++ b/integration_test/test_snapshot_interact.py
@@ -86,10 +86,10 @@ def test_snapshot_interact_lockstep(tmp_path):
     stop: 2.0
   - at:
     - 2.5"""
-    actors = {f'comp{i + 1}': component for i in range(2)}
+    actors = {f'comp{i + 1}': ('python', component) for i in range(2)}
 
     run_dir1 = RunDir(tmp_path / 'run1')
-    run_manager_with_actors(config, run_dir1.path, python_actors=actors)
+    run_manager_with_actors(config, run_dir1.path, actors)
 
     assert len(ls_snapshots(run_dir1, 'comp1')) == 3  # t=0.75, 1.75, 2.5
     assert len(ls_snapshots(run_dir1, 'comp2')) == 3  # t=0.75, 1.75, 2.5
@@ -103,8 +103,7 @@ def test_snapshot_interact_lockstep(tmp_path):
     config_doc = load(config)
     config_doc.update(snapshot_docs[1])
 
-    run_manager_with_actors(
-            dump(config_doc), run_dir2.path, python_actors=actors)
+    run_manager_with_actors(dump(config_doc), run_dir2.path, actors)
 
     assert len(ls_snapshots(run_dir2, 'comp1')) == 2  # resume, t=2.5
     assert len(ls_snapshots(run_dir2, 'comp2')) == 2  # resume, t=2.5
@@ -138,11 +137,11 @@ def test_snapshot_interact_varstep(tmp_path, scale):
     stop: 2.0
   - at:
     - 2.5"""
-    actors = {f'comp{i + 1}': component for i in range(2)}
-    actors['coupler'] = interact_coupling.checkpointing_temporal_coupler
+    actors = {f'comp{i + 1}': ('python', component) for i in range(2)}
+    actors['coupler'] = ('python', interact_coupling.checkpointing_temporal_coupler)
 
     run_dir1 = RunDir(tmp_path / 'run1')
-    run_manager_with_actors(config, run_dir1.path, python_actors=actors)
+    run_manager_with_actors(config, run_dir1.path, actors)
 
     assert len(ls_snapshots(run_dir1, 'comp1')) == 3  # t=0.75, 1.75, 2.5
     assert len(ls_snapshots(run_dir1, 'comp2')) == 3  # t=0.75, 1.75, 2.5
@@ -156,8 +155,7 @@ def test_snapshot_interact_varstep(tmp_path, scale):
     config_doc = load(config)
     config_doc.update(snapshot_docs[1])
 
-    run_manager_with_actors(
-            dump(config_doc), run_dir2.path, python_actors=actors)
+    run_manager_with_actors(dump(config_doc), run_dir2.path, actors)
 
     assert len(ls_snapshots(run_dir2, 'comp1')) == 2  # resume, t=2.5
     assert len(ls_snapshots(run_dir2, 'comp2')) == 2  # resume, t=2.5
diff --git a/integration_test/test_snapshot_macro_micro.py b/integration_test/test_snapshot_macro_micro.py
index 9d8481c2..b619f0b9 100644
--- a/integration_test/test_snapshot_macro_micro.py
+++ b/integration_test/test_snapshot_macro_micro.py
@@ -5,7 +5,8 @@
         Instance, Message, KEEPS_NO_STATE_FOR_NEXT_USE, USES_CHECKPOINT_API)
 from libmuscle.manager.run_dir import RunDir
 
-from .conftest import run_manager_with_actors, ls_snapshots
+from .conftest import (
+        run_manager_with_actors, ls_snapshots, skip_if_python_only, skip_if_no_mpi_cpp)
 
 
 _LOG_LEVEL = 'INFO'  # set to DEBUG for additional debug info
@@ -197,11 +198,23 @@ def config_with_transformer(base_config):
     return base_config
 
 
-def test_snapshot_macro_micro(tmp_path, base_config):
-    actors = {'macro': macro, 'micro': micro}
+@pytest.mark.parametrize('actors', [
+    {'macro': ('python', macro), 'micro': ('python', micro)},
+    pytest.param(
+        {'macro': ('cpp', 'snapshot_components_test', 'macro'),
+         'micro': ('cpp', 'snapshot_components_test', 'micro')},
+        marks=skip_if_python_only),
+    pytest.param(
+        {'macro': ('fortran', 'fortran_snapshot_macro_test'),
+         'micro': ('fortran', 'fortran_snapshot_micro_test')},
+        marks=skip_if_python_only),
+    pytest.param(
+        {'macro': ('python', macro), 'micro': ('cpp', 'mpi_snapshot_micro_test', '2')},
+        marks=[skip_if_python_only, skip_if_no_mpi_cpp])
+])
+def test_snapshot_macro_micro(tmp_path, base_config, actors):
     run_dir1 = RunDir(tmp_path / 'run1')
-    run_manager_with_actors(
-            dump(base_config), run_dir1.path, python_actors=actors)
+    run_manager_with_actors(dump(base_config), run_dir1.path, actors)
 
     macro_snapshots = ls_snapshots(run_dir1, 'macro')
     assert len(macro_snapshots) == 6  # 0, 0.4, 0.8, 1.2, 1.6, final
@@ -221,8 +234,7 @@ def test_snapshot_macro_micro(tmp_path, base_config):
     # resume from the snapshots taken at t>=1.2
     run_dir2 = RunDir(tmp_path / 'run2')
     base_config.update(snapshot_docs[4])  # add resume info
-    run_manager_with_actors(
-            dump(base_config), run_dir2.path, python_actors=actors)
+    run_manager_with_actors(dump(base_config), run_dir2.path, actors)
 
     assert len(ls_snapshots(run_dir2, 'macro')) == 3  # resume, 1.6, final
     assert len(ls_snapshots(run_dir2, 'micro')) == 3  # resume, 1.6, final
@@ -233,15 +245,22 @@ def test_snapshot_macro_micro(tmp_path, base_config):
     base_config.resume = {}                     # clear resume information
     base_config.update(snapshot_docs[0])        # add resume info
     base_config.settings['macro.t_max'] = 0.6   # run shorter
-    run_manager_with_actors(
-            dump(base_config), run_dir3.path, python_actors=actors)
-
-
-def test_snapshot_macro_stateless_micro(tmp_path, base_config):
-    actors = {'macro': macro, 'micro': stateless_micro}
+    run_manager_with_actors(dump(base_config), run_dir3.path, actors)
+
+
+@pytest.mark.parametrize('micro_actor', [
+    ('python', stateless_micro),
+    pytest.param(
+        ('cpp', 'snapshot_components_test', 'stateless_micro'),
+        marks=skip_if_python_only),
+    pytest.param(
+        ('fortran', 'fortran_snapshot_stateless_micro_test'),
+        marks=skip_if_python_only),
+])
+def test_snapshot_macro_stateless_micro(tmp_path, base_config, micro_actor):
+    actors = {'macro': ('python', macro), 'micro': micro_actor}
     run_dir1 = RunDir(tmp_path / 'run1')
-    run_manager_with_actors(
-            dump(base_config), run_dir1.path, python_actors=actors)
+    run_manager_with_actors(dump(base_config), run_dir1.path, actors)
 
     assert len(ls_snapshots(run_dir1, 'macro')) == 6  # 0, 0.4, 0.8, 1.2, 1.6, final
     assert len(ls_snapshots(run_dir1, 'micro')) == 6  # 0, 0.4, 0.8, 1.2, 1.6, final
@@ -252,21 +271,30 @@ def test_snapshot_macro_stateless_micro(tmp_path, base_config):
     # resume from the snapshot taken at t>=1.2
     run_dir2 = RunDir(tmp_path / 'run2')
     base_config.update(snapshot_docs[3])  # add resume info
-    run_manager_with_actors(
-            dump(base_config), run_dir2.path, python_actors=actors)
+    run_manager_with_actors(dump(base_config), run_dir2.path, actors)
 
     assert len(ls_snapshots(run_dir2, 'macro')) == 3  # resume, 1.6, final
     assert len(ls_snapshots(run_dir2, 'micro')) == 4  # resume, 1.2, 1.6, final
     assert len(ls_snapshots(run_dir2)) == 3
 
 
-def test_snapshot_macro_vector_micro(tmp_path, base_config):
+@pytest.mark.parametrize('macro_actor', [
+    ('python', macro_vector),
+    pytest.param(
+        ('cpp', 'snapshot_components_test', 'macro_vector'),
+        marks=skip_if_python_only),
+    pytest.param(
+        ('fortran', 'fortran_snapshot_macro_vector_test'),
+        marks=skip_if_python_only),
+])
+def test_snapshot_macro_vector_micro(tmp_path, base_config, macro_actor):
     base_config.model.components[1].multiplicity = [2]
-    actors = {'macro': macro_vector, 'micro[0]': micro, 'micro[1]': micro}
+    actors = {'macro': macro_actor,
+              'micro[0]': ('python', micro),
+              'micro[1]': ('python', micro)}
 
     run_dir1 = RunDir(tmp_path / 'run1')
-    run_manager_with_actors(
-            dump(base_config), run_dir1.path, python_actors=actors)
+    run_manager_with_actors(dump(base_config), run_dir1.path, actors)
 
     assert len(ls_snapshots(run_dir1, 'macro')) == 6  # 0, 0.4, 0.8, 1.2, 1.6, final
     assert len(ls_snapshots(run_dir1, 'micro[0]')) == 6  # 0, 0.4, 0.8, 1.2, 1.6, final
@@ -276,8 +304,7 @@ def test_snapshot_macro_vector_micro(tmp_path, base_config):
 
     run_dir2 = RunDir(tmp_path / 'run2')
     base_config.update(load(snapshots_ymmsl[-3]))  # add resume info
-    run_manager_with_actors(
-            dump(base_config), run_dir2.path, python_actors=actors)
+    run_manager_with_actors(dump(base_config), run_dir2.path, actors)
 
     assert len(ls_snapshots(run_dir2, 'macro')) == 3  # resume, 1.6, final
     assert len(ls_snapshots(run_dir2, 'micro[0]')) == 3  # resume, 1.6, final
@@ -286,12 +313,13 @@ def test_snapshot_macro_vector_micro(tmp_path, base_config):
 
 
 def test_snapshot_macro_transformer_micro(tmp_path, config_with_transformer):
-    actors = {'macro': macro, 'micro': micro, 'transformer1': data_transformer,
-              'transformer2': data_transformer}
+    actors = {'macro': ('python', macro),
+              'micro': ('python', micro),
+              'transformer1': ('python', data_transformer),
+              'transformer2': ('python', data_transformer)}
 
     run_dir1 = RunDir(tmp_path / 'run1')
-    run_manager_with_actors(
-            dump(config_with_transformer), run_dir1.path, python_actors=actors)
+    run_manager_with_actors(dump(config_with_transformer), run_dir1.path, actors)
 
     snapshots_ymmsl = ls_snapshots(run_dir1)
     assert len(snapshots_ymmsl) == 8
@@ -299,8 +327,7 @@ def test_snapshot_macro_transformer_micro(tmp_path, config_with_transformer):
     # pick one to resume from
     run_dir2 = RunDir(tmp_path / 'run2')
     config_with_transformer.update(load(snapshots_ymmsl[4]))  # add resume info
-    run_manager_with_actors(
-            dump(config_with_transformer), run_dir2.path, python_actors=actors)
+    run_manager_with_actors(dump(config_with_transformer), run_dir2.path, actors)
 
     snapshots_ymmsl = ls_snapshots(run_dir2)
     assert len(snapshots_ymmsl) == 6
diff --git a/integration_test/test_start_mpi.py b/integration_test/test_start_mpi.py
index 2ba3d522..38dae114 100644
--- a/integration_test/test_start_mpi.py
+++ b/integration_test/test_start_mpi.py
@@ -1,21 +1,16 @@
-import os
 from pathlib import Path
 
-import pytest
 import ymmsl
 
 from libmuscle.manager.manager import Manager
 from libmuscle.manager.run_dir import RunDir
 
-from .conftest import skip_if_python_only
+from .conftest import skip_if_python_only, skip_if_no_mpi_cpp
 
 
 @skip_if_python_only
+@skip_if_no_mpi_cpp
 def test_start_mpi(tmpdir, mpi_exec_model):
-    # only run this if MPI is enabled
-    if 'MUSCLE_ENABLE_CPP_MPI' not in os.environ:
-        pytest.skip('MPI support was not detected')
-
     tmppath = Path(str(tmpdir))
 
     # find our test components and their requirements
diff --git a/integration_test/test_start_script.py b/integration_test/test_start_script.py
index b5688e6d..7cda6c5a 100644
--- a/integration_test/test_start_script.py
+++ b/integration_test/test_start_script.py
@@ -1,21 +1,16 @@
-import os
 from pathlib import Path
 
-import pytest
 import ymmsl
 
 from libmuscle.manager.manager import Manager
 from libmuscle.manager.run_dir import RunDir
 
-from .conftest import skip_if_python_only
+from .conftest import skip_if_python_only, skip_if_no_mpi_cpp
 
 
 @skip_if_python_only
+@skip_if_no_mpi_cpp
 def test_start_script(tmpdir):
-    # only run this if MPI is enabled
-    if 'MUSCLE_ENABLE_CPP_MPI' not in os.environ:
-        pytest.skip('MPI support was not detected')
-
     tmppath = Path(str(tmpdir))
 
     # find our test components and their requirements
diff --git a/libmuscle/cpp/build/libmuscle/tests/Makefile b/libmuscle/cpp/build/libmuscle/tests/Makefile
index 327b4ddb..2dea6170 100644
--- a/libmuscle/cpp/build/libmuscle/tests/Makefile
+++ b/libmuscle/cpp/build/libmuscle/tests/Makefile
@@ -97,6 +97,9 @@ mpi_micro_model_test: mpi_micro_model_test.cpp $(CURDIR)/../../ymmsl/libymmsl_d.
 mpi_component_test: mpi_component_test.cpp $(CURDIR)/../../ymmsl/libymmsl_d.a $(CURDIR)/../libmuscle_mpi_d.a
 	$(MPICXX) $(CXXFLAGS) $(DEBUGFLAGS) -I$(CURDIR)/.. $(MPIFLAGS) $^ -o $@ $(LDFLAGS2)
 
+mpi_snapshot_micro_test: mpi_snapshot_micro_test.cpp $(CURDIR)/../../ymmsl/libymmsl_d.a $(CURDIR)/../libmuscle_mpi_d.a
+	$(MPICXX) $(CXXFLAGS) $(DEBUGFLAGS) -I$(CURDIR)/.. $(MPIFLAGS) $^ -o $@ $(LDFLAGS2)
+
 
 empty :=
 space := $(empty) $(empty)
diff --git a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_communicator.cpp b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_communicator.cpp
index a34d3df1..acc20d84 100644
--- a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_communicator.cpp
+++ b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_communicator.cpp
@@ -99,6 +99,16 @@ void MockCommunicator::shutdown() {
 }
 
 
+MockCommunicator::PortMessageCounts MockCommunicator::get_message_counts() {
+    return get_message_counts_return_value;
+}
+
+void MockCommunicator::restore_message_counts(
+        PortMessageCounts const & port_message_counts){
+    last_restored_message_counts = port_message_counts;
+}
+
+
 void MockCommunicator::reset() {
     num_constructed = 0;
     settings_in_connected_return_value = false;
@@ -109,6 +119,8 @@ void MockCommunicator::reset() {
     last_sent_port = "";
     last_sent_message = Message(0.0);
     last_sent_slot = {};
+    get_message_counts_return_value.clear();
+    last_restored_message_counts = {};
 }
 
 int MockCommunicator::num_constructed = 0;
@@ -130,5 +142,9 @@ Message MockCommunicator::last_sent_message(0.0);
 
 Optional<int> MockCommunicator::last_sent_slot;
 
+MockCommunicator::PortMessageCounts MockCommunicator::get_message_counts_return_value;
+
+MockCommunicator::PortMessageCounts MockCommunicator::last_restored_message_counts;
+
 } }
 
diff --git a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_communicator.hpp b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_communicator.hpp
index 32d47bee..d1274eb4 100644
--- a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_communicator.hpp
+++ b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_communicator.hpp
@@ -23,6 +23,8 @@ using PortsDescription = std::unordered_map<ymmsl::Operator, std::vector<std::st
 
 class MockCommunicator {
     public:
+        using PortMessageCounts = std::unordered_map<std::string, std::vector<int>>;
+
         MockCommunicator(
                 ymmsl::Reference const & kernel,
                 std::vector<int> const & index,
@@ -61,6 +63,10 @@ class MockCommunicator {
 
         void shutdown();
 
+        PortMessageCounts get_message_counts();
+
+        void restore_message_counts(PortMessageCounts const & port_message_counts);
+
         static void reset();
         static int num_constructed;
         static bool settings_in_connected_return_value;
@@ -72,6 +78,8 @@ class MockCommunicator {
         static std::string last_sent_port;
         static Message last_sent_message;
         static Optional<int> last_sent_slot;
+        static PortMessageCounts get_message_counts_return_value;
+        static PortMessageCounts last_restored_message_counts;
 
     private:
         friend class TestCommunicator;
diff --git a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mmp_client.cpp b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mmp_client.cpp
index e0edfd8f..76bf67e6 100644
--- a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mmp_client.cpp
+++ b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mmp_client.cpp
@@ -27,6 +27,12 @@ void MockMMPClient::submit_profile_events(std::vector<ProfileEvent> const & even
     last_submitted_profile_events = event;
 }
 
+void MockMMPClient::submit_snapshot_metadata(
+        SnapshotMetadata const & snapshot_metadata)
+{
+    last_submitted_snapshot_metadata = snapshot_metadata;
+}
+
 void MockMMPClient::register_instance(
         std::vector<std::string> const & locations,
         std::vector<::ymmsl::Port> const & ports)
@@ -42,6 +48,25 @@ ymmsl::Settings MockMMPClient::get_settings() {
     return settings;
 }
 
+auto MockMMPClient::get_checkpoint_info() ->
+            std::tuple<
+                double,
+                DataConstRef,
+                Optional<std::string>,
+                Optional<std::string>
+            >
+{
+    return {
+            0.1,
+            // no checkpoints defined:
+            Data::dict(
+                    "at_end", false,
+                    "wallclock_time", Data::list(),
+                    "simulation_time", Data::list()),
+            {},
+            {}};
+}
+
 auto MockMMPClient::request_peers() ->
         std::tuple<
             std::vector<::ymmsl::Conduit>,
@@ -90,5 +115,7 @@ LogMessage MockMMPClient::last_submitted_log_message(
 
 std::vector<ProfileEvent> MockMMPClient::last_submitted_profile_events;
 
+Optional<SnapshotMetadata> MockMMPClient::last_submitted_snapshot_metadata;
+
 } }
 
diff --git a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mmp_client.hpp b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mmp_client.hpp
index 4930d8af..158ec021 100644
--- a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mmp_client.hpp
+++ b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mmp_client.hpp
@@ -7,7 +7,9 @@
 
 #include <libmuscle/logging.hpp>
 #include <libmuscle/profiling.hpp>
+#include <libmuscle/snapshot.hpp>
 #include <libmuscle/timestamp.hpp>
+#include <libmuscle/util.hpp>
 
 #include <ymmsl/ymmsl.hpp>
 
@@ -25,8 +27,18 @@ class MockMMPClient {
 
         void submit_profile_events(std::vector<ProfileEvent> const & event);
 
+        void submit_snapshot_metadata(SnapshotMetadata const & snapshot_metadata);
+
         ymmsl::Settings get_settings();
 
+        auto get_checkpoint_info() ->
+            std::tuple<
+                double,
+                DataConstRef,
+                Optional<std::string>,
+                Optional<std::string>
+            >;
+
         void register_instance(
                 std::vector<std::string> const & locations,
                 std::vector<::ymmsl::Port> const & ports);
@@ -49,6 +61,7 @@ class MockMMPClient {
         static std::vector<::ymmsl::Port> last_registered_ports;
         static LogMessage last_submitted_log_message;
         static std::vector<ProfileEvent> last_submitted_profile_events;
+        static Optional<SnapshotMetadata> last_submitted_snapshot_metadata;
 };
 
 using MMPClient = MockMMPClient;
diff --git a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mpp_client.cpp b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mpp_client.cpp
index f1d7ba9a..d5848422 100644
--- a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mpp_client.cpp
+++ b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mpp_client.cpp
@@ -22,10 +22,12 @@ std::tuple<DataConstRef, ProfileData> MockMPPClient::receive(
         ::ymmsl::Reference const & receiver) {
     last_receiver = receiver;
 
-    return std::make_tuple(
+    auto retval = std::make_tuple(
             next_receive_message.encoded(), std::make_tuple(
                 ProfileTimestamp(1.0), ProfileTimestamp(2.0),
                 ProfileTimestamp(3.0)));
+    side_effect();
+    return retval;
 }
 
 void MockMPPClient::close() {}
@@ -38,7 +40,9 @@ void MockMPPClient::reset() {
     next_receive_message.port_length = 0;
     next_receive_message.timestamp = 0.0;
     next_receive_message.next_timestamp = 1.0;
+    next_receive_message.message_number = 0;
     last_receiver = "_none";
+    side_effect = [](){};  // empty lambda function
 }
 
 int MockMPPClient::num_constructed = 0;
@@ -50,10 +54,12 @@ Settings MockMPPClient::make_overlay_() {
 }
 
 MPPMessage MockMPPClient::next_receive_message(
-        "test.out", "test2.in", 0, 0.0, 1.0, make_overlay_(),0, 9.0,
+        "test.out", "test2.in", 0, 0.0, 1.0, make_overlay_(), 0, 9.0,
         Data::dict("test1", 12));
 
 Reference MockMPPClient::last_receiver("_none");
 
+std::function<void()> MockMPPClient::side_effect;
+
 } }
 
diff --git a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mpp_client.hpp b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mpp_client.hpp
index f2ab5815..6c4875cb 100644
--- a/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mpp_client.hpp
+++ b/libmuscle/cpp/src/libmuscle/tests/mocks/mock_mpp_client.hpp
@@ -6,6 +6,7 @@
 
 #include <ymmsl/ymmsl.hpp>
 
+#include <functional>
 #include <string>
 #include <tuple>
 #include <vector>
@@ -38,6 +39,8 @@ class MockMPPClient {
         static int num_constructed;
         static MPPMessage next_receive_message;
         static ::ymmsl::Reference last_receiver;
+        // Called after a mocked receive
+        static std::function<void()> side_effect;
 
     private:
         static ::ymmsl::Settings make_overlay_();
diff --git a/libmuscle/cpp/src/libmuscle/tests/mpi_snapshot_micro_test.cpp b/libmuscle/cpp/src/libmuscle/tests/mpi_snapshot_micro_test.cpp
new file mode 100644
index 00000000..bc2bcfc8
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/tests/mpi_snapshot_micro_test.cpp
@@ -0,0 +1,104 @@
+/* This is a part of the integration test suite, and is run from a Python
+ * test in /integration_test. It is not a unit test.
+ */
+#include <cassert>
+
+#include <libmuscle/libmuscle.hpp>
+#include <ymmsl/ymmsl.hpp>
+
+#include <mpi.h>
+
+
+using libmuscle::Data;
+using libmuscle::DataConstRef;
+using libmuscle::Instance;
+using libmuscle::InstanceFlags;
+using libmuscle::Message;
+using ymmsl::Operator;
+
+
+void mpi_micro(int argc, char * argv[]) {
+    MPI_Init(&argc, &argv);
+    int rank, num_ranks;
+    MPI_Comm_size(MPI_COMM_WORLD, &num_ranks);
+    MPI_Comm_rank(MPI_COMM_WORLD, &rank);
+
+    Instance instance(
+            argc, argv, {
+                {Operator::F_INIT, {"f_i"}},
+                {Operator::O_F, {"o_f"}}},
+            InstanceFlags::USES_CHECKPOINT_API,
+            MPI_COMM_WORLD, 0);
+
+    while (instance.reuse_instance()) {
+        double dt = instance.get_setting_as<double>("dt");
+        double t_max = instance.get_setting_as<double>("t_max");
+        double t_cur, t_stop;
+        int i;
+
+        if (instance.resuming()) {
+            if (rank == 0) {
+                auto msg = instance.load_snapshot();
+                // load state from message
+                t_cur = msg.timestamp();
+                i = msg.data()[0].as<int>();
+                t_stop = msg.data()[1].as<double>();
+                // and broadcast
+                MPI_Bcast(&t_cur, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
+                MPI_Bcast(&i, 1, MPI_INT, 0, MPI_COMM_WORLD);
+                MPI_Bcast(&t_stop, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
+            } else {
+                MPI_Bcast(&t_cur, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
+                MPI_Bcast(&i, 1, MPI_INT, 0, MPI_COMM_WORLD);
+                MPI_Bcast(&t_stop, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
+            }
+        }
+
+        if (instance.should_init()) {
+            if (rank == 0) {
+                auto msg = instance.receive("f_i");
+                t_cur = msg.timestamp();
+                i = msg.data().as<int>();
+                t_stop = t_cur + t_max;
+                MPI_Bcast(&t_cur, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
+                MPI_Bcast(&i, 1, MPI_INT, 0, MPI_COMM_WORLD);
+                MPI_Bcast(&t_stop, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
+            } else {
+                MPI_Bcast(&t_cur, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
+                MPI_Bcast(&i, 1, MPI_INT, 0, MPI_COMM_WORLD);
+                MPI_Bcast(&t_stop, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
+            }
+        }
+
+        while (t_cur <= t_stop) {
+            // faux time-integration for testing snapshots
+            t_cur += dt;
+
+            if (instance.should_save_snapshot(t_cur)) {
+                // [Optional] collectively gather state
+                if (rank == 0) {
+                    // Only root can save
+                    instance.save_snapshot(Message(t_cur, Data::list(i, t_stop)));
+                }
+            }
+        }
+
+        // Message is only sent from root process
+        instance.send("o_f", Message(t_cur, i));
+
+        if (instance.should_save_final_snapshot()) {
+            // [Optional] collectively gather state
+            if (rank == 0) {
+                // Only root can save
+                instance.save_final_snapshot(Message(t_cur, Data::list(i, t_stop)));
+            }
+        }
+    }
+}
+
+
+int main(int argc, char * argv[]) {
+    mpi_micro(argc, argv);
+    return EXIT_SUCCESS;
+}
+
diff --git a/libmuscle/cpp/src/libmuscle/tests/snapshot_components_test.cpp b/libmuscle/cpp/src/libmuscle/tests/snapshot_components_test.cpp
new file mode 100644
index 00000000..72d0b65f
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/tests/snapshot_components_test.cpp
@@ -0,0 +1,220 @@
+/* This is a part of the integration test suite, and is run from a Python
+ * test in /integration_test. It is not a unit test.
+ *
+ * This is a collection of actors used in test_snapshot_macro_micro.py
+ */
+#include <cassert>
+#include <iostream>
+#include <string>
+
+#include <libmuscle/libmuscle.hpp>
+#include <ymmsl/ymmsl.hpp>
+
+using namespace std::string_literals;
+
+using libmuscle::Data;
+using libmuscle::DataConstRef;
+using libmuscle::Instance;
+using libmuscle::InstanceFlags;
+using libmuscle::Message;
+using ymmsl::Operator;
+
+
+/** A simple snapshotting macro component used in
+ * integration_tests/test_snapshot_macro_micro.py
+ */
+void macro(int argc, char * argv[]) {
+    Instance instance(
+            argc, argv, {
+                {Operator::O_I, {"o_i"}},
+                {Operator::S, {"s"}}},
+            InstanceFlags::USES_CHECKPOINT_API);
+
+    while (instance.reuse_instance()) {
+        double dt = instance.get_setting_as<double>("dt");
+        double t_max = instance.get_setting_as<double>("t_max");
+        double t_cur;
+        int i;
+
+        if (instance.resuming()) {
+            auto msg = instance.load_snapshot();
+            // load state from message
+            t_cur = msg.timestamp();
+            i = msg.data().as<int>();
+            assert(i >= 1);
+        }
+
+        if (instance.should_init()) {
+            t_cur = instance.get_setting_as<double>("t0");
+            i = 0;
+        }
+
+        while (t_cur + dt <= t_max) {
+            Message msg(t_cur, i);
+            double t_next = t_cur + dt;
+            if (t_next + dt <= t_max)
+                msg.set_next_timestamp(t_next);
+            instance.send("o_i", msg);
+
+            msg = instance.receive("s");
+            assert(msg.data().as<int>() == i);
+
+            i ++;
+            t_cur += dt;
+
+            if (instance.should_save_snapshot(t_cur))
+                instance.save_snapshot(Message(t_cur, i));
+        }
+
+        if (instance.should_save_final_snapshot())
+            instance.save_final_snapshot(Message(t_cur, i));
+    }
+}
+
+
+void macro_vector(int argc, char * argv[]) {
+    Instance instance(
+            argc, argv, {
+                {Operator::O_I, {"o_i[]"}},
+                {Operator::S, {"s[]"}}},
+            InstanceFlags::USES_CHECKPOINT_API);
+
+    while (instance.reuse_instance()) {
+        double dt = instance.get_setting_as<double>("dt");
+        double t_max = instance.get_setting_as<double>("t_max");
+        double t_cur;
+        int i;
+
+        if (instance.resuming()) {
+            auto msg = instance.load_snapshot();
+            // load state from message
+            t_cur = msg.timestamp();
+            i = msg.data().as<int>();
+            assert(i >= 1);
+        }
+
+        if (instance.should_init()) {
+            t_cur = instance.get_setting_as<double>("t0");
+            i = 0;
+        }
+
+        while (t_cur + dt <= t_max) {
+            Message msg(t_cur, i);
+            double t_next = t_cur + dt;
+            if (t_next + dt <= t_max)
+                msg.set_next_timestamp(t_next);
+            for (int slot = 0; slot < instance.get_port_length("o_i"); ++slot)
+                instance.send("o_i", msg, slot);
+
+            for (int slot = 0; slot < instance.get_port_length("s"); ++slot) {
+                msg = instance.receive("s", slot);
+                assert(msg.data().as<int>() == i);
+            }
+
+            i ++;
+            t_cur += dt;
+
+            if (instance.should_save_snapshot(t_cur))
+                instance.save_snapshot(Message(t_cur, i));
+        }
+
+        if (instance.should_save_final_snapshot())
+            instance.save_final_snapshot(Message(t_cur, i));
+    }
+}
+
+
+void micro(int argc, char * argv[]) {
+    Instance instance(
+            argc, argv, {
+                {Operator::F_INIT, {"f_i"}},
+                {Operator::O_F, {"o_f"}}},
+            InstanceFlags::USES_CHECKPOINT_API);
+
+    while (instance.reuse_instance()) {
+        double dt = instance.get_setting_as<double>("dt");
+        double t_max = instance.get_setting_as<double>("t_max");
+        double t_cur, t_stop;
+        int i;
+
+        if (instance.resuming()) {
+            auto msg = instance.load_snapshot();
+            // load state from message
+            t_cur = msg.timestamp();
+            i = msg.data()[0].as<int>();
+            t_stop = msg.data()[1].as<double>();
+        }
+
+        if (instance.should_init()) {
+            auto msg = instance.receive("f_i");
+            t_cur = msg.timestamp();
+            i = msg.data().as<int>();
+            t_stop = t_cur + t_max;
+        }
+
+        while (t_cur <= t_stop) {
+            // faux time-integration for testing snapshots
+            t_cur += dt;
+
+            if (instance.should_save_snapshot(t_cur))
+                instance.save_snapshot(Message(t_cur, Data::list(i, t_stop)));
+        }
+
+        instance.send("o_f", Message(t_cur, i));
+
+        if (instance.should_save_final_snapshot())
+            instance.save_final_snapshot(Message(t_cur, Data::list(i, t_stop)));
+    }
+}
+
+
+void stateless_micro(int argc, char * argv[]) {
+    Instance instance(
+            argc, argv, {
+                {Operator::F_INIT, {"f_i"}},
+                {Operator::O_F, {"o_f"}}},
+            InstanceFlags::KEEPS_NO_STATE_FOR_NEXT_USE);
+
+    while (instance.reuse_instance()) {
+        double dt = instance.get_setting_as<double>("dt");
+        double t_max = instance.get_setting_as<double>("t_max");
+
+        auto msg = instance.receive("f_i");
+        auto t_cur = msg.timestamp();
+        auto i = msg.data().as<int>();
+        auto t_stop = t_cur + t_max;
+
+        while (t_cur <= t_stop) {
+            // faux time-integration for testing snapshots
+            t_cur += dt;
+        }
+
+        instance.send("o_f", Message(t_cur, i));
+    }
+}
+
+
+int main(int argc, char * argv[]) {
+    if (argc > 1) {
+        if (argv[1] == "macro"s) {
+            macro(argc, argv);
+            return EXIT_SUCCESS;
+        } else if (argv[1] == "macro_vector"s) {
+            macro_vector(argc, argv);
+            return EXIT_SUCCESS;
+        } else if (argv[1] == "micro"s) {
+            micro(argc, argv);
+            return EXIT_SUCCESS;
+        } else if (argv[1] == "stateless_micro"s) {
+            stateless_micro(argc, argv);
+            return EXIT_SUCCESS;
+        }
+        std::cerr << "Unknown component name: " << argv[1] << std::endl;
+    } else {
+        std::cerr << "No component name provided." << std::endl;
+    }
+    std::cerr << "Valid component names are: macro, macro_vector, micro";
+    std::cerr << " and stateless_micro" << std::endl;
+    return EXIT_FAILURE;
+}
+
diff --git a/libmuscle/cpp/src/libmuscle/tests/test_api_guard.cpp b/libmuscle/cpp/src/libmuscle/tests/test_api_guard.cpp
new file mode 100644
index 00000000..fce068bd
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/tests/test_api_guard.cpp
@@ -0,0 +1,211 @@
+#include <gtest/gtest.h>
+
+#include <libmuscle/api_guard.hpp>
+
+#include <vector>
+#include <set>
+
+using libmuscle::impl::APIGuard;
+using libmuscle::impl::APIPhase;
+
+int main(int argc, char *argv[]) {
+    ::testing::InitGoogleTest(&argc, argv);
+    return RUN_ALL_TESTS();
+}
+
+TEST(libmuscle_api_guard, test_no_checkpointing_support) {
+    auto guard = APIGuard(false, true);
+    for (int i=0; i<3; ++i) {
+        guard.verify_reuse_instance();
+        guard.reuse_instance_done(true);
+    }
+
+    guard.verify_reuse_instance();
+    guard.reuse_instance_done(false);
+}
+
+TEST(libmuscle_api_guard, test_final_snapshot_only) {
+    auto guard = APIGuard(true, true);
+    for (int i=0; i<4; ++i) {
+        guard.verify_reuse_instance();
+        guard.reuse_instance_done(true);
+
+        guard.verify_resuming();
+        if (i == 0) {
+            guard.resuming_done(true);
+
+            guard.verify_load_snapshot();
+            guard.load_snapshot_done();
+        } else {
+            guard.resuming_done(false);
+        }
+
+        guard.verify_should_init();
+        guard.should_init_done();
+
+        guard.verify_should_save_final_snapshot();
+        if (i == 2) {
+            guard.should_save_final_snapshot_done(true);
+
+            guard.verify_save_final_snapshot();
+            guard.save_final_snapshot_done();
+        } else {
+            guard.should_save_final_snapshot_done(false);
+        }
+    }
+
+    guard.verify_reuse_instance();
+    guard.reuse_instance_done(false);
+}
+
+
+TEST(libmuscle_api_guard, test_full_checkpointing) {
+    auto guard = APIGuard(true, true);
+    for (int i=0; i<4; ++i) {
+        guard.verify_reuse_instance();
+        guard.reuse_instance_done(true);
+
+        guard.verify_resuming();
+        if (i == 0) {
+            guard.resuming_done(true);
+
+            guard.verify_load_snapshot();
+            guard.load_snapshot_done();
+        } else {
+            guard.resuming_done(false);
+        }
+
+        guard.verify_should_init();
+        guard.should_init_done();
+
+        for (int j=0; j<3; ++j) {
+            guard.verify_should_save_snapshot();
+            if (j != 2) {
+                guard.should_save_snapshot_done(true);
+
+                guard.verify_save_snapshot();
+                guard.save_snapshot_done();
+            } else {
+                guard.should_save_snapshot_done(false);
+            }
+        }
+
+        guard.verify_should_save_final_snapshot();
+        if (i == 2) {
+            guard.should_save_final_snapshot_done(true);
+
+            guard.verify_save_final_snapshot();
+            guard.save_final_snapshot_done();
+        } else {
+            guard.should_save_final_snapshot_done(false);
+        }
+    }
+
+    guard.verify_reuse_instance();
+    guard.reuse_instance_done(false);
+}
+
+TEST(libmuscle_api_guard, test_non_root) {
+    auto guard = APIGuard(true, false);
+    guard.verify_reuse_instance();
+    guard.reuse_instance_done(true);
+    guard.verify_resuming();
+    guard.resuming_done(true);
+    ASSERT_THROW(guard.verify_load_snapshot(), std::runtime_error);
+    guard.verify_should_init();
+    guard.should_init_done();
+    guard.verify_should_save_snapshot();
+    guard.should_save_snapshot_done(true);
+    ASSERT_THROW(guard.verify_save_snapshot(), std::runtime_error);
+    guard.verify_should_save_final_snapshot();
+    guard.should_save_final_snapshot_done(true);
+    ASSERT_THROW(guard.verify_save_final_snapshot(), std::runtime_error);
+    guard.verify_reuse_instance();
+}
+
+static std::vector< std::function<void(APIGuard &)> > api_guard_funs_({
+    [](APIGuard & guard){ guard.verify_reuse_instance(); },                 //  0
+    [](APIGuard & guard){ guard.reuse_instance_done(true); },               //  1
+    [](APIGuard & guard){ guard.verify_resuming(); },                       //  2
+    [](APIGuard & guard){ guard.resuming_done(true); },                     //  3
+    [](APIGuard & guard){ guard.verify_load_snapshot(); },                  //  4
+    [](APIGuard & guard){ guard.load_snapshot_done(); },                    //  5
+    [](APIGuard & guard){ guard.verify_should_init(); },                    //  6
+    [](APIGuard & guard){ guard.should_init_done(); },                      //  7
+    [](APIGuard & guard){ guard.verify_should_save_snapshot(); },           //  8
+    [](APIGuard & guard){ guard.should_save_snapshot_done(true); },         //  9
+    [](APIGuard & guard){ guard.verify_save_snapshot(); },                  // 10
+    [](APIGuard & guard){ guard.save_snapshot_done(); },                    // 11
+    [](APIGuard & guard){ guard.verify_should_save_final_snapshot(); },     // 12
+    [](APIGuard & guard){ guard.should_save_final_snapshot_done(true); },   // 13
+    [](APIGuard & guard){ guard.verify_save_final_snapshot(); }             // 14
+});
+
+void run_until_before(APIGuard & guard, int fun) {
+    for (int i=0; i<fun; ++i) {
+        api_guard_funs_.at(i)(guard);
+    }
+}
+
+void check_all_raise_except(APIGuard & guard, std::set<int> excluded) {
+    for (uint i=0; i<api_guard_funs_.size(); i+=2) {
+        // only call the verify functions, which are the even-indexed ones
+        if (excluded.find(i) != excluded.end()) {
+            // no error expected from the excluded set
+            ASSERT_NO_THROW(api_guard_funs_.at(i)(guard));
+        } else {
+            ASSERT_THROW(api_guard_funs_.at(i)(guard), std::runtime_error);
+        }
+    }
+}
+
+TEST(libmuscle_api_guard, test_missing_resuming) {
+    auto guard = APIGuard(true, true);
+    run_until_before(guard, 2);  // 2 = verify_resuming
+    check_all_raise_except(guard, {2});
+}
+
+TEST(libmuscle_api_guard, test_missing_load_snapshot) {
+    auto guard = APIGuard(true, true);
+    run_until_before(guard, 4);  // 4 = verify_load_snapshot
+    check_all_raise_except(guard, {4});
+}
+
+TEST(libmuscle_api_guard, test_missing_should_init) {
+    auto guard = APIGuard(true, true);
+    run_until_before(guard, 6);  // 6 = verify_should_init
+    check_all_raise_except(guard, {6});
+}
+
+TEST(libmuscle_api_guard, test_missing_should_save) {
+    auto guard = APIGuard(true, true);
+    run_until_before(guard, 8);  // 8 = verify_should_save_snapshot
+    check_all_raise_except(guard, {8, 12});  // 12 = verify_should_save_final_snapshot
+}
+
+TEST(libmuscle_api_guard, test_missing_save_snapshot) {
+    auto guard = APIGuard(true, true);
+    run_until_before(guard, 10);  // 10 = verify_save_snapshot
+    check_all_raise_except(guard, {10});
+}
+
+TEST(libmuscle_api_guard, test_missing_should_save_final) {
+    auto guard = APIGuard(true, true);
+    run_until_before(guard, 12);  // 12 = verify_should_save_final_snapshot
+    check_all_raise_except(guard, {12, 8});  // 8 = verify_should_save_snapshot
+}
+
+TEST(libmuscle_api_guard, test_missing_save_final_snapshot) {
+    auto guard = APIGuard(true, true);
+    run_until_before(guard, 14);  // 14 = verify_save_final_snapshot
+    check_all_raise_except(guard, {14});
+}
+
+TEST(libmuscle_api_guard, test_double_should_save) {
+    auto guard = APIGuard(true, true);
+    run_until_before(guard, 8);  // 8 = verify_should_save_snapshot
+    guard.verify_should_save_snapshot();
+    guard.should_save_snapshot_done(true);
+    ASSERT_THROW(guard.verify_should_save_snapshot(), std::runtime_error);
+}
+
diff --git a/libmuscle/cpp/src/libmuscle/tests/test_checkpoint_triggers.cpp b/libmuscle/cpp/src/libmuscle/tests/test_checkpoint_triggers.cpp
new file mode 100644
index 00000000..9bd9c467
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/tests/test_checkpoint_triggers.cpp
@@ -0,0 +1,213 @@
+#include <gtest/gtest.h>
+
+#include <libmuscle/checkpoint_triggers.hpp>
+
+#include <libmuscle/mcp/data_pack.hpp>
+
+using libmuscle::impl::TriggerManager;
+using libmuscle::impl::AtCheckpointTrigger;
+using libmuscle::impl::RangeCheckpointTrigger;
+using libmuscle::impl::CombinedCheckpointTriggers;
+using libmuscle::impl::Data;
+
+int main(int argc, char *argv[]) {
+    ::testing::InitGoogleTest(&argc, argv);
+    return RUN_ALL_TESTS();
+}
+
+TEST(libmuscle_checkpoint_triggers, test_at_checkpoint_trigger) {
+    std::vector<double> at = {1, 3, 4, 4.5, 9};
+    AtCheckpointTrigger trigger(at);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(0).get(), 1);
+    ASSERT_FALSE(trigger.previous_checkpoint(0).is_set());
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(1).get(),  3);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(1).get(), 1);
+
+    double eps = 1e-16;
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(1 - eps).get(), 1);
+    ASSERT_FALSE(trigger.previous_checkpoint(1 - eps).is_set());
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(3.9).get(), 4);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(3.9).get(), 3);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(4.1).get(), 4.5);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(4.1).get(), 4);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(5).get(), 9);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(5).get(), 4.5);
+
+    ASSERT_FALSE(trigger.next_checkpoint(9).is_set());
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(9).get(), 9);
+
+    ASSERT_FALSE(trigger.next_checkpoint(11).is_set());
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(11).get(), 9);
+}
+
+TEST(libmuscle_checkpoint_triggers, test_range_checkpoint_trigger) {
+    auto range = Data::dict("start", 0.0, "stop", 20.0, "every", 1.2);
+    RangeCheckpointTrigger trigger(range);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(-1).get(), 0);
+    ASSERT_FALSE(trigger.previous_checkpoint(-1).is_set());
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(0).get(), 1.2);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(0).get(), 0);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(8).get(), 8.4);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(8).get(), 7.2);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(18.2).get(), 19.2);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(18.2).get(), 18);
+
+    ASSERT_FALSE(trigger.next_checkpoint(20).is_set());
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(20).get(), 19.2);
+}
+
+TEST(libmuscle_checkpoint_triggers, test_range_checkpoint_trigger_default_stop) {
+    auto range = Data::dict("start", 1.0, "stop", Data(), "every", 1.2);
+    RangeCheckpointTrigger trigger(range);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(-1.).get(), 1);
+    ASSERT_FALSE(trigger.previous_checkpoint(-1.).is_set());
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(148148.).get(), 148148.2);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(148148.).get(), 148147);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(148148148.).get(), 148148149);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(148148148.).get(), 148148147.8);
+}
+
+TEST(libmuscle_checkpoint_triggers, test_range_checkpoint_trigger_default_start) {
+    auto range = Data::dict("start", Data(), "stop", 10.0, "every", 1.2);
+    RangeCheckpointTrigger trigger(range);
+
+    ASSERT_FALSE(trigger.next_checkpoint(10).is_set());
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(10).get(), 9.6);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(0.0).get(), 1.2);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(0.0).get(), 0.0);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(-148148.).get(), -148147.2);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(-148148.).get(), -148148.4);
+}
+
+TEST(libmuscle_checkpoint_triggers, test_combined_checkpoint_trigger_every_at) {
+    auto rules = Data::list(
+        Data::dict("start", Data(), "stop", Data(), "every", 10.0),
+        Data::dict("at", Data::list(3.0, 7.0, 13.0, 17.0)));
+    CombinedCheckpointTriggers trigger(rules);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(-11.).get(), -10);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(-11).get(), -20);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(0.).get(), 3);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(0.).get(), 0);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(8.3).get(), 10);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(8.3).get(), 7);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(14.2).get(), 17);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(14.2).get(), 13);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(25.2).get(), 30);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(25.2).get(), 20);
+}
+
+TEST(libmuscle_checkpoint_triggers, test_combined_checkpoint_trigger_at_ranges) {
+    auto rules = Data::list(
+        Data::dict("at", Data::list(3.0, 7.0, 13.0, 17.0)),
+        Data::dict("start", 0.0, "stop", 20.0, "every", 5.0),
+        Data::dict("start", 20.0, "stop", 100.0, "every", 20.0));
+    CombinedCheckpointTriggers trigger(rules);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(-11.).get(), 0);
+    ASSERT_FALSE(trigger.previous_checkpoint(-11).is_set());
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(0.).get(), 3);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(0.).get(), 0);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(8.3).get(), 10);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(8.3).get(), 7);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(14.2).get(), 15);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(14.2).get(), 13);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(19.3).get(), 20);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(19.3).get(), 17);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(25.2).get(), 40);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(25.2).get(), 20);
+
+    ASSERT_DOUBLE_EQ(trigger.next_checkpoint(95.2).get(), 100);
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(95.2).get(), 80);
+
+    ASSERT_FALSE(trigger.next_checkpoint(125.2).is_set());
+    ASSERT_DOUBLE_EQ(trigger.previous_checkpoint(125.2).get(), 100);
+}
+
+TEST(libmuscle_checkpoint_triggers, test_trigger_manager_reference_time) {
+    auto encoded_checkpoints = Data::dict(
+        "at_end", true,
+        "wallclock_time", Data::list(),
+        "simulation_time", Data::list());
+
+    auto start = std::chrono::steady_clock::now();
+    double ref_elapsed = 15.0;
+
+    TriggerManager trigger_manager;
+    trigger_manager.set_checkpoint_info(ref_elapsed, encoded_checkpoints);
+    double elapsed_walltime = trigger_manager.elapsed_walltime();
+    auto duration = std::chrono::steady_clock::now() - start;
+    double duration_d = std::chrono::duration<double>(duration).count();
+
+    ASSERT_LT(ref_elapsed, elapsed_walltime);
+    ASSERT_LT(elapsed_walltime, ref_elapsed + duration_d);
+}
+
+TEST(libmuscle_checkpoint_triggers, test_trigger_manager) {
+    double ref_elapsed = 0.0;
+    TriggerManager trigger_manager;
+
+    auto encoded_checkpoints = Data::dict(
+        "at_end", true,
+        "wallclock_time", Data::list(Data::dict("at", Data::list(1e-12))),
+        "simulation_time", Data::list(Data::dict("at", Data::list(1.0, 3.0, 5.0))));
+    trigger_manager.set_checkpoint_info(ref_elapsed, encoded_checkpoints);
+
+    ASSERT_TRUE(trigger_manager.should_save_snapshot(0.1));
+    auto triggers = trigger_manager.get_triggers();
+    ASSERT_EQ(triggers.size(), 1);
+    ASSERT_NE(triggers[0].find("wallclock_time"), std::string::npos);
+    trigger_manager.update_checkpoints(0.1);
+
+    ASSERT_FALSE(trigger_manager.should_save_snapshot(0.99));
+
+    ASSERT_TRUE(trigger_manager.should_save_snapshot(3.2));
+    triggers = trigger_manager.get_triggers();
+    ASSERT_EQ(triggers.size(), 1);
+    ASSERT_NE(triggers[0].find("simulation_time"), std::string::npos);
+    trigger_manager.update_checkpoints(3.2);
+
+    ASSERT_TRUE(trigger_manager.should_save_final_snapshot(true, 7.0));
+    ASSERT_GT(trigger_manager.get_triggers().size(), 0);
+    trigger_manager.update_checkpoints(7.0);
+
+    ASSERT_FALSE(trigger_manager.should_save_snapshot(7.1));
+
+    ASSERT_TRUE(trigger_manager.should_save_final_snapshot(false, {}));
+    trigger_manager.update_checkpoints(7.1);
+}
+
+TEST(libmuscle_checkpoint_triggers, test_no_checkpointing) {
+    TriggerManager trigger_manager;
+    auto encoded_checkpoints = Data::dict(
+        "at_end", false,
+        "wallclock_time", Data::list(),
+        "simulation_time", Data::list());
+    trigger_manager.set_checkpoint_info(0.0, encoded_checkpoints);
+    ASSERT_FALSE(trigger_manager.should_save_snapshot(1));
+    ASSERT_FALSE(trigger_manager.should_save_snapshot(5000));
+    ASSERT_FALSE(trigger_manager.should_save_final_snapshot(false, {}));
+}
diff --git a/libmuscle/cpp/src/libmuscle/tests/test_communicator.cpp b/libmuscle/cpp/src/libmuscle/tests/test_communicator.cpp
index aa4db788..5a8d94c1 100644
--- a/libmuscle/cpp/src/libmuscle/tests/test_communicator.cpp
+++ b/libmuscle/cpp/src/libmuscle/tests/test_communicator.cpp
@@ -626,3 +626,133 @@ TEST(libmuscle_communicator, receive_message_with_slot_and_settings) {
     ASSERT_EQ(msg.settings().at("test2"), 3.1);
 }
 
+TEST(libmuscle_communicator, port_message_counts) {
+    reset_mocks();
+    auto comm = connected_communicator();
+
+    Message message(0.0, "test", Settings());
+    comm->send_message("out", message);
+
+    auto msg_counts = comm->get_message_counts();
+    ASSERT_EQ(msg_counts.size(), 3);
+    ASSERT_EQ(msg_counts["out"], std::vector<int>({1}));
+    ASSERT_EQ(msg_counts["in"], std::vector<int>({0}));
+    ASSERT_EQ(msg_counts["muscle_settings_in"], std::vector<int>({0}));
+
+    comm->restore_message_counts({
+            {"out", {3}},
+            {"in", {2}},
+            {"muscle_settings_in", {4}}});
+    comm->send_message("out", message);
+    msg_counts = comm->get_message_counts();
+    ASSERT_EQ(msg_counts.size(), 3);
+    ASSERT_EQ(msg_counts["out"], std::vector<int>({4}));
+    ASSERT_EQ(msg_counts["in"], std::vector<int>({2}));
+    ASSERT_EQ(msg_counts["muscle_settings_in"], std::vector<int>({4}));
+
+    ASSERT_THROW(
+            comm->restore_message_counts({{"x?invalid_port", {3}}}),
+            std::runtime_error);
+}
+
+TEST(libmuscle_communicator, vector_port_message_counts) {
+    reset_mocks();
+    auto comm = connected_communicator2();
+
+    auto msg_counts = comm->get_message_counts();
+    ASSERT_EQ(msg_counts.size(), 3);
+    std::vector<int> expected_counts(20);  // 20 zeros
+    ASSERT_EQ(msg_counts["out"], expected_counts);
+    ASSERT_EQ(msg_counts["in"], expected_counts);
+    ASSERT_EQ(msg_counts["muscle_settings_in"], std::vector<int>({0}));
+
+    Message message(0.0, "test", Settings());
+    comm->send_message("out", message, 13);
+    msg_counts = comm->get_message_counts();
+    ASSERT_EQ(msg_counts.size(), 3);
+    ASSERT_EQ(msg_counts["in"], expected_counts);
+    ASSERT_EQ(msg_counts["muscle_settings_in"], std::vector<int>({0}));
+    expected_counts[13] = 1;
+    ASSERT_EQ(msg_counts["out"], expected_counts);
+
+    std::iota(expected_counts.begin(), expected_counts.end(), 0);
+    // expected_counts = {0, 1, ..., 19}
+    comm->restore_message_counts({
+            {"out", expected_counts},
+            {"in", expected_counts},
+            {"muscle_settings_in", {4}}});
+    comm->send_message("out", message, 13);
+    msg_counts = comm->get_message_counts();
+    ASSERT_EQ(msg_counts.size(), 3);
+    ASSERT_EQ(msg_counts["in"], expected_counts);
+    ASSERT_EQ(msg_counts["muscle_settings_in"], std::vector<int>({4}));
+    expected_counts[13] = 14;
+    ASSERT_EQ(msg_counts["out"], expected_counts);
+}
+
+TEST(libmuscle_communicator, port_count_validation) {
+    reset_mocks();
+    MockMPPClient::next_receive_message.sender = "other.out[13]";
+    MockMPPClient::next_receive_message.receiver = "kernel[13].in";
+
+    auto comm = connected_communicator();
+    Message msg = comm->receive_message("in");
+
+    ASSERT_EQ(comm->get_message_counts()["in"], std::vector<int>({1}));
+
+    // the message received has message_number = 0 again
+    ASSERT_THROW(comm->receive_message("in"), std::runtime_error);
+}
+
+TEST(libmuscle_communicator, port_discard_error_on_resume) {
+    reset_mocks();
+    MockMPPClient::next_receive_message.sender = "other.out[13]";
+    MockMPPClient::next_receive_message.receiver = "kernel[13].in";
+    MockMPPClient::next_receive_message.message_number = 1;
+
+    auto comm = connected_communicator();
+
+    comm->restore_message_counts({
+            {"out", {0}},
+            {"in", {2}},
+            {"muscle_settings_in", {0}}});
+    auto & ports = TestCommunicator::ports_(*comm);
+    for (auto const & port : ports) {
+        ASSERT_TRUE(port.second.is_resuming());
+    }
+
+    // In the next block, the first message with message_number=1 is discarded.
+    // The RuntimeError is raised when 'receiving' the second message with
+    // message_number=1
+    ASSERT_THROW(comm->receive_message("in"), std::runtime_error);
+    // TODO: test that a debug message was logged?
+}
+
+TEST(libmuscle_communicator, port_discard_success_on_resume) {
+    reset_mocks();
+    MockMPPClient::next_receive_message.sender = "other.out[13]";
+    MockMPPClient::next_receive_message.receiver = "kernel[13].in";
+    MockMPPClient::next_receive_message.message_number = 1;
+    MockMPPClient::next_receive_message.timestamp = 1.0;
+    MockMPPClient::side_effect = [](){
+        // ensure message_number increases after every receive()
+        MockMPPClient::next_receive_message.message_number ++;
+        MockMPPClient::next_receive_message.timestamp += 1.0;
+    };
+
+    auto comm = connected_communicator();
+
+    comm->restore_message_counts({
+            {"out", {0}},
+            {"in", {2}},
+            {"muscle_settings_in", {0}}});
+    auto & ports = TestCommunicator::ports_(*comm);
+    for (auto const & port : ports) {
+        ASSERT_TRUE(port.second.is_resuming());
+    }
+
+    auto msg = comm->receive_message("in");
+    // TODO: test that a debug message was logged?
+    ASSERT_EQ(msg.timestamp(), 2.0);
+    ASSERT_EQ(comm->get_message_counts()["in"], std::vector<int>({3}));
+}
diff --git a/libmuscle/cpp/src/libmuscle/tests/test_instance.cpp b/libmuscle/cpp/src/libmuscle/tests/test_instance.cpp
index 637732e0..f462facb 100644
--- a/libmuscle/cpp/src/libmuscle/tests/test_instance.cpp
+++ b/libmuscle/cpp/src/libmuscle/tests/test_instance.cpp
@@ -15,6 +15,7 @@
 #include <libmuscle/message.cpp>
 #include <libmuscle/port.cpp>
 #include <libmuscle/settings_manager.cpp>
+#include <libmuscle/snapshot_manager.cpp>
 #include <libmuscle/timestamp.cpp>
 
 // then add mock implementations as needed.
diff --git a/libmuscle/cpp/src/libmuscle/tests/test_snapshot.cpp b/libmuscle/cpp/src/libmuscle/tests/test_snapshot.cpp
new file mode 100644
index 00000000..5d1516ef
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/tests/test_snapshot.cpp
@@ -0,0 +1,100 @@
+#include <gtest/gtest.h>
+
+#include <libmuscle/snapshot.hpp>
+
+using libmuscle::impl::Data;
+using libmuscle::impl::Message;
+using libmuscle::impl::Optional;
+using libmuscle::impl::Snapshot;
+using libmuscle::impl::SnapshotMetadata;
+
+int main(int argc, char *argv[]) {
+    ::testing::InitGoogleTest(&argc, argv);
+    return RUN_ALL_TESTS();
+}
+
+Snapshot create_snapshot() {
+    ::ymmsl::Settings settings;
+    settings["test"] = 1;
+    return Snapshot(
+            {"test triggers"},
+            15.3,
+            {{"in", {1}}, {"out", {4}}, {"muscle_settings_in", {0}}},
+            true,
+            Message(1.2, "test_data"),
+            settings
+            );
+}
+
+TEST(libmuscle_snapshot, test_snapshot) {
+    auto snapshot = create_snapshot();
+
+    ASSERT_EQ(snapshot.triggers.size(), 1);
+    ASSERT_STREQ(snapshot.triggers[0].c_str(), "test triggers");
+    ASSERT_DOUBLE_EQ(snapshot.wallclock_time, 15.3);
+    ASSERT_EQ(snapshot.port_message_counts.size(), 3);
+    ASSERT_EQ(snapshot.port_message_counts.at("in"), std::vector<int>({1}));
+    ASSERT_EQ(snapshot.port_message_counts.at("out"), std::vector<int>({4}));
+    ASSERT_EQ(snapshot.port_message_counts.at("muscle_settings_in"),
+              std::vector<int>({0}));
+    ASSERT_TRUE(snapshot.is_final_snapshot);
+    ASSERT_TRUE(snapshot.message.is_set());
+    ASSERT_DOUBLE_EQ(snapshot.message.get().timestamp(), 1.2);
+    ASSERT_FALSE(snapshot.message.get().has_next_timestamp());
+    ASSERT_FALSE(snapshot.message.get().has_settings());
+    ASSERT_STREQ(snapshot.message.get().data().as<std::string>().c_str(),
+                 "test_data");
+    ASSERT_EQ(snapshot.settings_overlay["test"], 1);
+
+    auto binary_snapshot = snapshot.to_bytes();
+    Snapshot snapshot2 = Snapshot::from_bytes(binary_snapshot);
+
+    ASSERT_EQ(snapshot.triggers, snapshot2.triggers);
+    ASSERT_EQ(snapshot.wallclock_time, snapshot2.wallclock_time);
+    ASSERT_EQ(snapshot.port_message_counts, snapshot2.port_message_counts);
+    ASSERT_EQ(snapshot.is_final_snapshot, snapshot2.is_final_snapshot);
+    ASSERT_EQ(snapshot.message.get().timestamp(),
+              snapshot2.message.get().timestamp());
+    ASSERT_EQ(snapshot.message.get().data().as<std::string>(),
+              snapshot2.message.get().data().as<std::string>());
+    ASSERT_EQ(snapshot.settings_overlay, snapshot2.settings_overlay);
+}
+
+TEST(libmuscle_snapshot, test_snapshot_metadata) {
+    auto snapshot = create_snapshot();
+
+    auto metadata = SnapshotMetadata::from_snapshot(snapshot, "test");
+    ASSERT_EQ(metadata.triggers, snapshot.triggers);
+    ASSERT_EQ(metadata.wallclock_time, snapshot.wallclock_time);
+    ASSERT_EQ(metadata.port_message_counts, snapshot.port_message_counts);
+    ASSERT_EQ(metadata.is_final_snapshot, snapshot.is_final_snapshot);
+    ASSERT_EQ(metadata.timestamp, snapshot.message.get().timestamp());
+    ASSERT_EQ(metadata.next_timestamp.is_set(),
+              snapshot.message.get().has_next_timestamp());
+    ASSERT_EQ(metadata.snapshot_filename, "test");
+}
+
+TEST(libmuscle_snapshot, test_message_with_settings) {
+    ::ymmsl::Settings settings;
+    settings["b"] = true;
+    Message message(1.0, 2.0, "test_data", settings);
+    Snapshot snapshot ({}, 0, {}, false, message, {});
+    ASSERT_TRUE(snapshot.message.get().settings().at("b").as<bool>());
+
+    auto binary_snapshot = snapshot.to_bytes();
+    Snapshot snapshot2 = Snapshot::from_bytes(binary_snapshot);
+
+    ASSERT_TRUE(snapshot2.message.get().settings().at("b").as<bool>());
+}
+
+TEST(libmuscle_snapshot, test_implicit_snapshot) {
+    Optional<Message> message;
+    Snapshot snapshot({}, 0, {}, true, message, {});
+    ASSERT_FALSE(snapshot.message.is_set());
+
+
+    auto binary_snapshot = snapshot.to_bytes();
+    Snapshot snapshot2 = Snapshot::from_bytes(binary_snapshot);
+
+    ASSERT_FALSE(snapshot2.message.is_set());
+}
diff --git a/libmuscle/cpp/src/libmuscle/tests/test_snapshot_manager.cpp b/libmuscle/cpp/src/libmuscle/tests/test_snapshot_manager.cpp
new file mode 100644
index 00000000..7c787eba
--- /dev/null
+++ b/libmuscle/cpp/src/libmuscle/tests/test_snapshot_manager.cpp
@@ -0,0 +1,225 @@
+// Inject mocks
+#define LIBMUSCLE_MOCK_COMMUNICATOR <mocks/mock_communicator.hpp>
+#define LIBMUSCLE_MOCK_LOGGER <mocks/mock_logger.hpp>
+#define LIBMUSCLE_MOCK_MMP_CLIENT <mocks/mock_mmp_client.hpp>
+#define LIBMUSCLE_MOCK_PROFILER <mocks/mock_profiler.hpp>
+
+// into the real implementation,
+#include <ymmsl/ymmsl.hpp>
+
+#include <libmuscle/close_port.cpp>
+#include <libmuscle/data.cpp>
+#include <libmuscle/instance.cpp>
+#include <libmuscle/logging.cpp>
+#include <libmuscle/mcp/data_pack.cpp>
+#include <libmuscle/message.cpp>
+#include <libmuscle/port.cpp>
+#include <libmuscle/settings_manager.cpp>
+#include <libmuscle/snapshot_manager.cpp>
+#include <libmuscle/timestamp.cpp>
+
+// then add mock implementations as needed.
+#include <mocks/mock_communicator.cpp>
+#include <mocks/mock_logger.cpp>
+#include <mocks/mock_mmp_client.cpp>
+#include <mocks/mock_profiler.cpp>
+
+// Test code dependencies
+#include <cstdio>
+#include <iostream>
+#include <memory>
+#include <stdexcept>
+#include <typeinfo>
+
+#include <gtest/gtest.h>
+#include <libmuscle/snapshot_manager.hpp>
+
+// Note: using POSIX for filesystem calls
+// Could be upgraded to std::filesystem when targeting C++17 or later
+#include <cstdlib>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <string.h>
+#include <unistd.h>
+#include <ftw.h>
+
+using libmuscle::impl::Data;
+using libmuscle::impl::Message;
+using libmuscle::impl::MockCommunicator;
+using libmuscle::impl::MockLogger;
+using libmuscle::impl::MockMMPClient;
+using libmuscle::impl::MockProfiler;
+using libmuscle::impl::Optional;
+using libmuscle::impl::Snapshot;
+using libmuscle::impl::SnapshotMetadata;
+using libmuscle::impl::SnapshotManager;
+using ymmsl::Reference;
+
+int main(int argc, char *argv[]) {
+    ::testing::InitGoogleTest(&argc, argv);
+    return RUN_ALL_TESTS();
+}
+
+/* Mocks have internal state, which needs to be reset before each test. This
+ * means that the tests are not reentrant, and cannot be run in parallel.
+ * It's all fast enough, so that's not a problem.
+ */
+void reset_mocks() {
+    MockCommunicator::reset();
+    MockMMPClient::reset();
+}
+
+MockLogger & mock_logger() {
+    static MockLogger logger;
+    return logger;
+}
+
+MockProfiler & mock_profiler() {
+    static MockProfiler profiler;
+    return profiler;
+}
+
+// callback for nftw() to delete all contents of a folder
+int _nftw_rm_callback(
+        const char *fpath, const struct stat *sb, int tflag, struct FTW *ftwbuf) {
+    if (tflag == FTW_DP) {
+        std::cerr << "DEBUG: removing dir " << fpath << std::endl;
+        return rmdir(fpath);
+    }
+    if (tflag == FTW_F) {
+        std::cerr << "DEBUG: removing file " << fpath << std::endl;
+        return unlink(fpath);
+    }
+    std::cerr << "DEBUG: unknown file type " << fpath << std::endl;
+    return -1;
+}
+
+class libmuscle_snapshot_manager : public ::testing::Test {
+    protected:
+        void SetUp() override {
+            char tmpname[] = "/tmp/muscle3_test.XXXXXX";
+            if (mkdtemp(tmpname) == nullptr)
+                throw std::runtime_error(strerror(errno));
+            temp_dir_ = tmpname;
+            std::cerr << "DEBUG: using temp dir " << temp_dir_ << std::endl;
+
+            reset_mocks();
+        }
+
+        void TearDown() override {
+            // simulate rm -rf `temp_dir_` using a file-tree-walk
+            if (nftw(temp_dir_.c_str(), _nftw_rm_callback, 3, FTW_DEPTH) < 0) {
+                throw std::runtime_error(strerror(errno));
+            }
+            temp_dir_.clear();
+        }
+
+        std::string temp_dir_;
+};
+
+TEST_F(libmuscle_snapshot_manager, test_no_checkpointing) {
+    MockCommunicator communicator("test", {}, {}, mock_logger(), mock_profiler());
+    MockMMPClient manager("instance", "");
+    SnapshotManager snapshot_manager("test", manager, communicator, mock_logger());
+
+    snapshot_manager.prepare_resume({}, temp_dir_);
+    ASSERT_FALSE(snapshot_manager.resuming_from_intermediate());
+    ASSERT_FALSE(snapshot_manager.resuming_from_final());
+}
+
+TEST_F(libmuscle_snapshot_manager, test_save_load_snapshot) {
+    MockCommunicator communicator("test", {}, {}, mock_logger(), mock_profiler());
+    MockCommunicator::PortMessageCounts port_message_counts = {
+            {"in", {1}},
+            {"out", {2}},
+            {"muscle_settings_in", {0}}};
+    MockCommunicator::get_message_counts_return_value = port_message_counts;
+    MockMMPClient manager("instance", "");
+    Reference instance_id("test[1]");
+
+    SnapshotManager snapshot_manager(instance_id, manager, communicator, mock_logger());
+
+    snapshot_manager.prepare_resume({}, temp_dir_);
+    ASSERT_FALSE(snapshot_manager.resuming_from_intermediate());
+    ASSERT_FALSE(snapshot_manager.resuming_from_final());
+
+    snapshot_manager.save_snapshot(
+            Message(0.2, "test data"), false, {"test"}, 13.0, {}, {});
+
+    ASSERT_TRUE(MockMMPClient::last_submitted_snapshot_metadata.is_set());
+    auto & metadata = MockMMPClient::last_submitted_snapshot_metadata.get();
+    ASSERT_EQ(metadata.triggers, std::vector<std::string>({"test"}));
+    ASSERT_EQ(metadata.wallclock_time, 13.0);
+    ASSERT_EQ(metadata.timestamp, 0.2);
+    ASSERT_FALSE(metadata.next_timestamp.is_set());
+    ASSERT_EQ(metadata.port_message_counts, port_message_counts);
+    ASSERT_FALSE(metadata.is_final_snapshot);
+    auto snapshot_path = metadata.snapshot_filename;
+    ASSERT_EQ(snapshot_path, temp_dir_ + "/test-1_1.pack");
+
+    SnapshotManager snapshot_manager2(
+            instance_id, manager, communicator, mock_logger());
+    snapshot_manager2.prepare_resume(snapshot_path, temp_dir_);
+    ASSERT_EQ(MockCommunicator::last_restored_message_counts, port_message_counts);
+
+    ASSERT_TRUE(snapshot_manager2.resuming_from_intermediate());
+    ASSERT_FALSE(snapshot_manager2.resuming_from_final());
+
+    auto msg = snapshot_manager2.load_snapshot();
+    ASSERT_DOUBLE_EQ(msg.timestamp(), 0.2);
+    ASSERT_FALSE(msg.has_next_timestamp());
+    ASSERT_EQ(msg.data().as<std::string>(), "test data");
+
+    snapshot_manager2.save_snapshot(
+            Message(0.6, "test data2"), true, {"test"}, 42.2, 1.2, {});
+
+    ASSERT_TRUE(MockMMPClient::last_submitted_snapshot_metadata.is_set());
+    metadata = MockMMPClient::last_submitted_snapshot_metadata.get();
+    ASSERT_EQ(metadata.triggers, std::vector<std::string>({"test"}));
+    ASSERT_EQ(metadata.wallclock_time, 42.2);
+    ASSERT_EQ(metadata.timestamp, 0.6);
+    ASSERT_FALSE(metadata.next_timestamp.is_set());
+    ASSERT_EQ(metadata.port_message_counts, port_message_counts);
+    ASSERT_TRUE(metadata.is_final_snapshot);
+    snapshot_path = metadata.snapshot_filename;
+    ASSERT_EQ(snapshot_path, temp_dir_ + "/test-1_3.pack");
+}
+
+TEST_F(libmuscle_snapshot_manager, test_save_load_implicit_snapshot) {
+    MockCommunicator communicator("test", {}, {}, mock_logger(), mock_profiler());
+    MockCommunicator::PortMessageCounts port_message_counts = {
+            {"in", {1}},
+            {"out", {2}},
+            {"muscle_settings_in", {0}}};
+    MockCommunicator::get_message_counts_return_value = port_message_counts;
+    MockMMPClient manager("instance", "");
+    Reference instance_id("test[1]");
+
+    SnapshotManager snapshot_manager(instance_id, manager, communicator, mock_logger());
+
+    snapshot_manager.prepare_resume({}, temp_dir_);
+    ASSERT_FALSE(snapshot_manager.resuming_from_intermediate());
+    ASSERT_FALSE(snapshot_manager.resuming_from_final());
+
+    // save implicit snapshot, i.e. Message=not set
+    snapshot_manager.save_snapshot({}, true, {"implicit"}, 1.0, 1.5, {});
+
+    ASSERT_TRUE(MockMMPClient::last_submitted_snapshot_metadata.is_set());
+    auto & metadata = MockMMPClient::last_submitted_snapshot_metadata.get();
+    std::string snapshot_path = metadata.snapshot_filename;
+    MockMMPClient::reset();
+
+    SnapshotManager snapshot_manager2(
+            instance_id, manager, communicator, mock_logger());
+
+    snapshot_manager2.prepare_resume(snapshot_path, temp_dir_);
+    ASSERT_EQ(MockCommunicator::last_restored_message_counts, port_message_counts);
+    ASSERT_TRUE(MockMMPClient::last_submitted_snapshot_metadata.is_set());
+    MockMMPClient::reset();
+
+    ASSERT_FALSE(snapshot_manager2.resuming_from_intermediate());
+    ASSERT_FALSE(snapshot_manager2.resuming_from_final());
+    snapshot_manager2.save_snapshot({}, true, {"implicit"}, 12.3, 2.5, {});
+    ASSERT_TRUE(MockMMPClient::last_submitted_snapshot_metadata.is_set());
+}
diff --git a/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_macro_test.f90 b/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_macro_test.f90
new file mode 100644
index 00000000..38a5c821
--- /dev/null
+++ b/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_macro_test.f90
@@ -0,0 +1,86 @@
+! This is a part of the integration test suite, and is run from a Python
+! test in /integration_test. It is not a unit test.
+
+program snapshot_macro
+    use assert
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+    type(LIBMUSCLE_Message) :: msg
+    type(LIBMUSCLE_DataConstRef) :: rdata
+    type(LIBMUSCLE_Message) :: message
+    type(LIBMUSCLE_Data) :: sdata
+    integer :: i
+    real (LIBMUSCLE_real8) :: dt, t_max, t_cur, t_next
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_I, 'o_i')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_S, 's')
+    instance = LIBMUSCLE_Instance_create( &
+        ports, LIBMUSCLE_InstanceFlags(USES_CHECKPOINT_API=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while (LIBMUSCLE_Instance_reuse_instance(instance))
+        dt = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dt')
+        t_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't_max')
+
+        if (LIBMUSCLE_Instance_resuming(instance)) then
+            msg = LIBMUSCLE_Instance_load_snapshot(instance)
+            ! load state from message
+            t_cur = LIBMUSCLE_Message_timestamp(msg)
+            rdata = LIBMUSCLE_Message_get_data(msg)
+            i = LIBMUSCLE_DataConstRef_as_int(rdata)
+
+            call LIBMUSCLE_DataConstRef_free(rdata)
+            call LIBMUSCLE_Message_free(msg)
+        end if
+
+        if (LIBMUSCLE_Instance_should_init(instance)) then
+            t_cur = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't0')
+            i = 0
+        end if
+
+        do while (t_cur + dt <= t_max)
+            sdata = LIBMUSCLE_Data_create(i)
+            msg = LIBMUSCLE_Message_create(t_cur, sdata)
+            t_next = t_cur + dt
+            if (t_next + dt <= t_max) then
+                call LIBMUSCLE_Message_set_next_timestamp(msg, t_next)
+            end if
+            call LIBMUSCLE_Instance_send(instance, 'o_i', msg)
+            call LIBMUSCLE_Message_free(msg)
+            call LIBMUSCLE_Data_free(sdata)
+
+            msg = LIBMUSCLE_Instance_receive(instance, 's')
+            rdata = LIBMUSCLE_Message_get_data(msg)
+            call assert_eq_integer(LIBMUSCLE_DataConstRef_as_int(rdata), i)
+            call LIBMUSCLE_DataConstRef_free(rdata)
+            call LIBMUSCLE_Message_free(msg)
+
+            i = i + 1
+            t_cur = t_cur + dt
+
+            if (LIBMUSCLE_Instance_should_save_snapshot(instance, t_cur)) then
+                sdata = LIBMUSCLE_Data_create(i)
+                msg = LIBMUSCLE_Message_create(t_cur, sdata)
+                call LIBMUSCLE_Instance_save_snapshot(instance, msg)
+                call LIBMUSCLE_Message_free(msg)
+                call LIBMUSCLE_Data_free(sdata)
+            end if
+        end do
+
+        if (LIBMUSCLE_Instance_should_save_final_snapshot(instance)) then
+            sdata = LIBMUSCLE_Data_create(i)
+            msg = LIBMUSCLE_Message_create(t_cur, sdata)
+            call LIBMUSCLE_Instance_save_final_snapshot(instance, msg)
+            call LIBMUSCLE_Message_free(msg)
+            call LIBMUSCLE_Data_free(sdata)
+        end if
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+end program snapshot_macro
diff --git a/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_macro_vector_test.f90 b/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_macro_vector_test.f90
new file mode 100644
index 00000000..987242f8
--- /dev/null
+++ b/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_macro_vector_test.f90
@@ -0,0 +1,90 @@
+! This is a part of the integration test suite, and is run from a Python
+! test in /integration_test. It is not a unit test.
+
+program snapshot_macro_vector
+    use assert
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+    type(LIBMUSCLE_Message) :: msg
+    type(LIBMUSCLE_DataConstRef) :: rdata
+    type(LIBMUSCLE_Message) :: message
+    type(LIBMUSCLE_Data) :: sdata
+    integer :: i, slot
+    real (LIBMUSCLE_real8) :: dt, t_max, t_cur, t_next
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_I, 'o_i[]')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_S, 's[]')
+    instance = LIBMUSCLE_Instance_create( &
+        ports, LIBMUSCLE_InstanceFlags(USES_CHECKPOINT_API=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while (LIBMUSCLE_Instance_reuse_instance(instance))
+        dt = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dt')
+        t_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't_max')
+
+        if (LIBMUSCLE_Instance_resuming(instance)) then
+            msg = LIBMUSCLE_Instance_load_snapshot(instance)
+            ! load state from message
+            t_cur = LIBMUSCLE_Message_timestamp(msg)
+            rdata = LIBMUSCLE_Message_get_data(msg)
+            i = LIBMUSCLE_DataConstRef_as_int(rdata)
+
+            call LIBMUSCLE_DataConstRef_free(rdata)
+            call LIBMUSCLE_Message_free(msg)
+        end if
+
+        if (LIBMUSCLE_Instance_should_init(instance)) then
+            t_cur = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't0')
+            i = 0
+        end if
+
+        do while (t_cur + dt <= t_max)
+            sdata = LIBMUSCLE_Data_create(i)
+            msg = LIBMUSCLE_Message_create(t_cur, sdata)
+            t_next = t_cur + dt
+            if (t_next + dt <= t_max) then
+                call LIBMUSCLE_Message_set_next_timestamp(msg, t_next)
+            end if
+            do slot = 1, LIBMUSCLE_Instance_get_port_length(instance, 'o_i')
+                call LIBMUSCLE_Instance_send(instance, 'o_i', msg, slot - 1)
+            end do
+            call LIBMUSCLE_Message_free(msg)
+            call LIBMUSCLE_Data_free(sdata)
+
+            do slot = 1, LIBMUSCLE_Instance_get_port_length(instance, 's')
+                msg = LIBMUSCLE_Instance_receive_on_slot(instance, 's', slot - 1)
+                rdata = LIBMUSCLE_Message_get_data(msg)
+                call assert_eq_integer(LIBMUSCLE_DataConstRef_as_int(rdata), i)
+                call LIBMUSCLE_DataConstRef_free(rdata)
+                call LIBMUSCLE_Message_free(msg)
+            end do
+
+            i = i + 1
+            t_cur = t_cur + dt
+
+            if (LIBMUSCLE_Instance_should_save_snapshot(instance, t_cur)) then
+                sdata = LIBMUSCLE_Data_create(i)
+                msg = LIBMUSCLE_Message_create(t_cur, sdata)
+                call LIBMUSCLE_Instance_save_snapshot(instance, msg)
+                call LIBMUSCLE_Message_free(msg)
+                call LIBMUSCLE_Data_free(sdata)
+            end if
+        end do
+
+        if (LIBMUSCLE_Instance_should_save_final_snapshot(instance)) then
+            sdata = LIBMUSCLE_Data_create(i)
+            msg = LIBMUSCLE_Message_create(t_cur, sdata)
+            call LIBMUSCLE_Instance_save_final_snapshot(instance, msg)
+            call LIBMUSCLE_Message_free(msg)
+            call LIBMUSCLE_Data_free(sdata)
+        end if
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+end program snapshot_macro_vector
diff --git a/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_micro_test.f90 b/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_micro_test.f90
new file mode 100644
index 00000000..c8ea51ec
--- /dev/null
+++ b/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_micro_test.f90
@@ -0,0 +1,98 @@
+! This is a part of the integration test suite, and is run from a Python
+! test in /integration_test. It is not a unit test.
+
+program snapshot_micro
+    use assert
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+    type(LIBMUSCLE_Message) :: msg
+    type(LIBMUSCLE_DataConstRef) :: rdata, rdata2
+    type(LIBMUSCLE_Message) :: message
+    type(LIBMUSCLE_Data) :: sdata
+    integer :: i
+    real (LIBMUSCLE_real8) :: dt, t_max, t_cur, t_next, t_stop
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_F_INIT, 'f_i')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_F, 'o_f')
+    instance = LIBMUSCLE_Instance_create( &
+        ports, LIBMUSCLE_InstanceFlags(USES_CHECKPOINT_API=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while (LIBMUSCLE_Instance_reuse_instance(instance))
+        dt = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dt')
+        t_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't_max')
+
+        if (LIBMUSCLE_Instance_resuming(instance)) then
+            msg = LIBMUSCLE_Instance_load_snapshot(instance)
+            ! load state from message
+            t_cur = LIBMUSCLE_Message_timestamp(msg)
+            rdata = LIBMUSCLE_Message_get_data(msg)
+
+            rdata2 = LIBMUSCLE_DataConstRef_get_item(rdata, 1_LIBMUSCLE_size)
+            i = LIBMUSCLE_DataConstRef_as_int(rdata2)
+            call LIBMUSCLE_DataConstRef_free(rdata2)
+
+            rdata2 = LIBMUSCLE_DataConstRef_get_item(rdata, 2_LIBMUSCLE_size)
+            t_stop = LIBMUSCLE_DataConstRef_as_real8(rdata2)
+            call LIBMUSCLE_DataConstRef_free(rdata2)
+
+            call LIBMUSCLE_DataConstRef_free(rdata)
+            call LIBMUSCLE_Message_free(msg)
+        end if
+
+        if (LIBMUSCLE_Instance_should_init(instance)) then
+            msg = LIBMUSCLE_Instance_receive(instance, 'f_i')
+            t_cur = LIBMUSCLE_Message_timestamp(msg)
+
+            rdata = LIBMUSCLE_Message_get_data(msg)
+            i = LIBMUSCLE_DataConstRef_as_int(rdata)
+            t_stop = t_cur + t_max
+
+            call LIBMUSCLE_DataConstRef_free(rdata)
+            call LIBMUSCLE_Message_free(msg)
+        end if
+
+        do while (t_cur <= t_stop)
+            ! faux time-integration for testing snapshots
+            t_cur = t_cur + dt
+
+            if (LIBMUSCLE_Instance_should_save_snapshot(instance, t_cur)) then
+                sdata = LIBMUSCLE_Data_create_nils(2_LIBMUSCLE_size)
+                call LIBMUSCLE_Data_set_item(sdata, 1_LIBMUSCLE_size, i)
+                call LIBMUSCLE_Data_set_item(sdata, 2_LIBMUSCLE_size, t_stop)
+
+                msg = LIBMUSCLE_Message_create(t_cur, sdata)
+                call LIBMUSCLE_Instance_save_snapshot(instance, msg)
+
+                call LIBMUSCLE_Message_free(msg)
+                call LIBMUSCLE_Data_free(sdata)
+            end if
+        end do
+
+        sdata = LIBMUSCLE_Data_create(i)
+        msg = LIBMUSCLE_Message_create(t_cur, sdata)
+        call LIBMUSCLE_Instance_send(instance, 'o_f', msg)
+        call LIBMUSCLE_Data_free(sdata)
+        call LIBMUSCLE_Message_free(msg)
+
+        if (LIBMUSCLE_Instance_should_save_final_snapshot(instance)) then
+            sdata = LIBMUSCLE_Data_create_nils(2_LIBMUSCLE_size)
+            call LIBMUSCLE_Data_set_item(sdata, 1_LIBMUSCLE_size, i)
+            call LIBMUSCLE_Data_set_item(sdata, 2_LIBMUSCLE_size, t_stop)
+
+            msg = LIBMUSCLE_Message_create(t_cur, sdata)
+            call LIBMUSCLE_Instance_save_final_snapshot(instance, msg)
+
+            call LIBMUSCLE_Message_free(msg)
+            call LIBMUSCLE_Data_free(sdata)
+        end if
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+end program snapshot_micro
diff --git a/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_stateless_micro_test.f90 b/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_stateless_micro_test.f90
new file mode 100644
index 00000000..47106894
--- /dev/null
+++ b/libmuscle/fortran/src/libmuscle/tests/fortran_snapshot_stateless_micro_test.f90
@@ -0,0 +1,54 @@
+! This is a part of the integration test suite, and is run from a Python
+! test in /integration_test. It is not a unit test.
+
+program snapshot_micro
+    use assert
+    use ymmsl
+    use libmuscle
+    implicit none
+
+    type(LIBMUSCLE_PortsDescription) :: ports
+    type(LIBMUSCLE_Instance) :: instance
+    type(LIBMUSCLE_Message) :: msg
+    type(LIBMUSCLE_DataConstRef) :: rdata, rdata2
+    type(LIBMUSCLE_Message) :: message
+    type(LIBMUSCLE_Data) :: sdata
+    integer :: i
+    real (LIBMUSCLE_real8) :: dt, t_max, t_cur, t_next, t_stop
+
+    ports = LIBMUSCLE_PortsDescription_create()
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_F_INIT, 'f_i')
+    call LIBMUSCLE_PortsDescription_add(ports, YMMSL_Operator_O_F, 'o_f')
+    instance = LIBMUSCLE_Instance_create( &
+        ports, LIBMUSCLE_InstanceFlags(KEEPS_NO_STATE_FOR_NEXT_USE=.true.))
+    call LIBMUSCLE_PortsDescription_free(ports)
+
+    do while(LIBMUSCLE_Instance_reuse_instance(instance))
+        dt = LIBMUSCLE_Instance_get_setting_as_real8(instance, 'dt')
+        t_max = LIBMUSCLE_Instance_get_setting_as_real8(instance, 't_max')
+
+        msg = LIBMUSCLE_Instance_receive(instance, 'f_i')
+        t_cur = LIBMUSCLE_Message_timestamp(msg)
+
+        rdata = LIBMUSCLE_Message_get_data(msg)
+        i = LIBMUSCLE_DataConstRef_as_int(rdata)
+        t_stop = t_cur + t_max
+
+        call LIBMUSCLE_DataConstRef_free(rdata)
+        call LIBMUSCLE_Message_free(msg)
+
+        do while (t_cur <= t_stop)
+            ! faux time-integration for testing snapshots
+            t_cur = t_cur + dt
+        end do
+
+        sdata = LIBMUSCLE_Data_create(i)
+        msg = LIBMUSCLE_Message_create(t_cur, sdata)
+        call LIBMUSCLE_Instance_send(instance, 'o_f', msg)
+        call LIBMUSCLE_Data_free(sdata)
+        call LIBMUSCLE_Message_free(msg)
+    end do
+
+    call LIBMUSCLE_Instance_free(instance)
+
+end program snapshot_micro
diff --git a/libmuscle/python/libmuscle/manager/test/test_mmp_request_handler.py b/libmuscle/python/libmuscle/manager/test/test_mmp_request_handler.py
index 131eee81..768d2160 100644
--- a/libmuscle/python/libmuscle/manager/test/test_mmp_request_handler.py
+++ b/libmuscle/python/libmuscle/manager/test/test_mmp_request_handler.py
@@ -153,7 +153,11 @@ def test_get_checkpoint_info(mmp_configuration, mmp_request_handler):
     wallclock_time = checkpoints['wallclock_time']
     assert len(wallclock_time) == 2
     assert wallclock_time[0] == {'start': None, 'stop': None, 'every': 10}
+    assert all(isinstance(obj, (type(None), float))
+               for obj in wallclock_time[0].values())
     assert wallclock_time[1] == {'at': [1, 2, 3.0]}
+    assert all(isinstance(obj, (type(None), float))
+               for obj in wallclock_time[1]['at'])
 
     assert resume is not None
     assert Path(resume) == resume_path
diff --git a/libmuscle/python/libmuscle/test/test_checkpoint_triggers.py b/libmuscle/python/libmuscle/test/test_checkpoint_triggers.py
index e111a758..d78030bc 100644
--- a/libmuscle/python/libmuscle/test/test_checkpoint_triggers.py
+++ b/libmuscle/python/libmuscle/test/test_checkpoint_triggers.py
@@ -8,7 +8,7 @@
 
 
 def test_at_checkpoint_trigger():
-    trigger = AtCheckpointTrigger([CheckpointAtRule([1, 3, 4, 4.5, 9])])
+    trigger = AtCheckpointTrigger([CheckpointAtRule([1.0, 3.0, 4.0, 4.5, 9.0])])
 
     assert trigger.next_checkpoint(0) == 1
     assert trigger.previous_checkpoint(0) is None
@@ -37,7 +37,7 @@ def test_at_checkpoint_trigger():
 
 
 def test_range_checkpoint_trigger():
-    range = CheckpointRangeRule(start=0, stop=20, every=1.2)
+    range = CheckpointRangeRule(start=0.0, stop=20.0, every=1.2)
     trigger = RangeCheckpointTrigger(range)
 
     assert trigger.next_checkpoint(-1) == 0
@@ -57,7 +57,7 @@ def test_range_checkpoint_trigger():
 
 
 def test_range_checkpoint_trigger_default_stop():
-    range = CheckpointRangeRule(start=1, every=1.2)
+    range = CheckpointRangeRule(start=1.0, every=1.2)
     trigger = RangeCheckpointTrigger(range)
 
     assert trigger.next_checkpoint(-1.) == 1
@@ -71,7 +71,7 @@ def test_range_checkpoint_trigger_default_stop():
 
 
 def test_range_checkpoint_trigger_default_start():
-    range = CheckpointRangeRule(every=1.2, stop=10)
+    range = CheckpointRangeRule(every=1.2, stop=10.0)
     trigger = RangeCheckpointTrigger(range)
 
     assert trigger.next_checkpoint(10) is None
@@ -85,7 +85,7 @@ def test_range_checkpoint_trigger_default_start():
 
 
 def test_combined_checkpoint_trigger_every_at():
-    rules = [CheckpointRangeRule(every=10), CheckpointAtRule([3, 7, 13, 17])]
+    rules = [CheckpointRangeRule(every=10.0), CheckpointAtRule([3.0, 7.0, 13.0, 17.0])]
     trigger = CombinedCheckpointTriggers(rules)
 
     assert trigger.next_checkpoint(-11.) == pytest.approx(-10)
@@ -105,9 +105,9 @@ def test_combined_checkpoint_trigger_every_at():
 
 
 def test_combined_checkpoint_trigger_at_ranges():
-    rules = [CheckpointAtRule([3, 7, 13, 17]),
-             CheckpointRangeRule(start=0, every=5, stop=20),
-             CheckpointRangeRule(start=20, every=20, stop=100)]
+    rules = [CheckpointAtRule([3.0, 7.0, 13.0, 17.0]),
+             CheckpointRangeRule(start=0.0, every=5.0, stop=20.0),
+             CheckpointRangeRule(start=20.0, every=20.0, stop=100.0)]
     trigger = CombinedCheckpointTriggers(rules)
 
     assert trigger.next_checkpoint(-11.) == pytest.approx(0)
@@ -142,7 +142,7 @@ def test_trigger_manager_reference_time():
     trigger_manager.set_checkpoint_info(ref_elapsed, Checkpoints(at_end=True))
     elapsed_walltime = trigger_manager.elapsed_walltime()
     duration = time.monotonic() - monotonic_start
-    assert ref_elapsed < elapsed_walltime <= (ref_elapsed + duration)
+    assert ref_elapsed < elapsed_walltime < (ref_elapsed + duration)
 
 
 def test_trigger_manager():
@@ -151,7 +151,7 @@ def test_trigger_manager():
     trigger_manager.set_checkpoint_info(ref_elapsed, Checkpoints(
             at_end=True,
             wallclock_time=[CheckpointAtRule([1e-12])],
-            simulation_time=[CheckpointAtRule([1, 3, 5])]))
+            simulation_time=[CheckpointAtRule([1.0, 3.0, 5.0])]))
 
     assert trigger_manager.should_save_snapshot(0.1)
     triggers = trigger_manager.get_triggers()
diff --git a/libmuscle/python/libmuscle/test/test_snapshot_manager.py b/libmuscle/python/libmuscle/test/test_snapshot_manager.py
index e530ad06..e2b71ebe 100644
--- a/libmuscle/python/libmuscle/test/test_snapshot_manager.py
+++ b/libmuscle/python/libmuscle/test/test_snapshot_manager.py
@@ -38,8 +38,7 @@ def test_save_load_snapshot(tmp_path: Path) -> None:
 
     communicator.get_message_counts.assert_called_with()
     manager.submit_snapshot_metadata.assert_called()
-    instance, metadata = manager.submit_snapshot_metadata.call_args[0]
-    assert instance == instance_id
+    metadata, = manager.submit_snapshot_metadata.call_args[0]
     assert isinstance(metadata, SnapshotMetadata)
     assert metadata.triggers == ['test']
     assert metadata.wallclock_time == 13.0
@@ -67,8 +66,7 @@ def test_save_load_snapshot(tmp_path: Path) -> None:
             Message(0.6, None, 'test data2'), True, ['test'], 42.2, 1.2,
             Settings())
 
-    instance, metadata = manager.submit_snapshot_metadata.call_args[0]
-    assert instance == instance_id
+    metadata, = manager.submit_snapshot_metadata.call_args[0]
     assert isinstance(metadata, SnapshotMetadata)
     assert metadata.triggers == ['test']
     assert metadata.wallclock_time == 42.2
@@ -80,12 +78,6 @@ def test_save_load_snapshot(tmp_path: Path) -> None:
     assert snapshot_path.parent == tmp_path
     assert snapshot_path.name == 'test-1_3.pack'
 
-    assert snapshot_manager2.resuming_from_intermediate()
-    assert not snapshot_manager2.resuming_from_final()
-    snapshot_manager2.load_snapshot()
-    assert snapshot_manager2.resuming_from_intermediate()
-    assert not snapshot_manager2.resuming_from_final()
-
 
 def test_save_load_implicit_snapshot(tmp_path: Path) -> None:
     manager = MagicMock()
@@ -105,8 +97,7 @@ def test_save_load_implicit_snapshot(tmp_path: Path) -> None:
             None, True, ['implicit'], 1.0, 1.5, Settings())
 
     manager.submit_snapshot_metadata.assert_called_once()
-    instance, metadata = manager.submit_snapshot_metadata.call_args[0]
-    assert instance == instance_id
+    metadata, = manager.submit_snapshot_metadata.call_args[0]
     assert isinstance(metadata, SnapshotMetadata)
     snapshot_path = Path(metadata.snapshot_filename)
     manager.submit_snapshot_metadata.reset_mock()