diff --git a/partitioned-heat-conduction-direct/README.md b/partitioned-heat-conduction-direct/README.md
new file mode 100644
index 000000000..4a66e09e1
--- /dev/null
+++ b/partitioned-heat-conduction-direct/README.md
@@ -0,0 +1,40 @@
+---
+title: Partitioned heat conduction (direct access setup)
+permalink: tutorials-partitioned-heat-conduction-direct.html
+keywords: Nutils, Heat conduction, Direct mesh access
+summary: This tutorial is a modified version of the "partitioned heat conduction" tutorial showcasing direct mesh access.
+---
+
+{% note %}
+Get the [case files of this tutorial](https://github.com/precice/tutorials/tree/master/partitioned-heat-conduction-direct). Read how in the [tutorials introduction](https://www.precice.org/tutorials.html).
+{% endnote %}
+
+## Setup
+
+This case is a modified version of the [partitioned heat conduction tutorial](tutorials-partitioned-heat-conduction.html). Main modification is that we here use the [direct mesh access feature](couple-your-code-direct-access.html) to let the solvers compute the data mapping and not preCICE.
+
+Further minor modifications:
+
+- We use a parallel coupling scheme instead of a serial one to prevent running into the problem where we are trying to add a zero column to the quasi-Newton matrix. For serial coupling, this happens here because one data field converges much faster than the other.
+
+## Available solvers
+
+Currently only `nutils` is provided as a solver. The data mapping is computed by directly sampling the FEM function representation at the inquired locations.
+
+## Running the simulation
+
+Open two terminals and run:
+
+```bash
+cd nutils
+./run.sh -d
+```
+
+and
+
+```bash
+cd nutils
+./run.sh -n
+```
+
+See the [partitioned heat conduction tutorial](tutorials-partitioned-heat-conduction.html).
diff --git a/partitioned-heat-conduction-direct/clean-tutorial.sh b/partitioned-heat-conduction-direct/clean-tutorial.sh
new file mode 100755
index 000000000..471f8f887
--- /dev/null
+++ b/partitioned-heat-conduction-direct/clean-tutorial.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e -u
+
+# shellcheck disable=SC1091
+. ../tools/cleaning-tools.sh
+
+clean_tutorial .
+
diff --git a/partitioned-heat-conduction-direct/nutils/clean.sh b/partitioned-heat-conduction-direct/nutils/clean.sh
new file mode 100755
index 000000000..6893c1ea5
--- /dev/null
+++ b/partitioned-heat-conduction-direct/nutils/clean.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+set -e -u
+
+. ../../tools/cleaning-tools.sh
+
+clean_nutils .
diff --git a/partitioned-heat-conduction-direct/nutils/heat.py b/partitioned-heat-conduction-direct/nutils/heat.py
new file mode 100644
index 000000000..e3ab91d98
--- /dev/null
+++ b/partitioned-heat-conduction-direct/nutils/heat.py
@@ -0,0 +1,156 @@
+#! /usr/bin/env python3
+
+from nutils import cli, mesh, function, solver, export
+import functools
+import treelog
+import numpy as np
+import precice
+
+
+def main(side='Dirichlet', n=10, degree=1, timestep=.1, alpha=3., beta=1.3):
+
+    if side == 'Dirichlet':
+        x_grid = np.linspace(0, 1, n)
+    elif side == 'Neumann':
+        x_grid = np.linspace(1, 2, n)
+    else:
+        raise Exception('invalid side {!r}'.format(side))
+    y_grid = np.linspace(0, 1, n)
+
+    # define the Nutils mesh
+    domain, geom = mesh.rectilinear([x_grid, y_grid])
+    coupling_boundary = domain.boundary['right' if side == 'Dirichlet' else 'left']
+    read_sample = coupling_boundary.sample('gauss', degree=degree * 2)
+
+    # Nutils namespace
+    ns = function.Namespace()
+    ns.x = geom
+    ns.basis = domain.basis('std', degree=degree)
+    ns.alpha = alpha  # parameter of problem
+    ns.beta = beta  # parameter of problem
+    ns.u = 'basis_n ?lhs_n'  # solution
+    ns.dudt = 'basis_n (?lhs_n - ?lhs0_n) / ?dt'  # time derivative
+    ns.flux = 'basis_n ?fluxdofs_n'  # heat flux
+    ns.f = 'beta - 2 - 2 alpha'  # rhs
+    ns.uexact = '1 + x_0 x_0 + alpha x_1 x_1 + beta ?t'  # analytical solution
+    ns.readbasis = read_sample.basis()
+    ns.readfunc = 'readbasis_n ?readdata_n'
+
+    # define the weak form
+    res = domain.integral('(basis_n dudt - basis_n f + basis_n,i u_,i) d:x' @ ns, degree=degree * 2)
+
+    # set boundary conditions at non-coupling boundaries
+    # top and bottom boundary are non-coupling for both sides
+    sqr = domain.boundary['top,bottom,left' if side == 'Dirichlet'
+                          else 'top,bottom,right'].integral('(u - uexact)^2 d:x' @ ns, degree=degree * 2)
+
+    if side == 'Dirichlet':
+        sqr += read_sample.integral('(u - readfunc)^2 d:x' @ ns)
+    else:
+        res += read_sample.integral('basis_n readfunc d:x' @ ns)
+
+    # preCICE setup
+    interface = precice.Interface(side, "../precice-config.xml", 0, 1)
+
+    mesh_id_read = interface.get_mesh_id("Dirichlet-Mesh" if side == "Dirichlet" else "Neumann-Mesh")
+    mesh_id_write = interface.get_mesh_id("Neumann-Mesh" if side == "Dirichlet" else "Dirichlet-Mesh")
+
+    vertex_ids_read = interface.set_mesh_vertices(mesh_id_read, read_sample.eval(ns.x))
+    interface.set_mesh_access_region(mesh_id_write, [.9, 1.1, -.1, 1.1])
+
+    precice_dt = interface.initialize()
+
+    vertex_ids_write, coords = interface.get_mesh_vertices_and_ids(mesh_id_write)
+    write_sample = domain.locate(ns.x, coords, eps=1e-10, tol=1e-10)
+    precice_write = functools.partial(interface.write_block_scalar_data, interface.get_data_id(
+        "Heat-Flux" if side == "Dirichlet" else "Temperature", mesh_id_write), vertex_ids_write)
+    precice_read = functools.partial(interface.read_block_scalar_data, interface.get_data_id(
+        "Temperature" if side == "Dirichlet" else "Heat-Flux", mesh_id_read), vertex_ids_read)
+
+    # helper functions to project heat flux to coupling boundary
+    if side == 'Dirichlet':
+        # To communicate the flux to the Neumann side we should not simply
+        # evaluate u_,i n_i as this is an unbounded term leading to suboptimal
+        # convergence. Instead we project ∀ v: ∫_Γ v flux = ∫_Γ v u_,i n_i and
+        # evaluate flux. While the right-hand-side contains the same unbounded
+        # term, we can use the strong identity du/dt - u_,ii = f to rewrite it
+        # to ∫_Ω [v (du/dt - f) + v_,i u_,i] - ∫_∂Ω\Γ v u_,k n_k, in which we
+        # recognize the residual and an integral over the exterior boundary.
+        # While the latter still contains the problematic unbounded term, we
+        # can use the fact that the flux is a known value at the top and bottom
+        # via the Dirichlet boundary condition, and impose it as constraints.
+        right_sqr = domain.boundary['right'].integral('flux^2 d:x' @ ns, degree=degree * 2)
+        right_cons = solver.optimize('fluxdofs', right_sqr, droptol=1e-10)
+        # right_cons is NaN in dofs that are NOT supported on the right boundary
+        flux_sqr = domain.boundary['right'].boundary['top,bottom'].integral(
+            '(flux - uexact_,0)^2 d:x' @ ns, degree=degree * 2)
+        flux_cons = solver.optimize('fluxdofs', flux_sqr, droptol=1e-10,
+                                    constrain=np.choose(np.isnan(right_cons), [np.nan, 0.]))
+        # flux_cons is NaN in dofs that are supported on ONLY the right boundary
+        flux_res = read_sample.integral('basis_n flux d:x' @ ns) - res
+
+    # write initial data
+    if interface.is_action_required(precice.action_write_initial_data()):
+        precice_write(write_sample.eval(0.))
+        interface.mark_action_fulfilled(precice.action_write_initial_data())
+
+    interface.initialize_data()
+
+    t = 0.
+    istep = 0
+
+    # initial condition
+    sqr0 = domain.integral('(u - uexact)^2' @ ns, degree=degree * 2)
+    lhs = solver.optimize('lhs', sqr0, arguments=dict(t=t))
+    bezier = domain.sample('bezier', degree * 2)
+
+    while interface.is_coupling_ongoing():
+
+        # save checkpoint
+        if interface.is_action_required(precice.action_write_iteration_checkpoint()):
+            checkpoint = lhs, t, istep
+            interface.mark_action_fulfilled(precice.action_write_iteration_checkpoint())
+
+        # read data from interface
+        read_data = precice_read()
+
+        # prepare next timestep
+        lhs0 = lhs
+        istep += 1
+        dt = min(timestep, precice_dt)
+        t += dt
+
+        # update (time-dependent) boundary condition
+        cons = solver.optimize('lhs', sqr, droptol=1e-15, arguments=dict(t=t, readdata=read_data))
+
+        # solve nutils timestep
+        lhs = solver.solve_linear('lhs', res, constrain=cons, arguments=dict(lhs0=lhs0, dt=dt, t=t, readdata=read_data))
+
+        # write data to interface
+        if side == 'Dirichlet':
+            fluxdofs = solver.solve_linear(
+                'fluxdofs', flux_res, arguments=dict(
+                    lhs0=lhs0, lhs=lhs, dt=dt, t=t), constrain=flux_cons)
+            write_data = write_sample.eval('flux' @ ns, fluxdofs=fluxdofs)
+        else:
+            write_data = write_sample.eval('u' @ ns, lhs=lhs)
+        precice_write(write_data)
+
+        # do the coupling
+        precice_dt = interface.advance(dt)
+
+        # read checkpoint if required
+        if interface.is_action_required(precice.action_read_iteration_checkpoint()):
+            lhs, t, istep = checkpoint
+            interface.mark_action_fulfilled(precice.action_read_iteration_checkpoint())
+        else:
+            # generate output
+            x, u, uexact = bezier.eval(['x_i', 'u', 'uexact'] @ ns, lhs=lhs, t=t)
+            with treelog.add(treelog.DataLog()):
+                export.vtk(side + "-" + str(istep), bezier.tri, x, Temperature=u, reference=uexact)
+
+    interface.finalize()
+
+
+if __name__ == '__main__':
+    cli.run(main)
diff --git a/partitioned-heat-conduction-direct/nutils/run.sh b/partitioned-heat-conduction-direct/nutils/run.sh
new file mode 100755
index 000000000..4b531fe9c
--- /dev/null
+++ b/partitioned-heat-conduction-direct/nutils/run.sh
@@ -0,0 +1,25 @@
+#!/bin/sh
+set -e -u
+
+usage() { echo "Usage: cmd [-d] [-n]" 1>&2; exit 1; }
+
+# Check if no input argument was provided
+if [ -z "$*" ] ; then
+        usage
+fi
+
+while getopts ":dn" opt; do
+  case ${opt} in
+  d)
+    rm -rf Dirichlet-*.vtk
+    NUTILS_RICHOUTPUT=no python3 heat.py --side=Dirichlet
+    ;;
+  n)
+    rm -rf Neumann-*.vtk
+    NUTILS_RICHOUTPUT=no python3 heat.py --side=Neumann
+    ;;
+  *)
+    usage
+    ;;
+  esac
+done
diff --git a/partitioned-heat-conduction-direct/precice-config.xml b/partitioned-heat-conduction-direct/precice-config.xml
new file mode 100644
index 000000000..b3287af65
--- /dev/null
+++ b/partitioned-heat-conduction-direct/precice-config.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<precice-configuration>
+  <log>
+    <sink
+      filter="%Severity% > debug and %Rank% = 0"
+      format="---[precice] %ColorizedSeverity% %Message%"
+      enabled="true" />
+  </log>
+
+  <solver-interface dimensions="2" experimental="yes">
+    <data:scalar name="Temperature" />
+    <data:scalar name="Heat-Flux" />
+
+    <mesh name="Dirichlet-Mesh">
+      <use-data name="Temperature" />
+      <use-data name="Heat-Flux" />
+    </mesh>
+
+    <mesh name="Neumann-Mesh">
+      <use-data name="Temperature" />
+      <use-data name="Heat-Flux" />
+    </mesh>
+
+    <participant name="Dirichlet">
+      <use-mesh name="Dirichlet-Mesh" provide="yes" />
+      <use-mesh name="Neumann-Mesh" from="Neumann" direct-access="true" />
+      <write-data name="Heat-Flux" mesh="Neumann-Mesh" />
+      <read-data name="Temperature" mesh="Dirichlet-Mesh" />
+    </participant>
+
+    <participant name="Neumann">
+      <use-mesh name="Neumann-Mesh" provide="yes" />
+      <use-mesh name="Dirichlet-Mesh" from="Dirichlet" direct-access="true" />
+      <write-data name="Temperature" mesh="Dirichlet-Mesh" />
+      <read-data name="Heat-Flux" mesh="Neumann-Mesh" />
+    </participant>
+
+    <m2n:sockets from="Dirichlet" to="Neumann" exchange-directory=".." />
+
+    <coupling-scheme:parallel-implicit>
+      <participants first="Dirichlet" second="Neumann" />
+      <max-time value="1.0" />
+      <time-window-size value="0.1" />
+      <max-iterations value="100" />
+      <exchange data="Heat-Flux" mesh="Neumann-Mesh" from="Dirichlet" to="Neumann" />
+      <exchange data="Temperature" mesh="Dirichlet-Mesh" from="Neumann" to="Dirichlet" />
+      <relative-convergence-measure data="Heat-Flux" mesh="Neumann-Mesh" limit="1e-5" />
+      <relative-convergence-measure data="Temperature" mesh="Dirichlet-Mesh" limit="1e-5" />
+      <acceleration:IQN-ILS>
+        <data name="Temperature" mesh="Dirichlet-Mesh" />
+        <data name="Heat-Flux" mesh="Neumann-Mesh" />
+        <initial-relaxation value="0.1" />
+        <max-used-iterations value="10" />
+        <time-windows-reused value="5" />
+        <preconditioner type="residual-sum" />
+        <filter type="QR2" limit="1e-3" />
+      </acceleration:IQN-ILS>
+    </coupling-scheme:parallel-implicit>
+  </solver-interface>
+</precice-configuration>
diff --git a/partitioned-heat-conduction/nutils/run.sh b/partitioned-heat-conduction/nutils/run.sh
old mode 100644
new mode 100755
index 60e3f3911..4b531fe9c
--- a/partitioned-heat-conduction/nutils/run.sh
+++ b/partitioned-heat-conduction/nutils/run.sh
@@ -12,11 +12,11 @@ while getopts ":dn" opt; do
   case ${opt} in
   d)
     rm -rf Dirichlet-*.vtk
-    NUTILS_RICHOUTPUT=no python3 heat.py side=Dirichlet
+    NUTILS_RICHOUTPUT=no python3 heat.py --side=Dirichlet
     ;;
   n)
     rm -rf Neumann-*.vtk
-    NUTILS_RICHOUTPUT=no python3 heat.py side=Neumann
+    NUTILS_RICHOUTPUT=no python3 heat.py --side=Neumann
     ;;
   *)
     usage