diff --git a/ironic_python_agent/agent.py b/ironic_python_agent/agent.py
index 5eb1e28dd..b2e2e38eb 100644
--- a/ironic_python_agent/agent.py
+++ b/ironic_python_agent/agent.py
@@ -29,7 +29,6 @@ from oslo_concurrency import processutils
 from oslo_config import cfg
 from oslo_log import log
 import pkg_resources
-from stevedore import extension
 
 from ironic_python_agent.api import app
 from ironic_python_agent import config
@@ -171,12 +170,7 @@ class IronicPythonAgent(base.ExecuteCommandMixin):
         if bool(cfg.CONF.keyfile) != bool(cfg.CONF.certfile):
             LOG.warning("Only one of 'keyfile' and 'certfile' options is "
                         "defined in config file. Its value will be ignored.")
-        self.ext_mgr = extension.ExtensionManager(
-            namespace='ironic_python_agent.extensions',
-            invoke_on_load=True,
-            propagate_map_exceptions=True,
-            invoke_kwds={'agent': self},
-        )
+        self.ext_mgr = base.init_ext_manager(self)
         self.api_url = api_url
         if not self.api_url or self.api_url == 'mdns':
             try:
diff --git a/ironic_python_agent/extensions/base.py b/ironic_python_agent/extensions/base.py
index a4314098b..d2ea4337b 100644
--- a/ironic_python_agent/extensions/base.py
+++ b/ironic_python_agent/extensions/base.py
@@ -19,6 +19,7 @@ import threading
 
 from oslo_log import log
 from oslo_utils import uuidutils
+from stevedore import extension
 
 from ironic_python_agent import encoding
 from ironic_python_agent import errors
@@ -331,3 +332,25 @@ def sync_command(command_name, validator=None):
 
         return wrapper
     return sync_decorator
+
+
+_EXT_MANAGER = None
+
+
+def init_ext_manager(agent):
+    global _EXT_MANAGER
+    _EXT_MANAGER = extension.ExtensionManager(
+        namespace='ironic_python_agent.extensions',
+        invoke_on_load=True,
+        propagate_map_exceptions=True,
+        invoke_kwds={'agent': agent},
+    )
+    return _EXT_MANAGER
+
+
+def get_extension(name):
+    if _EXT_MANAGER is None:
+        raise errors.ExtensionError('Extension manager is not initialized')
+    ext = _EXT_MANAGER[name].obj
+    ext.ext_mgr = _EXT_MANAGER
+    return ext
diff --git a/ironic_python_agent/hardware.py b/ironic_python_agent/hardware.py
index a5b31c737..fb1a21020 100644
--- a/ironic_python_agent/hardware.py
+++ b/ironic_python_agent/hardware.py
@@ -37,6 +37,7 @@ import yaml
 
 from ironic_python_agent import encoding
 from ironic_python_agent import errors
+from ironic_python_agent.extensions import base as ext_base
 from ironic_python_agent import netutils
 from ironic_python_agent import raid_utils
 from ironic_python_agent import utils
@@ -1566,6 +1567,15 @@ class GenericHardwareManager(HardwareManager):
                 'reboot_requested': False,
                 'argsinfo': RAID_APPLY_CONFIGURATION_ARGSINFO,
             },
+            {
+                'step': 'write_image',
+                # NOTE(dtantsur): this step has to be proxied via an
+                # out-of-band step with the same name, hence the priority here
+                # doesn't really matter.
+                'priority': 0,
+                'interface': 'deploy',
+                'reboot_requested': False,
+            },
         ]
 
     def apply_configuration(self, node, ports, raid_config,
@@ -1980,6 +1990,25 @@ class GenericHardwareManager(HardwareManager):
                                       'errors': '; '.join(raid_errors)}
             raise errors.SoftwareRAIDError(error)
 
+    def write_image(self, node, ports, image_info, configdrive=None):
+        """A deploy step to write an image.
+
+        Downloads and writes an image to disk if necessary. Also writes a
+        configdrive to disk if the configdrive parameter is specified.
+
+        :param node: A dictionary of the node object
+        :param ports: A list of dictionaries containing information
+                      of ports for the node
+        :param image_info: Image information dictionary.
+        :param configdrive: A string containing the location of the config
+                            drive as a URL OR the contents (as gzip/base64)
+                            of the configdrive. Optional, defaults to None.
+        """
+        ext = ext_base.get_extension('standby')
+        cmd = ext.prepare_image(image_info=image_info, configdrive=configdrive)
+        # The result is asynchronous, wait here.
+        cmd.join()
+
 
 def _compare_extensions(ext1, ext2):
     mgr1 = ext1.obj
diff --git a/ironic_python_agent/tests/unit/base.py b/ironic_python_agent/tests/unit/base.py
index 033c39cb6..941969856 100644
--- a/ironic_python_agent/tests/unit/base.py
+++ b/ironic_python_agent/tests/unit/base.py
@@ -23,6 +23,7 @@ from oslo_config import cfg
 from oslo_config import fixture as config_fixture
 from oslotest import base as test_base
 
+from ironic_python_agent.extensions import base as ext_base
 from ironic_python_agent import utils
 
 CONF = cfg.CONF
@@ -58,6 +59,8 @@ class IronicAgentTest(test_base.BaseTestCase):
             self.patch(subprocess, 'check_output', do_not_call)
             self.patch(utils, 'execute', do_not_call)
 
+        ext_base._EXT_MANAGER = None
+
     def _set_config(self):
         self.cfg_fixture = self.useFixture(config_fixture.Config(CONF))