diff --git a/include/crm/common/output.h b/include/crm/common/output.h
new file mode 100644
index 0000000000..f31b78430f
--- /dev/null
+++ b/include/crm/common/output.h
@@ -0,0 +1,513 @@
+/*
+ * Copyright 2019 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU Lesser General Public License
+ * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
+ */
+
+#ifndef CRM_OUTPUT__H
+#  define CRM_OUTPUT__H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * \file
+ * \brief Formatted output for pacemaker tools
+ */
+
+#  include <stdio.h>
+#  include <libxml/tree.h>
+
+#  include <glib.h>
+#  include <crm/common/results.h>
+
+#  define PCMK__API_VERSION "1.0"
+
+/* Add to the long_options block in each tool to get the formatted output
+ * command line options added.  Then call pcmk__parse_output_args to handle
+ * them.
+ */
+#  define PCMK__OUTPUT_OPTIONS(fmts) \
+    {   "output-as", required_argument, NULL, 0, \
+        "Specify the format for output, one of: " fmts \
+    }, \
+    {   "output-to", required_argument, NULL, 0, \
+        "Specify the destination for formatted output, \"-\" for stdout or a filename" \
+    }
+
+typedef struct pcmk__output_s pcmk__output_t;
+
+/*!
+ * \internal
+ * \brief The type of a function that creates a ::pcmk__output_t.
+ *
+ * Instances of this type are passed to pcmk__register_format(), stored in an
+ * internal data structure, and later accessed by pcmk__output_new().  For 
+ * examples, see pcmk__mk_xml_output() and pcmk__mk_text_output().
+ *
+ * \param[in] argv The list of command line arguments.
+ */
+typedef pcmk__output_t * (*pcmk__output_factory_t)(char **argv);
+
+/*!
+ * \internal
+ * \brief The type of a custom message formatting function.
+ *
+ * These functions are defined by various libraries to support formatting of
+ * types aside from the basic types provided by a ::pcmk__output_t.
+ *
+ * The meaning of the return value will be different for each message.
+ * In general, however, 0 should be returned on success and a positive value
+ * on error.
+ *
+ * \note These functions must not call va_start or va_end - that is done
+ *       automatically before the custom formatting function is called.
+ */
+typedef int (*pcmk__message_fn_t)(pcmk__output_t *out, va_list args);
+
+/*!
+ * \internal
+ * \brief Internal type for tracking custom messages.
+ *
+ * Each library can register functions that format custom message types.  These
+ * are commonly used to handle some library-specific type.  Registration is
+ * done by first defining a table of ::pcmk__message_entry_t structures and
+ * then passing that table to pcmk__register_messages().  Separate handlers
+ * can be defined for the same message, but for different formats (xml vs.
+ * text).  Unknown formats will be ignored.
+ */
+typedef struct pcmk__message_entry_s {
+    /*!
+     * \brief The message to be handled.
+     *
+     * This must be the same ID that is passed to the message function of
+     * a ::pcmk__output_t.  Unknown message IDs will be ignored.
+     */
+    const char *message_id;
+
+    /*!
+     * \brief The format type this handler is for.
+     *
+     * This name must match the fmt_name of the currently active formatter in
+     * order for the registered function to be called.  It is valid to have
+     * multiple entries for the same message_id but with different fmt_name
+     * values.
+     */
+    const char *fmt_name;
+
+    /*!
+     * \brief The function to be called for message_id given a match on
+     *        fmt_name.  See comments on ::pcmk__message_fn_t.
+     */
+    pcmk__message_fn_t fn;
+} pcmk__message_entry_t;
+
+/* Basic formatters everything supports.  This block needs to be updated every
+ * time a new base formatter is added.
+ */
+pcmk__output_t *pcmk__mk_text_output(char **argv);
+pcmk__output_t *pcmk__mk_xml_output(char **argv);
+
+/*!
+ * \brief This structure contains everything that makes up a single output
+ *        formatter.
+ *
+ * Instances of this structure may be created by calling pcmk__output_new()
+ * with the name of the desired formatter.  They should later be freed with
+ * pcmk__output_free().
+ */
+struct pcmk__output_s {
+    /*!
+     * \brief The name of this output formatter.
+     */
+    char *fmt_name;
+
+    /*!
+     * \brief A copy of the request that generated this output.
+     *
+     * In the case of command line usage, this would be the command line
+     * arguments.  For other use cases, it could be different.
+     */
+    char *request;
+
+    /*!
+     * \brief Does this formatter support a special quiet mode?
+     *
+     * In this mode, most output can be supressed but some information is still
+     * displayed to an interactive user.  In general, machine-readable output
+     * formats will not support this while user-oriented formats will.
+     */
+    bool supports_quiet;
+
+    /*!
+     * \brief Where output should be written.
+     *
+     * This could be a file handle, or stdout or stderr.  This is really only
+     * useful internally.
+     */
+    FILE *dest;
+
+    /*!
+     * \brief Custom messages that are currently registered on this formatter.
+     *
+     * Keys are the string message IDs, values are ::pcmk__message_fn_t function
+     * pointers.
+     */
+    GHashTable *messages;
+
+    /*!
+     * \brief Implementation-specific private data.
+     *
+     * Each individual formatter may have some private data useful in its
+     * implementation.  This points to that data.  Callers should not rely on
+     * its contents or structure.
+     */
+    void *priv;
+
+    /*!
+     * \internal
+     * \brief Take whatever actions are necessary to prepare out for use.  This is
+     *        called by pcmk__output_new().  End users should not need to call this.
+     *
+     * \note For formatted output implementers - This function should be written in
+     *       such a way that it can be called repeatedly on an already initialized
+     *       object without causing problems, or on a previously finished object
+     *       without crashing.
+     *
+     * \param[in,out] out The output functions structure.
+     *
+     * \return true on success, false on error.
+     */
+    bool (*init) (pcmk__output_t *out);
+
+    /*!
+     * \internal
+     * \brief Free the private formatter-specific data.
+     *
+     * This is called from pcmk__output_free() and does not typically need to be
+     * called directly.
+     *
+     * \param[in,out] out The output functions structure.
+     */
+    void (*free_priv)(pcmk__output_t *out);
+
+    /*!
+     * \internal
+     * \brief Take whatever actions are necessary to end formatted output.
+     *
+     * This could include flushing output to a file, but does not include freeing
+     * anything.  Note that pcmk__output_free() will automatically call this
+     * function, so there is typically no need to do so manually.
+     *
+     * \note For formatted output implementers - This function should be written in
+     *       such a way that it can be called repeatedly on a previously finished
+     *       object without crashing.
+     *
+     * \param[in,out] out         The output functions structure.
+     * \param[in]     exit_status The exit value of the whole program.
+     */
+    void (*finish) (pcmk__output_t *out, crm_exit_t exit_status);
+
+    /*!
+     * \internal
+     * \brief Finalize output and then immediately set back up to start a new set
+     *        of output.
+     *
+     * This is conceptually the same as calling finish and then init, though in
+     * practice more be happening behind the scenes.
+     *
+     * \note This function differs from finish in that no exit_status is added.
+     *       The idea is that the program is not shutting down, so there is not
+     *       yet a final exit code.  Call finish on the last time through if this
+     *       is needed.
+     *
+     * \param[in,out] out The output functions structure.
+     */
+    void (*reset) (pcmk__output_t *out);
+
+    /*!
+     * \internal
+     * \brief Register a custom message.
+     *
+     * \param[in,out] out        The output functions structure.
+     * \param[in]     message_id The name of the message to register.  This name
+     *                           will be used as the message_id parameter to the
+     *                           message function in order to call the custom
+     *                           format function.
+     * \param[in]     fn         The custom format function to call for message_id.
+     */
+    void (*register_message) (pcmk__output_t *out, const char *message_id,
+                              pcmk__message_fn_t fn);
+
+    /*!
+     * \internal
+     * \brief Call a previously registered custom message.
+     *
+     * \param[in,out] out        The output functions structure.
+     * \param[in]     message_id The name of the message to call.  This name must
+     *                           be the same as the message_id parameter of some
+     *                           previous call to register_message.
+     * \param[in] ...            Arguments to be passed to the registered function.
+     *
+     * \return 0 if a function was registered for the message, that function was
+     *         called, and returned successfully.  A negative value is returned if
+     *         no function was registered.  A positive value is returned if the
+     *         function was called but encountered an error.
+     */
+    int (*message) (pcmk__output_t *out, const char *message_id, ...);
+
+    /*!
+     * \internal
+     * \brief Format the output of a completed subprocess.
+     *
+     * \param[in,out] out         The output functions structure.
+     * \param[in]     exit_status The exit value of the subprocess.
+     * \param[in]     proc_stdout stdout from the completed subprocess.
+     * \param[in]     proc_stderr stderr from the completed subprocess.
+     */
+    void (*subprocess_output) (pcmk__output_t *out, int exit_status,
+                               const char *proc_stdout, const char *proc_stderr);
+
+    /*!
+     * \internal
+     * \brief Format an informational message that should be shown to
+     *        to an interactive user.  Not all formatters will do this.
+     *
+     * \note A newline will automatically be added to the end of the format
+     *       string, so callers should not include a newline.
+     *
+     * \param[in,out] out The output functions structure.
+     * \param[in]     buf The message to be printed.
+     * \param[in]     ... Arguments to be formatted.
+     */
+    void (*info) (pcmk__output_t *out, const char *format, ...) G_GNUC_PRINTF(2, 3);
+
+    /*!
+     * \internal
+     * \brief Format already formatted XML.
+     *
+     * \param[in,out] out  The output functions structure.
+     * \param[in]     name A name to associate with the XML.
+     * \param[in]     buf  The XML in a string.
+     */
+    void (*output_xml) (pcmk__output_t *out, const char *name, const char *buf);
+
+    /*!
+     * \internal
+     * \brief Start a new list of items.
+     *
+     * \note For text output, this corresponds to another level of indentation.  For
+     *       XML output, this corresponds to wrapping any following output in another
+     *       layer of tags.
+     *
+     * \note If singular_noun and plural_noun are non-NULL, calling end_list will
+     *       result in a summary being added.
+     *
+     * \param[in,out] out           The output functions structure.
+     * \param[in]     name          A descriptive, user-facing name for this list.
+     * \param[in]     singular_noun When outputting the summary for a list with
+     *                              one item, the noun to use.
+     * \param[in]     plural_noun   When outputting the summary for a list with
+     *                              more than one item, the noun to use.
+     */
+    void (*begin_list) (pcmk__output_t *out, const char *name,
+                        const char *singular_noun, const char *plural_noun);
+
+    /*!
+     * \internal
+     * \brief Format a single item in a list.
+     *
+     * \param[in,out] out     The output functions structure.
+     * \param[in]     name    A name to associate with this item.
+     * \param[in]     content The item to be formatted.
+     */
+    void (*list_item) (pcmk__output_t *out, const char *name, const char *content);
+
+    /*!
+     * \internal
+     * \brief Conclude a list.
+     *
+     * \note If begin_list was called with non-NULL for both the singular_noun
+     *       and plural_noun arguments, this function will output a summary.
+     *       Otherwise, no summary will be added.
+     *
+     * \param[in,out] out The output functions structure.
+     */
+    void (*end_list) (pcmk__output_t *out);
+};
+
+/*!
+ * \internal
+ * \brief Call a formatting function for a previously registered message.
+ *
+ * \note This function is for implementing custom formatters.  It should not
+ *       be called directly.  Instead, call out->message.
+ *
+ * \param[in,out] out        The output functions structure.
+ * \param[in]     message_id The message to be handled.  Unknown messages
+ *                           will be ignored.
+ * \param[in]     ...        Arguments to be passed to the registered function.
+ */
+int
+pcmk__call_message(pcmk__output_t *out, const char *message_id, ...);
+
+/*!
+ * \internal
+ * \brief Free a ::pcmk__output_t structure that was previously created by
+ *        pcmk__output_new().  This will first call the finish function.
+ *
+ * \note While the create and finish functions are designed in such a way that
+ *       they can be called repeatedly, this function will completely free the
+ *       memory of the object.  Once this function has been called, producing
+ *       more output requires starting over from pcmk__output_new().
+ *
+ * \param[in,out] out         The output structure.
+ * \param[in]     exit_status The exit value of the whole program.
+ */
+void pcmk__output_free(pcmk__output_t *out, crm_exit_t exit_status);
+
+/*!
+ * \internal
+ * \brief Create a new ::pcmk__output_t structure.
+ *
+ * \param[in,out] out      The destination of the new ::pcmk__output_t.
+ * \param[in]     fmt_name How should output be formatted?
+ * \param[in]     filename Where should formatted output be written to?  This
+ *                         can be a filename (which will be overwritten if it
+ *                         already exists), or NULL or "-" for stdout.  For no
+ *                         output, pass a filename of "/dev/null".
+ * \param[in]     argv     The list of command line arguments.
+ *
+ * \return 0 on success or an error code on error.
+ */
+int pcmk__output_new(pcmk__output_t **out, const char *fmt_name,
+                     const char *filename, char **argv);
+
+/*!
+ * \internal
+ * \brief Process formatted output related command line options.  This should
+ *        be called wherever other long options are handled.
+ *
+ * \param[in]  argname      The long command line argument to process.
+ * \param[in]  argvalue     The value of the command line argument.
+ * \param[out] output_ty   How should output be formatted? ("text", "xml", etc.)
+ * \param[out] output_dest Where should formatted output be written to?  This is
+ *                         typically a filename, but could be NULL or "-".
+ *
+ * \return true if longname was handled, false otherwise.
+ */
+bool
+pcmk__parse_output_args(const char *argname, char *argvalue, char **output_ty,
+                        char **output_dest);
+
+/*!
+ * \internal
+ * \brief Register a new output formatter, making it available for use
+ *        the same as a base formatter.
+ *
+ * \param[in] fmt The new output formatter to register.
+ *
+ * \return 0 on success or an error code on error.
+ */
+int
+pcmk__register_format(const char *fmt_name, pcmk__output_factory_t create);
+
+
+/*!
+ * \internal
+ * \brief Register a function to handle a custom message.
+ *
+ * \note This function is for implementing custom formatters.  It should not
+ *       be called directly.  Instead, call out->register_message.
+ *
+ * \param[in,out] out        The output functions structure.
+ * \param[in]     message_id The message to be handled.
+ * \param[in]     fn         The custom format function to call for message_id.
+ */
+void
+pcmk__register_message(pcmk__output_t *out, const char *message_id,
+                       pcmk__message_fn_t fn);
+
+/*!
+ * \internal
+ * \brief Register an entire table of custom formatting functions at once.
+ *
+ * This table can contain multiple formatting functions for the same message ID
+ * if they are for different format types.
+ *
+ * \param[in,out] out   The output functions structure.
+ * \param[in]     table An array of ::pcmk__message_entry_t values which should
+ *                      all be registered.  This array must be NULL-terminated.
+ */
+void
+pcmk__register_messages(pcmk__output_t *out, pcmk__message_entry_t *table);
+
+/* Functions that are useful for implementing custom message formatters */
+
+/*!
+ * \internal
+ * \brief A printf-like function.
+ *
+ * This function writes to out->dest and indents the text to the current level
+ * of the text formatter's nesting.  This should be used when implementing
+ * custom message functions instead of printf.
+ *
+ * \param[in,out] out The output functions structure.
+ */
+void
+pcmk__indented_printf(pcmk__output_t *out, const char *format, ...) G_GNUC_PRINTF(2, 3);
+
+/*!
+ * \internal
+ * \brief Add the given node as a child of the current list parent.  This is
+ *        used when implementing custom message functions.
+ *
+ * \param[in,out] out  The output functions structure.
+ * \param[in]     node An XML node to be added as a child.
+ */
+void
+pcmk__xml_add_node(pcmk__output_t *out, xmlNodePtr node);
+
+/*!
+ * \internal
+ * \brief Push a parent XML node onto the stack.  This is used when implementing
+ *        custom message functions.
+ *
+ * The XML output formatter maintains an internal stack to keep track of which nodes
+ * are parents in order to build up the tree structure.  This function can be used
+ * to temporarily push a new node onto the stack.  After calling this function, any
+ * other formatting functions will have their nodes added as children of this new
+ * parent.
+ *
+ * \param[in,out] out  The output functions structure.
+ * \param[in]     node The node to be added/
+ */
+void
+pcmk__xml_push_parent(pcmk__output_t *out, xmlNodePtr node);
+
+/*!
+ * \internal
+ * \brief Pop a parent XML node onto the stack.  This is used when implementing
+ *        custom message functions.
+ *
+ * This function removes a parent node from the stack.  See pcmk__xml_push_parent()
+ * for more details.
+ *
+ * \note Little checking is done with this function.  Be sure you only pop parents
+ * that were previously pushed.  In general, it is best to keep the code between
+ * push and pop simple.
+ *
+ * \param[in,out] out The output functions structure.
+ */
+void
+pcmk__xml_pop_parent(pcmk__output_t *out);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/lib/common/Makefile.am b/lib/common/Makefile.am
index 78f8d4e38b..f8bebd7acd 100644
--- a/lib/common/Makefile.am
+++ b/lib/common/Makefile.am
@@ -1,43 +1,44 @@
 #
 # Copyright 2004-2019 Andrew Beekhof <andrew@beekhof.net>
 #
 # This source code is licensed under the GNU General Public License version 2
 # or later (GPLv2+) WITHOUT ANY WARRANTY.
 #
 include $(top_srcdir)/Makefile.common
 
 AM_CPPFLAGS		+= -I$(top_builddir)/lib/gnu -I$(top_srcdir)/lib/gnu -DPCMK_SCHEMAS_EMERGENCY_XSLT=0
 
 ## libraries
 lib_LTLIBRARIES	= libcrmcommon.la
 
 # Disable -Wcast-qual if used, because we do some hacky casting,
 # and because libxml2 has some signatures that should be const but aren't
 # for backward compatibility reasons.
 
 # s390 needs -fPIC 
 # s390-suse-linux/bin/ld: .libs/ipc.o: relocation R_390_PC32DBL against `__stack_chk_fail@@GLIBC_2.4' can not be used when making a shared object; recompile with -fPIC
 
 CFLAGS		= $(CFLAGS_COPY:-Wcast-qual=) -fPIC
 
 noinst_HEADERS		= crmcommon_private.h
 
 libcrmcommon_la_LDFLAGS	= -version-info 35:0:1
 
 libcrmcommon_la_CFLAGS	= $(CFLAGS_HARDENED_LIB)
 libcrmcommon_la_LDFLAGS	+= $(LDFLAGS_HARDENED_LIB)
 
 libcrmcommon_la_LIBADD	= @LIBADD_DL@ $(GNUTLSLIBS)
 
 libcrmcommon_la_SOURCES	= compat.c digest.c ipc.c io.c procfs.c utils.c xml.c	\
 			  iso8601.c remote.c mainloop.c logging.c watchdog.c	\
 			  schemas.c strings.c xpath.c attrd_client.c alerts.c	\
-			  operations.c pid.c results.c acl.c agents.c nvpair.c
+			  operations.c pid.c results.c acl.c agents.c nvpair.c \
+			  output.c output_text.c output_xml.c
 if BUILD_CIBSECRETS
 libcrmcommon_la_SOURCES	+= cib_secrets.c
 endif
 #libcrmcommon_la_SOURCES	+= $(top_builddir)/lib/gnu/md5.c
 libcrmcommon_la_SOURCES	+= ../gnu/md5.c
 
 clean-generic:
 	rm -f *.log *.debug *.xml *~
diff --git a/lib/common/output.c b/lib/common/output.c
new file mode 100644
index 0000000000..534a377c03
--- /dev/null
+++ b/lib/common/output.c
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2019 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU Lesser General Public License
+ * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm/common/util.h>
+#include <crm/common/xml.h>
+#include <crm/common/internal.h>
+#include <crm/common/output.h>
+#include <libxml/tree.h>
+
+static GHashTable *formatters = NULL;
+
+void
+pcmk__output_free(pcmk__output_t *out, crm_exit_t exit_status) {
+    pcmk__output_factory_t fn = g_hash_table_lookup(formatters, out->fmt_name);
+    CRM_ASSERT(fn != NULL);
+
+    out->finish(out, exit_status);
+    out->free_priv(out);
+
+    g_hash_table_destroy(out->messages);
+    free(out->fmt_name);
+    free(out->request);
+    free(out);
+}
+
+int
+pcmk__output_new(pcmk__output_t **out, const char *fmt_name, const char *filename,
+                 char **argv) {
+    pcmk__output_factory_t create = NULL;;
+
+    if (formatters == NULL) {
+        return EINVAL;
+    }
+
+    /* If no name was given, just try "text".  It's up to each tool to register
+     * what it supports so this also may not be valid.
+     */
+    if (fmt_name == NULL) {
+        create = g_hash_table_lookup(formatters, "text");
+    } else {
+        create = g_hash_table_lookup(formatters, fmt_name);
+    }
+
+    if (create == NULL) {
+        return pcmk_err_unknown_format;
+    }
+
+    *out = create(argv);
+    if (*out == NULL) {
+        return ENOMEM;
+    }
+
+    if (fmt_name == NULL) {
+        (*out)->fmt_name = strdup("text");
+    } else {
+        (*out)->fmt_name = strdup(fmt_name);
+    }
+
+    if (filename == NULL || safe_str_eq(filename, "-")) {
+        (*out)->dest = stdout;
+    } else {
+        (*out)->dest = fopen(filename, "w");
+        if ((*out)->dest == NULL) {
+            return errno;
+        }
+    }
+
+    (*out)->messages = g_hash_table_new_full(crm_str_hash, g_str_equal, free, NULL);
+
+    if ((*out)->init(*out) == false) {
+        pcmk__output_free(*out, 0);
+        return ENOMEM;
+    }
+
+    return 0;
+}
+
+bool
+pcmk__parse_output_args(const char *argname, char *argvalue, char **output_ty, char **output_dest) {
+    if (safe_str_eq("output-as", argname)) {
+        *output_ty = argvalue;
+        return true;
+    } else if (safe_str_eq("output-to", argname)) {
+        if (safe_str_eq(argvalue, "-")) {
+            *output_dest = NULL;
+        } else {
+            *output_dest = argvalue;
+        }
+
+        return true;
+    }
+
+    return false;
+}
+
+int
+pcmk__register_format(const char *fmt_name, pcmk__output_factory_t create) {
+    if (create == NULL) {
+        return -EINVAL;
+    }
+
+    if (formatters == NULL) {
+        formatters = g_hash_table_new_full(crm_str_hash, g_str_equal, NULL, NULL);
+    }
+
+    g_hash_table_insert(formatters, strdup(fmt_name), create);
+    return 0;
+}
+
+int
+pcmk__call_message(pcmk__output_t *out, const char *message_id, ...) {
+    va_list args;
+    int rc = 0;
+    pcmk__message_fn_t fn;
+
+    fn = g_hash_table_lookup(out->messages, message_id);
+    if (fn == NULL) {
+        return -EINVAL;
+    }
+
+    va_start(args, message_id);
+    rc = fn(out, args);
+    va_end(args);
+
+    return rc;
+}
+
+void
+pcmk__register_message(pcmk__output_t *out, const char *message_id,
+                       pcmk__message_fn_t fn) {
+    g_hash_table_replace(out->messages, strdup(message_id), fn);
+}
+
+void
+pcmk__register_messages(pcmk__output_t *out, pcmk__message_entry_t *table) {
+    pcmk__message_entry_t *entry;
+
+    for (entry = table; entry->message_id != NULL; entry++) {
+        if (safe_str_eq(out->fmt_name, entry->fmt_name)) {
+            pcmk__register_message(out, entry->message_id, entry->fn);
+        }
+    }
+}
diff --git a/lib/common/output_text.c b/lib/common/output_text.c
new file mode 100644
index 0000000000..76ae4ca4e1
--- /dev/null
+++ b/lib/common/output_text.c
@@ -0,0 +1,223 @@
+/*
+ * Copyright 2019 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU Lesser General Public License
+ * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
+ */
+
+#include <stdlib.h>
+#include <crm/crm.h>
+#include <crm/common/output.h>
+
+/* Disabled for the moment, but we can enable it (or remove it entirely)
+ * when we make a decision on whether this is preferred output.
+ */
+#define FANCY_TEXT_OUTPUT 0
+
+typedef struct text_list_data_s {
+    unsigned int len;
+    char *singular_noun;
+    char *plural_noun;
+} text_list_data_t;
+
+typedef struct text_private_s {
+    GQueue *parent_q;
+} text_private_t;
+
+static void
+text_free_priv(pcmk__output_t *out) {
+    text_private_t *priv = out->priv;
+
+    if (priv == NULL) {
+        return;
+    }
+
+    g_queue_free(priv->parent_q);
+    free(priv);
+}
+
+static bool
+text_init(pcmk__output_t *out) {
+    text_private_t *priv = NULL;
+
+    /* If text_init was previously called on this output struct, just return. */
+    if (out->priv != NULL) {
+        return true;
+    } else {
+        out->priv = calloc(1, sizeof(text_private_t));
+        if (out->priv == NULL) {
+            return false;
+        }
+
+        priv = out->priv;
+    }
+
+    priv->parent_q = g_queue_new();
+    return true;
+}
+
+static void
+text_finish(pcmk__output_t *out, crm_exit_t exit_status) {
+    /* This function intentionally left blank */
+}
+
+static void
+text_reset(pcmk__output_t *out) {
+    CRM_ASSERT(out->priv != NULL);
+
+    text_free_priv(out);
+    text_init(out);
+}
+
+static void
+text_subprocess_output(pcmk__output_t *out, int exit_status,
+                       const char *proc_stdout, const char *proc_stderr) {
+    if (proc_stdout != NULL) {
+        fprintf(out->dest, "%s\n", proc_stdout);
+    }
+
+    if (proc_stderr != NULL) {
+        fprintf(out->dest, "%s\n", proc_stderr);
+    }
+}
+
+G_GNUC_PRINTF(2, 3)
+static void
+text_info(pcmk__output_t *out, const char *format, ...) {
+    va_list ap;
+    int len = 0;
+
+    va_start(ap, format);
+
+    /* Informational output does not get indented, to separate it from other
+     * potentially indented list output.
+     */
+    len = vfprintf(out->dest, format, ap);
+    CRM_ASSERT(len > 0);
+    va_end(ap);
+
+    /* Add a newline. */
+    fprintf(out->dest, "\n");
+}
+
+static void
+text_output_xml(pcmk__output_t *out, const char *name, const char *buf) {
+    text_private_t *priv = out->priv;
+
+    CRM_ASSERT(priv != NULL);
+    pcmk__indented_printf(out, "%s", buf);
+}
+
+static void
+text_begin_list(pcmk__output_t *out, const char *name, const char *singular_noun,
+                const char *plural_noun) {
+    text_private_t *priv = out->priv;
+    text_list_data_t *new_list = NULL;
+
+    CRM_ASSERT(priv != NULL);
+
+#if FANCY_TEXT_OUTPUT > 0
+    pcmk__indented_printf(out, "%s:\n", name);
+#endif
+
+    new_list = calloc(1, sizeof(text_list_data_t));
+    new_list->len = 0;
+    new_list->singular_noun = strdup(singular_noun);
+    new_list->plural_noun = strdup(plural_noun);
+
+    g_queue_push_tail(priv->parent_q, new_list);
+}
+
+static void
+text_list_item(pcmk__output_t *out, const char *id, const char *content) {
+    text_private_t *priv = out->priv;
+
+    CRM_ASSERT(priv != NULL);
+
+#if FANCY_TEXT_OUTPUT > 0
+    if (id != NULL) {
+        pcmk__indented_printf(out, "* %s: %s\n", id, content);
+    } else {
+        pcmk__indented_printf(out, "* %s\n", content);
+    }
+#else
+    fprintf(out->dest, "%s\n", content);
+#endif
+
+    ((text_list_data_t *) g_queue_peek_tail(priv->parent_q))->len++;
+}
+
+static void
+text_end_list(pcmk__output_t *out) {
+    text_private_t *priv = out->priv;
+    text_list_data_t *node = NULL;
+
+    CRM_ASSERT(priv != NULL);
+    node = g_queue_pop_tail(priv->parent_q);
+
+    if (node->singular_noun != NULL && node->plural_noun != NULL) {
+        if (node->len == 1) {
+            pcmk__indented_printf(out, "%d %s found\n", node->len, node->singular_noun);
+        } else {
+            pcmk__indented_printf(out, "%d %s found\n", node->len, node->plural_noun);
+        }
+    }
+
+    free(node);
+}
+
+pcmk__output_t *
+pcmk__mk_text_output(char **argv) {
+    pcmk__output_t *retval = calloc(1, sizeof(pcmk__output_t));
+
+    if (retval == NULL) {
+        return NULL;
+    }
+
+    retval->request = g_strjoinv(" ", argv);
+    retval->supports_quiet = true;
+
+    retval->init = text_init;
+    retval->free_priv = text_free_priv;
+    retval->finish = text_finish;
+    retval->reset = text_reset;
+
+    retval->register_message = pcmk__register_message;
+    retval->message = pcmk__call_message;
+
+    retval->subprocess_output = text_subprocess_output;
+    retval->info = text_info;
+    retval->output_xml = text_output_xml;
+
+    retval->begin_list = text_begin_list;
+    retval->list_item = text_list_item;
+    retval->end_list = text_end_list;
+
+    return retval;
+}
+
+G_GNUC_PRINTF(2, 3)
+void
+pcmk__indented_printf(pcmk__output_t *out, const char *format, ...) {
+    va_list ap;
+    int len = 0;
+#if FANCY_TEXT_OUTPUT > 0
+    int level = 0;
+    text_private_t *priv = out->priv;
+
+    CRM_ASSERT(priv != NULL);
+
+    level = g_queue_get_length(priv->parent_q);
+
+    for (int i = 0; i < level; i++) {
+        putc('\t', out->dest);
+    }
+#endif
+
+    va_start(ap, format);
+    len = vfprintf(out->dest, format, ap);
+    CRM_ASSERT(len > 0);
+    va_end(ap);
+}
diff --git a/lib/common/output_xml.c b/lib/common/output_xml.c
new file mode 100644
index 0000000000..c93d66ce4e
--- /dev/null
+++ b/lib/common/output_xml.c
@@ -0,0 +1,257 @@
+/*
+ * Copyright 2019 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU Lesser General Public License
+ * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
+ */
+
+#ifndef _GNU_SOURCE
+#  define _GNU_SOURCE
+#endif
+
+#include <ctype.h>
+#include <stdarg.h>
+#include <stdlib.h>
+#include <stdio.h>
+#include <crm/crm.h>
+#include <crm/common/output.h>
+#include <crm/common/xml.h>
+
+typedef struct xml_private_s {
+    xmlNode *root;
+    GQueue *parent_q;
+} xml_private_t;
+
+static void
+xml_free_priv(pcmk__output_t *out) {
+    xml_private_t *priv = out->priv;
+
+    if (priv == NULL) {
+        return;
+    }
+
+    xmlFreeNode(priv->root);
+    g_queue_free(priv->parent_q);
+    free(priv);
+}
+
+static bool
+xml_init(pcmk__output_t *out) {
+    xml_private_t *priv = NULL;
+
+    /* If xml_init was previously called on this output struct, just return. */
+    if (out->priv != NULL) {
+        return true;
+    } else {
+        out->priv = calloc(1, sizeof(xml_private_t));
+        if (out->priv == NULL) {
+            return false;
+        }
+
+        priv = out->priv;
+    }
+
+    priv->root = create_xml_node(NULL, "pacemaker-result");
+    xmlSetProp(priv->root, (pcmkXmlStr) "api-version", (pcmkXmlStr) PCMK__API_VERSION);
+
+    if (out->request != NULL) {
+        xmlSetProp(priv->root, (pcmkXmlStr) "request", (pcmkXmlStr) out->request);
+    }
+
+    priv->parent_q = g_queue_new();
+    g_queue_push_tail(priv->parent_q, priv->root);
+
+    return true;
+}
+
+static void
+xml_finish(pcmk__output_t *out, crm_exit_t exit_status) {
+    xmlNodePtr node;
+    char *rc_as_str = NULL;
+    char *buf = NULL;
+    xml_private_t *priv = out->priv;
+
+    /* If root is NULL, xml_init failed and we are being called from pcmk__output_free
+     * in the pcmk__output_new path.
+     */
+    if (priv->root == NULL) {
+        return;
+    }
+
+    rc_as_str = crm_itoa(exit_status);
+
+    node = xmlNewTextChild(priv->root, NULL, (pcmkXmlStr) "status",
+                           (pcmkXmlStr) crm_exit_str(exit_status));
+    xmlSetProp(node, (pcmkXmlStr) "code", (pcmkXmlStr) rc_as_str);
+
+    buf = dump_xml_formatted_with_text(priv->root);
+    fprintf(out->dest, "%s", buf);
+
+    free(rc_as_str);
+    free(buf);
+}
+
+static void
+xml_reset(pcmk__output_t *out) {
+    char *buf = NULL;
+    xml_private_t *priv = out->priv;
+
+    CRM_ASSERT(priv != NULL);
+
+    buf = dump_xml_formatted_with_text(priv->root);
+    fprintf(out->dest, "%s", buf);
+
+    free(buf);
+    xml_free_priv(out);
+    xml_init(out);
+}
+
+static void
+xml_subprocess_output(pcmk__output_t *out, int exit_status,
+                      const char *proc_stdout, const char *proc_stderr) {
+    xmlNodePtr node, child_node;
+    char *rc_as_str = NULL;
+    xml_private_t *priv = out->priv;
+    CRM_ASSERT(priv != NULL);
+
+    rc_as_str = crm_itoa(exit_status);
+
+    node = xmlNewNode(g_queue_peek_tail(priv->parent_q), (pcmkXmlStr) "command");
+    xmlSetProp(node, (pcmkXmlStr) "code", (pcmkXmlStr) rc_as_str);
+
+    if (proc_stdout != NULL) {
+        child_node = xmlNewTextChild(node, NULL, (pcmkXmlStr) "output",
+                                     (pcmkXmlStr) proc_stdout);
+        xmlSetProp(child_node, (pcmkXmlStr) "source", (pcmkXmlStr) "stdout");
+    }
+
+    if (proc_stderr != NULL) {
+        child_node = xmlNewTextChild(node, NULL, (pcmkXmlStr) "output",
+                                     (pcmkXmlStr) proc_stderr);
+        xmlSetProp(node, (pcmkXmlStr) "source", (pcmkXmlStr) "stderr");
+    }
+
+    pcmk__xml_add_node(out, node);
+    free(rc_as_str);
+}
+
+G_GNUC_PRINTF(2, 3)
+static void
+xml_info(pcmk__output_t *out, const char *format, ...) {
+    /* This function intentially left blank */
+}
+
+static void
+xml_output_xml(pcmk__output_t *out, const char *name, const char *buf) {
+    xmlNodePtr parent = NULL;
+    xmlNodePtr cdata_node = NULL;
+    xml_private_t *priv = out->priv;
+
+    CRM_ASSERT(priv != NULL);
+
+    parent = xmlNewChild(g_queue_peek_tail(priv->parent_q), NULL,
+                         (pcmkXmlStr) name, NULL);
+    cdata_node = xmlNewCDataBlock(getDocPtr(parent), (pcmkXmlStr) buf, strlen(buf));
+    xmlAddChild(parent, cdata_node);
+}
+
+static void
+xml_begin_list(pcmk__output_t *out, const char *name,
+               const char *singular_noun, const char *plural_noun) {
+    xmlNodePtr list_node = NULL;
+    xml_private_t *priv = out->priv;
+
+    CRM_ASSERT(priv != NULL);
+
+    list_node = create_xml_node(g_queue_peek_tail(priv->parent_q), "list");
+    xmlSetProp(list_node, (pcmkXmlStr) "name", (pcmkXmlStr) name);
+    g_queue_push_tail(priv->parent_q, list_node);
+}
+
+static void
+xml_list_item(pcmk__output_t *out, const char *name, const char *content) {
+    xml_private_t *priv = out->priv;
+    xmlNodePtr item_node = NULL;
+
+    CRM_ASSERT(priv != NULL);
+
+    item_node = xmlNewChild(g_queue_peek_tail(priv->parent_q), NULL,
+                            (pcmkXmlStr) "item", (pcmkXmlStr) content);
+    xmlSetProp(item_node, (pcmkXmlStr) "name", (pcmkXmlStr) name);
+}
+
+static void
+xml_end_list(pcmk__output_t *out) {
+    char *buf = NULL;
+    xml_private_t *priv = out->priv;
+    xmlNodePtr node;
+
+    CRM_ASSERT(priv != NULL);
+
+    node = g_queue_pop_tail(priv->parent_q);
+    buf = crm_strdup_printf("%lu", xmlChildElementCount(node));
+    xmlSetProp(node, (pcmkXmlStr) "count", (pcmkXmlStr) buf);
+    free(buf);
+}
+
+pcmk__output_t *
+pcmk__mk_xml_output(char **argv) {
+    pcmk__output_t *retval = calloc(1, sizeof(pcmk__output_t));
+
+    if (retval == NULL) {
+        return NULL;
+    }
+
+    retval->request = g_strjoinv(" ", argv);
+    retval->supports_quiet = false;
+
+    retval->init = xml_init;
+    retval->free_priv = xml_free_priv;
+    retval->finish = xml_finish;
+    retval->reset = xml_reset;
+
+    retval->register_message = pcmk__register_message;
+    retval->message = pcmk__call_message;
+
+    retval->subprocess_output = xml_subprocess_output;
+    retval->info = xml_info;
+    retval->output_xml = xml_output_xml;
+
+    retval->begin_list = xml_begin_list;
+    retval->list_item = xml_list_item;
+    retval->end_list = xml_end_list;
+
+    return retval;
+}
+
+void
+pcmk__xml_add_node(pcmk__output_t *out, xmlNodePtr node) {
+    xml_private_t *priv = out->priv;
+
+    CRM_ASSERT(priv != NULL);
+    CRM_ASSERT(node != NULL);
+
+    xmlAddChild(g_queue_peek_tail(priv->parent_q), node);
+}
+
+void
+pcmk__xml_push_parent(pcmk__output_t *out, xmlNodePtr parent) {
+    xml_private_t *priv = out->priv;
+
+    CRM_ASSERT(priv != NULL);
+    CRM_ASSERT(parent != NULL);
+
+    g_queue_push_tail(priv->parent_q, parent);
+}
+
+void
+pcmk__xml_pop_parent(pcmk__output_t *out) {
+    xml_private_t *priv = out->priv;
+
+    CRM_ASSERT(priv != NULL);
+    CRM_ASSERT(g_queue_get_length(priv->parent_q) > 0);
+
+    g_queue_pop_tail(priv->parent_q);
+}