diff --git a/doc/source/admin/how_it_works.rst b/doc/source/admin/how_it_works.rst
index aeb25ad28..d93c878c3 100644
--- a/doc/source/admin/how_it_works.rst
+++ b/doc/source/admin/how_it_works.rst
@@ -60,6 +60,33 @@ full endpoint of Ironic Inspector, for example::
 
 Make sure your DHCP environment is set to boot IPA by default.
 
+For the cases where the infrastructure operator and cloud user are the same,
+an additional tool exists that can be installed alongside the agent inside
+a running instance. This is the ``ironic-collect-introspection-data``
+command which allows for a node in ``ACTIVE`` state to publish updated
+introspection data to ironic-inspector. This ability requires ironic-inspector
+to be configured with ``[processing]permit_active_introspection`` set to
+``True``. For example::
+
+    ironic-collect-introspection-data --inspection_callback_url http://IP:5050/v1/continue
+
+Alternatively, this command may also be used with multicast DNS
+functionality to identify the `Ironic Inspector`_ service endpoint.
+For example::
+
+    ironic-collect-introspection-data --inspection_callback_url mdns
+
+An additional daemon mode may be useful for some operators who wish to receive
+regular updates, in the form of the ``[DEFAULT]introspection_daemon`` boolean
+configuration option.
+For example::
+
+    ironic-collect-introspection-data --inspection_callback_url mdns --introspection_daemon
+
+The above command will attempt to connect to introspection and will then enter
+a loop to publish every 300 seconds. This can be tuned with the
+``[DEFAULT]introspection_daemon_post_interval`` configuration option.
+
 .. _Ironic Inspector: https://docs.openstack.org/ironic-inspector/
 
 Hardware Inventory
diff --git a/ironic_python_agent/agent.py b/ironic_python_agent/agent.py
index 390afce8e..4641b82e3 100644
--- a/ironic_python_agent/agent.py
+++ b/ironic_python_agent/agent.py
@@ -388,8 +388,13 @@ class IronicPythonAgent(base.ExecuteCommandMixin):
             # lookup will fail due to unknown MAC.
             uuid = None
             if cfg.CONF.inspection_callback_url:
-                uuid = inspector.inspect()
-
+                try:
+                    # Attempt inspection. This may fail, and previously
+                    # an error would be logged.
+                    uuid = inspector.inspect()
+                except errors.InspectionError as e:
+                    LOG.error('Failed to perform inspection: %(err)s',
+                              {'error': e})
             if self.api_url:
                 self._wait_for_interface()
                 content = self.api_client.lookup_node(
diff --git a/ironic_python_agent/cmd/inspect.py b/ironic_python_agent/cmd/inspect.py
new file mode 100644
index 000000000..af747f7e0
--- /dev/null
+++ b/ironic_python_agent/cmd/inspect.py
@@ -0,0 +1,30 @@
+# Copyright 2013 Rackspace, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+
+from oslo_config import cfg
+from oslo_log import log
+
+from ironic_python_agent import inspect as inspection
+
+CONF = cfg.CONF
+
+
+def run():
+    """Entrypoint for IronicPythonAgent."""
+    log.register_options(CONF)
+    CONF(args=sys.argv[1:])
+    log.setup(CONF, 'ironic-python-agent')
+    inspection.IronicInspection().run()
diff --git a/ironic_python_agent/config.py b/ironic_python_agent/config.py
index 5bd1cdc2d..2b65bcfea 100644
--- a/ironic_python_agent/config.py
+++ b/ironic_python_agent/config.py
@@ -217,6 +217,18 @@ cli_opts = [
                     'Must be provided together with "certfile" option. '
                     'Default is to not present any client certificates to '
                     'the server.'),
+    cfg.BoolOpt('introspection_daemon',
+                default=False,
+                help='When the ``ironic-collect-introspection-data`` '
+                     'command is executed, continue running as '
+                     'a background process and continue to post data '
+                     'to the bare metal inspection service.'),
+    cfg.IntOpt('introspection_daemon_post_interval',
+               default=300,
+               help='The interval in seconds by which to transmit data to '
+                    'the bare metal introspection service when the '
+                    '``ironic-collect-introspection-data`` program is '
+                    'executing in daemon mode.'),
 ]
 
 CONF.register_cli_opts(cli_opts)
diff --git a/ironic_python_agent/inspect.py b/ironic_python_agent/inspect.py
new file mode 100644
index 000000000..b8b8fd73b
--- /dev/null
+++ b/ironic_python_agent/inspect.py
@@ -0,0 +1,86 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import select
+import threading
+
+from ironic_lib import exception
+from oslo_config import cfg
+from oslo_log import log
+
+from ironic_python_agent import errors
+from ironic_python_agent import inspector
+
+LOG = log.getLogger(__name__)
+
+
+class IronicInspection(threading.Thread):
+    """Class for manual inspection functionality."""
+
+    def __init__(self):
+        super(IronicInspection, self).__init__()
+        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.")
+
+    def _run(self):
+        try:
+            daemon_mode = cfg.CONF.introspection_daemon
+            post_interval = cfg.CONF.introspection_daemon_post_interval
+
+            inspector.inspect()
+            if not daemon_mode:
+                # No reason to continue unless we're in daemon mode.
+                return
+
+            self.reader, self.writer = os.pipe()
+            p = select.poll()
+            p.register(self.reader)
+
+            try:
+                while daemon_mode:
+                    LOG.info('Sleeping until next check-in.')
+                    # TODO(TheJulia): It would likely be good to introduce
+                    # some jitter into this at some point...
+                    if p.poll(post_interval * 1000):
+                        if os.read(self.reader, 1).decode() == 'a':
+                            break
+                    try:
+                        inspector.inspect()
+                    except errors.InspectionError as e:
+                        # Failures happen, no reason to exit as
+                        # the failure could be intermittent.
+                        LOG.warning('Error reporting introspection '
+                                    'data: %(err)s',
+                                    {'err': e})
+                    except exception.ServiceLookupFailure as e:
+                        # Likely a mDNS lookup failure. We should
+                        # keep retrying.
+                        LOG.error('Error looking up introspection '
+                                  'endpoint: %(err)s',
+                                  {'err': e})
+
+            finally:
+                os.close(self.reader)
+                os.close(self.writer)
+                self.reader = None
+                self.writer = None
+        except errors.InspectionError as e:
+            msg = "Inspection failed: %s" % e
+            raise errors.InspectionError(msg)
+
+    def run(self):
+        """Run Inspection."""
+        if not cfg.CONF.inspection_callback_url:
+            cfg.CONF.set_override('inspection_callback_url', 'mdns')
+        self._run()
diff --git a/ironic_python_agent/inspector.py b/ironic_python_agent/inspector.py
index 54de65d07..6b6458619 100644
--- a/ironic_python_agent/inspector.py
+++ b/ironic_python_agent/inspector.py
@@ -104,8 +104,8 @@ def inspect():
     failures.raise_if_needed()
 
     if resp is None:
-        LOG.info('stopping inspection, as inspector returned an error')
-        return
+        raise errors.InspectionError('stopping inspection, as inspector '
+                                     'returned an error')
 
     LOG.info('inspection finished successfully')
     return resp.get('uuid')
diff --git a/ironic_python_agent/tests/unit/test_inspector.py b/ironic_python_agent/tests/unit/test_inspector.py
index 501e8c1e5..10f9f9e41 100644
--- a/ironic_python_agent/tests/unit/test_inspector.py
+++ b/ironic_python_agent/tests/unit/test_inspector.py
@@ -137,11 +137,11 @@ class TestInspect(base.IronicAgentTest):
         mock_call.return_value = None
         mock_ext_mgr.return_value = [self.mock_ext]
 
-        result = inspector.inspect()
+        self.assertRaises(errors.InspectionError,
+                          inspector.inspect)
 
         self.mock_collect.assert_called_with_failure()
         mock_call.assert_called_with_failure()
-        self.assertIsNone(result)
 
 
 @mock.patch.object(requests, 'post', autospec=True)
diff --git a/releasenotes/notes/manual-introspection-b04b5c25f5e004ac.yaml b/releasenotes/notes/manual-introspection-b04b5c25f5e004ac.yaml
new file mode 100644
index 000000000..5918ee917
--- /dev/null
+++ b/releasenotes/notes/manual-introspection-b04b5c25f5e004ac.yaml
@@ -0,0 +1,13 @@
+---
+features:
+  - |
+    Adds a new CLI command ``ironic-collect-introspection-data`` to enable
+    manually publishing into the ``baremetal-introspection`` service.
+    Executing this command on a system unknown to the Bare Metal service
+    will likely result in the machine becoming registered to Ironic, and
+    as such this command should be used with caution.
+
+    If the capability to update introspection data for running machines
+    has been enabled in the Bare Metal introspection service, then an
+    operator may use this command in the ``active`` or ``rescue`` states
+    to update introspection data.
diff --git a/setup.cfg b/setup.cfg
index 1344c04d1..e287f18f1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -29,6 +29,7 @@ oslo.config.opts =
 
 console_scripts =
     ironic-python-agent = ironic_python_agent.cmd.agent:run
+    ironic-collect-introspection-data = ironic_python_agent.cmd.inspect:run
 
 ironic_python_agent.extensions =
     standby = ironic_python_agent.extensions.standby:StandbyExtension