diff --git a/configs/samples/aeap.conf.sample b/configs/samples/aeap.conf.sample
index 8941dd1f0375b1f24e87a02a97f7b808bea70aa5..2431ea3259e190dd0f005cac2fa91917aa489965 100644
--- a/configs/samples/aeap.conf.sample
+++ b/configs/samples/aeap.conf.sample
@@ -2,14 +2,20 @@
 ; This file is used by the res_aeap module to configure parameters
 ; used for AEAP applications.
 ;
-;[myserver]
+;[myclient]
 ;
-; type must be "server".
-;type=server
+; type must be "client".
+;type=client
 ;
-; server_url must be a websocket URL (ws or wss).
-;server_url
+; URL used to connect to a server. It must be a websocket URL (ws or wss).
+;url=ws://127.0.0.1:9099
 ;
-; codecs is an optional list of codecs that will be used over the codecs
-; specified on an endpoint if this option is present.
-;codecs=ulaw,alaw,g722,opus
+; codecs is comma separated string of allowed/disallowed codec names.
+;codecs=!all,ulaw,alaw,opus
+;
+; protocol is the implementation specific sub-protocol
+;protocol=speech_to_text
+;
+; "@" parameters can be specified and are used to to set custom values to
+; be passed as "params" in the initial "setup" request.
+;@language=en-US
diff --git a/include/asterisk/res_aeap.h b/include/asterisk/res_aeap.h
new file mode 100644
index 0000000000000000000000000000000000000000..0a1747e923f6af084a2ae3923fa534db797aec4c
--- /dev/null
+++ b/include/asterisk/res_aeap.h
@@ -0,0 +1,370 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*! \file
+ * \brief Asterisk External Application Protocol API
+ */
+
+#ifndef AST_RES_AEAP_H
+#define AST_RES_AEAP_H
+
+#include <stdint.h>
+
+struct ao2_container;
+struct ast_sorcery;
+struct ast_variable;
+
+struct ast_aeap_client_config;
+struct ast_aeap_message;
+
+#define AEAP_CONFIG_CLIENT "client"
+
+/*!
+ * \brief Retrieve the AEAP sorcery object
+ *
+ * \returns the AEAP sorcery object
+ */
+struct ast_sorcery *ast_aeap_sorcery(void);
+
+/*!
+ * \brief Retrieve a listing of all client configuration objects by protocol.
+ *
+ * \note Caller is responsible for the returned container's reference.
+ *
+ * \param protocol An optional protocol to filter on (if NULL returns all client configs)
+ *
+ * \returns A container of client configuration objects
+ */
+struct ao2_container *ast_aeap_client_configs_get(const char *protocol);
+
+/*!
+ * \brief Retrieve codec capabilities from the configuration
+ *
+ * \param config A configuration object
+ *
+ * \returns The configuration's codec capabilities
+ */
+const struct ast_format_cap *ast_aeap_client_config_codecs(const struct ast_aeap_client_config *cfg);
+
+/*!
+ * \brief Check a given protocol against that in an Asterisk external application configuration
+ *
+ * \param config A configuration object
+ * \param protocol The protocol to check
+ *
+ * \returns True if the configuration's protocol matches, false otherwise
+ */
+int ast_aeap_client_config_has_protocol(const struct ast_aeap_client_config *cfg,
+	const char *protocol);
+
+/*!
+ * \brief Retrieve a list of custom configuration fields
+ *
+ * \param id configuration id/sorcery lookup key
+ *
+ * \returns variables, or NULL on error
+ */
+struct ast_variable *ast_aeap_custom_fields_get(const char *id);
+
+/*!
+ * \brief An Asterisk external application object
+ *
+ * Connects to an external application, sending and receiving data, and
+ * dispatches received data to registered handlers.
+ */
+struct ast_aeap;
+
+/*!
+ * \brief Event raised when a message is received
+ *
+ * \param aeap An Asterisk external application object
+ * \param message The received message
+ * \param obj Associated user object
+ *
+ * \returns 0 on if message handled, otherwise non-zero
+ */
+typedef int (*ast_aeap_on_message)(struct ast_aeap *aeap, struct ast_aeap_message *message, void *obj);
+
+/*!
+ * \brief An Asterisk external application message handler
+ *
+ * Used to register message handlers with an AEAP object.
+ */
+struct ast_aeap_message_handler {
+	/*! The handler name */
+	const char *name;
+	/*! Callback triggered when on a name match */
+	ast_aeap_on_message on_message;
+};
+
+/*!
+ * \brief Event raised when a sent message does not receive a reply within
+ *        a specified time interval
+ *
+ * \param aeap An Asterisk external application object
+ * \param message The message sent that received no response
+ * \param obj Associated user object
+ */
+typedef void (*ast_aeap_on_timeout)(struct ast_aeap *aeap, struct ast_aeap_message *message, void *obj);
+
+/*!
+ * \brief Callback to cleanup a user object
+ *
+ * \param obj The user object
+ */
+typedef void (*ast_aeap_user_obj_cleanup)(void *obj);
+
+/*!
+ * \brief Supported Asterisk external application data types
+ */
+enum AST_AEAP_DATA_TYPE {
+	AST_AEAP_DATA_TYPE_NONE,
+	AST_AEAP_DATA_TYPE_BINARY,
+	AST_AEAP_DATA_TYPE_STRING,
+};
+
+/*!
+ * \brief Callbacks and other parameters used by an Asterisk external application object
+ */
+struct ast_aeap_params {
+	/*!
+	 * If true pass along error messages to the implementation.
+	 * Otherwise log it only, and consider it handled.
+	 */
+	unsigned int emit_error;
+
+	/*! The message type used for communication */
+	const struct ast_aeap_message_type *msg_type;
+
+	/*! Response handlers array */
+	const struct ast_aeap_message_handler *response_handlers;
+	/*! The number of response handlers */
+	uintmax_t response_handlers_size;
+
+	/*! Request handlers array */
+	const struct ast_aeap_message_handler *request_handlers;
+	/*! The number of request handlers */
+	uintmax_t request_handlers_size;
+
+	/*!
+	 * \brief Raised when binary data is received
+	 *
+	 * \param aeap An Asterisk external application object
+	 * \param buf The buffer containing binary data
+	 * \param size The size of the buffer
+	 */
+	void (*on_binary)(struct ast_aeap *aeap, const void *buf, intmax_t size);
+
+	/*!
+	 * \brief Raised when string data is received
+	 *
+	 * \param aeap An Asterisk external application object
+	 * \param buf The buffer containing string data
+	 * \param size The size/length of the string
+	 */
+	void (*on_string)(struct ast_aeap *aeap, const char *buf, intmax_t size);
+
+	/*!
+	 * \brief Raised when an error occurs during reading
+	 *
+	 * \note This is an AEAP transport level read error event
+	 *
+	 * \note When this event is triggered the client has also
+	 *       been disconnected.
+	 *
+	 * \param aeap An Asterisk external application object
+	 */
+	void (*on_error)(struct ast_aeap *aeap);
+};
+
+/*!
+ * \brief Create an Asterisk external application object
+ *
+ * \param type The type of underlying transport
+ * \param params Callbacks and other parameters to use
+ *
+ * \returns A new ao2 reference counted aeap object, or NULL on error
+ */
+struct ast_aeap *ast_aeap_create(const char *type, const struct ast_aeap_params *params);
+
+/*!
+ * \brief Create an Asterisk external application object by sorcery id
+ *
+ * \param id The sorcery id to lookup
+ * \param params Callbacks and other parameters to use
+ *
+ * \returns A new ao2 reference counted aeap object, or NULL on error
+ */
+struct ast_aeap *ast_aeap_create_by_id(const char *id, const struct ast_aeap_params *params);
+
+/*!
+ * \brief Connect to an external application
+ *
+ * \param aeap An Asterisk external application object
+ * \param url The url to connect to
+ * \param protocol A protocol to use
+ * \param timeout How long (in milliseconds) to attempt to connect (-1 equals infinite)
+ *
+ * \returns 0 if able to connect, -1 on error
+ */
+int ast_aeap_connect(struct ast_aeap *aeap, const char *url, const char *protocol, int timeout);
+
+/*!
+ * \brief Create and connect to an Asterisk external application by sorcery id
+ *
+ * \param id The sorcery id to lookup
+ * \param params Callbacks and other parameters to use
+ * \param timeout How long (in milliseconds) to attempt to connect (-1 equals infinite)
+ *
+ * \returns A new ao2 reference counted aeap object, or NULL on error
+ */
+struct ast_aeap *ast_aeap_create_and_connect_by_id(const char *id,
+	const struct ast_aeap_params *params, int timeout);
+
+/*!
+ * \brief Create and connect to an Asterisk external application
+ *
+ * \param type The type of client connection to make
+ * \param params Callbacks and other parameters to use
+ * \param url The url to connect to
+ * \param protocol A protocol to use
+ * \param timeout How long (in milliseconds) to attempt to connect (-1 equals infinite)
+ *
+ * \returns A new ao2 reference counted aeap object, or NULL on error
+ */
+struct ast_aeap *ast_aeap_create_and_connect(const char *type,
+	const struct ast_aeap_params *params, const char *url, const char *protocol, int timeout);
+
+/*!
+ * \brief Disconnect an Asterisk external application object
+ *
+ * \note Depending on the underlying transport this call may block
+ *
+ * \param aeap An Asterisk external application object
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_disconnect(struct ast_aeap *aeap);
+
+/*!
+ * \brief Register a user data object
+ *
+ * \note The "cleanup" is called on un-register, if one is specified
+ *
+ * \param aeap An Asterisk external application object
+ * \param id The look up id for the object
+ * \param obj The user object to register
+ * \param cleanup Optional user object clean up callback
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_user_data_register(struct ast_aeap *aeap, const char *id, void *obj,
+	ast_aeap_user_obj_cleanup cleanup);
+
+/*!
+ * \brief Un-register a user data object
+ *
+ * \note If specified on register, the "cleanup" callback is called during unregister.
+ *
+ * \param aeap An Asterisk external application object
+ * \param id The look up id for the object
+ */
+void ast_aeap_user_data_unregister(struct ast_aeap *aeap, const char *id);
+
+/*!
+ * \brief Retrieve a registered user data object by its id
+ *
+ * \note Depending on how it was registered the returned user data object's lifetime
+ *       may be managed by the given "aeap" object. If it was registered with a cleanup
+ *       handler that [potentially] frees it the caller of this function must ensure
+ *       it's done using the returned object before it's unregistered.
+ *
+ * \param data A user data object
+ *
+ * \returns A user data object
+ */
+void *ast_aeap_user_data_object_by_id(struct ast_aeap *aeap, const char *id);
+
+/*!
+ * \brief Send a binary data to an external application
+ *
+ * \param aeap An Asterisk external application object
+ * \param buf Binary data to send
+ * \param size The size of the binary data
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_send_binary(struct ast_aeap *aeap, const void *buf, uintmax_t size);
+
+/*!
+ * \brief Send a message to an external application
+ *
+ * \note "Steals" the given message reference, thus callers are not required to un-ref
+ *       the message object after calling this function.
+ *
+ * \param aeap An Asterisk external application object
+ * \param msg The message to send
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_send_msg(struct ast_aeap *aeap, struct ast_aeap_message *msg);
+
+/*!
+ * \brief Parameters to be used when sending a transaction based message
+ */
+struct ast_aeap_tsx_params {
+	/*! The message to send */
+	struct ast_aeap_message *msg;
+	/*! The amount of time (in milliseconds) to wait for a received message */
+	int timeout;
+	/*! Optional callback raised when no message is received in an allotted time */
+	ast_aeap_on_timeout on_timeout;
+	/*! Whether or not to block the current thread, and wait for a received message */
+	int wait;
+	/*!
+	 * Optional user object to pass to handlers. User is responsible for object's lifetime
+	 * unless an obj_cleanup callback is specified that handles its cleanup (e.g. freeing
+	 * of memory).
+	 */
+	void *obj;
+	/*!
+	 * Optional user object cleanup callback. If specified, called upon "this" param's
+	 * destruction (including on error).
+	 */
+	ast_aeap_user_obj_cleanup obj_cleanup;
+};
+
+/*!
+ * \brief Send a transaction based message to an external application using the given parameters
+ *
+ * \note "Steals" the given message reference, thus callers are not required to un-ref
+ *       the message object after calling this function.
+ *
+ * \note Also handles cleaning up the user object if the obj_cleanup callback
+ *       is specified in "params".
+ *
+ * \param aeap An Asterisk external application object
+ * \param msg The message to send
+ * \param params (optional) Additional parameters to consider when sending. Heap allocation
+ *     not required.
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_send_msg_tsx(struct ast_aeap *aeap, struct ast_aeap_tsx_params *params);
+
+#endif /* AST_RES_AEAP_H */
diff --git a/include/asterisk/res_aeap_message.h b/include/asterisk/res_aeap_message.h
new file mode 100644
index 0000000000000000000000000000000000000000..294039e41a588b2ebf1398d9185a8c75ed7ae500
--- /dev/null
+++ b/include/asterisk/res_aeap_message.h
@@ -0,0 +1,374 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*! \file
+ * \brief Asterisk External Application Protocol Message API
+ */
+
+#ifndef AST_AEAP_MESSAGE_H
+#define AST_AEAP_MESSAGE_H
+
+#include <stdint.h>
+
+#include "asterisk/res_aeap.h"
+
+struct ast_aeap_message;
+
+/*!
+ * \brief Message type virtual method table
+ */
+struct ast_aeap_message_type {
+	/*! The size of the message implementation type. Used for allocations. */
+	size_t type_size;
+	/*! The name of this type */
+	const char *type_name;
+	/*! The type to serialize to, and de-serialize from */
+	enum AST_AEAP_DATA_TYPE serial_type;
+
+	/*!
+	 * \brief Construct/Initialize a message object
+	 *
+	 * \param self The message object to initialize
+	 * \param params Other optional parameter(s) to possibly use
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*construct1)(struct ast_aeap_message *self, const void *params);
+
+	/*!
+	 * \brief Construct/Initialize a message object
+	 *
+	 * \param self The message object to initialize
+	 * \param msg_type The type of message (e.g. request or response)
+	 * \param name The name of the message
+	 * \param id The message id
+	 * \param params Other optional parameter(s) to possibly use
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*construct2)(struct ast_aeap_message *self, const char *msg_type, const char *name,
+		const char *id, const void *params);
+
+	/*!
+	 * \brief Destruct/Cleanup object resources
+	 *
+	 * \param self The message object being destructed
+	 */
+	void (*destruct)(struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Deserialize the given buffer into a message object
+	 *
+	 * \param self The message object to deserialize into
+	 * \param buf The buffer to deserialize
+	 * \param size The size/length of the buffer
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*deserialize)(struct ast_aeap_message *self, const void *buf, intmax_t size);
+
+	/*!
+	 * \brief Serialize the message object into byte/char buffer
+	 *
+	 * \param self The message object to serialize
+	 * \param buf [out] The buffer to hold the "packed" data
+	 * \param size [out] The size of the data written to the buffer
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*serialize)(const struct ast_aeap_message *self, void **buf, intmax_t *size);
+
+	/*!
+	 * \brief Retrieve a message id
+	 *
+	 * \param self The message object
+	 *
+	 * \returns The message id
+	 */
+	const char *(*id)(const struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Set a message id.
+	 *
+	 * \param self The message object
+	 * \param id The id to set
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*id_set)(struct ast_aeap_message *self, const char *id);
+
+	/*!
+	 * \brief Retrieve a message name
+	 *
+	 * \param self The message object
+	 *
+	 * \returns The message name
+	 */
+	const char *(*name)(const struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Retrieve the core message data/body
+	 *
+	 * \param self This message object
+	 */
+	void *(*data)(struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Retrieve whether or not this is a request message
+	 *
+	 * \param self The message object
+	 *
+	 * \returns True if message is a request, false otherwise
+	 */
+	int (*is_request)(const struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Retrieve whether or not this is a response message
+	 *
+	 * \param self The message object
+	 *
+	 * \returns True if message is a response, false otherwise
+	 */
+	int (*is_response)(const struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Retrieve the error message if it has one
+	 *
+	 * \param self The message object
+	 *
+	 * \returns The error message if available, or NULL
+	 */
+	const char *(*error_msg)(const struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Set an error message
+	 *
+	 * \param self The message object
+	 * \param error_msg The error message string to set
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*error_msg_set)(struct ast_aeap_message *self, const char *error_msg);
+};
+
+/*!
+ * \brief Asterisk external application base message
+ */
+struct ast_aeap_message {
+	/*! The type virtual table */
+	const struct ast_aeap_message_type *type;
+};
+
+/*!
+ * \brief Retrieve the serial type a message type
+ *
+ * \param type A message type
+ *
+ * \returns The type's serial type
+ */
+enum AST_AEAP_DATA_TYPE ast_aeap_message_serial_type(const struct ast_aeap_message_type *type);
+
+/*!
+ * \brief Create an Asterisk external application message object
+ *
+ * \param type The type of message object to create
+ * \param params Any parameter(s) to pass to the type's constructor
+ *
+ * \returns An ao2 reference counted AEAP message object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_create1(const struct ast_aeap_message_type *type,
+	const void *params);
+
+/*!
+ * \brief Create an Asterisk external application message object
+ *
+ * \param type The type of message object to create
+ * \param msg_type The type of message (e.g. request or response)
+ * \param name The name of the message
+ * \param id The message id
+ * \param params Other optional parameter(s) to possibly use
+ *
+ * \returns An ao2 reference counted AEAP message object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_create2(const struct ast_aeap_message_type *type,
+	const char *msg_type, const char *name, const char *id, const void *params);
+
+/*!
+ * \brief Create an Asterisk external application request object
+ *
+ * \param type The type of message object to create
+ * \param name The name of the message
+ * \param id Optional id (if NULL an id is generated)
+ * \param params Other optional parameter(s) to possibly use
+ *
+ * \returns An ao2 reference counted AEAP request object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_create_request(const struct ast_aeap_message_type *type,
+	const char *name, const char *id, const void *params);
+
+/*!
+ * \brief Create an Asterisk external application response object
+ *
+ * \param type The type of message object to create
+ * \param name The name of the message
+ * \param id Optional id
+ * \param params Other optional parameter(s) to possibly use
+ *
+ * \returns An ao2 reference counted AEAP response object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_create_response(const struct ast_aeap_message_type *type,
+	const char *name, const char *id, const void *params);
+
+/*!
+ * \brief Create an Asterisk external application error response object
+ *
+ * \param type The type of message object to create
+ * \param name The name of the message
+ * \param id Optional id
+ * \param error_msg Error message to set
+ * \param params Other optional parameter(s) to possibly use
+ *
+ * \returns An ao2 reference counted AEAP response object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_create_error(const struct ast_aeap_message_type *type,
+	const char *name, const char *id, const char *error_msg);
+
+/*!
+ * \brief Deserialize the given buffer into an Asterisk external application message object
+ *
+ * \param type The message type to create, and deserialize to
+ * \param buf The buffer to deserialize
+ * \param size The size/length of the buffer
+ *
+ * \returns An ao2 reference counted AEAP message object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_deserialize(const struct ast_aeap_message_type *type,
+	const void *buf, intmax_t size);
+
+/*!
+ * \brief Serialize the given message object into a byte/char buffer
+ *
+ * \param message The message object to serialize
+ * \param buf [out] The buffer to hold the "packed" data
+ * \param size [out] The size of the data written to the buffer
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_message_serialize(const struct ast_aeap_message *message,
+	void **buf, intmax_t *size);
+
+/*!
+ * \brief Retrieve a message id
+ *
+ * \param message A message object
+ *
+ * \returns The message id, or an empty string
+ */
+const char *ast_aeap_message_id(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Set a message id.
+ *
+ * \param message A message object
+ * \param id The id to set
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_message_id_set(struct ast_aeap_message *message, const char *id);
+
+/*!
+ * \brief Generate an id, and set it for the message
+ *
+ * \param message A message object
+ *
+ * \returns the generated id on success, or NULL on error
+ */
+const char *ast_aeap_message_id_generate(struct ast_aeap_message *message);
+
+/*!
+ * \brief Retrieve a message name
+ *
+ * \param message A message object
+ *
+ * \returns The message name, or an empty string
+ */
+const char *ast_aeap_message_name(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Check whether or not a message's name matches the given one
+ *
+ * \note Case insensitive
+ *
+ * \param message A message object
+ * \param message name The name to check against
+ *
+ * \returns True if matched, false otherwise
+ */
+int ast_aeap_message_is_named(const struct ast_aeap_message *message, const char *name);
+
+/*!
+ * \brief Retrieve the core message data/body
+ *
+ * \param message A message object
+ */
+void *ast_aeap_message_data(struct ast_aeap_message *message);
+
+/*!
+ * \brief Retrieve whether or not this is a request message
+ *
+ * \param message A message object
+ *
+ * \returns True if the message is a request, false otherwise
+ */
+int ast_aeap_message_is_request(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Retrieve whether or not this is a response message
+ *
+ * \param message A message object
+ *
+ * \returns True if the message is a response, false otherwise
+ */
+int ast_aeap_message_is_response(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Retrieve the error message if it has one
+ *
+ * \param message A message object
+ *
+ * \returns The error message if available, or NULL
+ */
+const char *ast_aeap_message_error_msg(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Set an error message.
+ *
+ * \param message A message object
+ * \param error_msg The error string to set
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_message_error_msg_set(struct ast_aeap_message *message,
+	const char *error_msg);
+
+/*!
+ * \brief Asterisk external application JSON message type
+ */
+extern const struct ast_aeap_message_type *ast_aeap_message_type_json;
+
+#endif /* AST_AEAP_MESSAGE_H */
diff --git a/include/asterisk/speech.h b/include/asterisk/speech.h
index 681c536019c1f67f031d3f9d8f1be96e1849503f..ec5abaf3c382c7f9bf714d582fab04037f192aa8 100644
--- a/include/asterisk/speech.h
+++ b/include/asterisk/speech.h
@@ -158,6 +158,12 @@ int ast_speech_unregister(const char *engine_name);
 /*! \brief Unregister a speech recognition engine */
 struct ast_speech_engine *ast_speech_unregister2(const char *engine_name);
 
+/*! \brief Retrieve a speech recognition engine */
+struct ast_speech_engine *ast_speech_find_engine(const char *engine_name);
+/*! \brief Unregister all speech recognition engines told to by callback */
+void ast_speech_unregister_engines(
+	int (*should_unregister)(const struct ast_speech_engine *engine, void *data), void *data,
+	void (*on_unregistered)(void *obj));
 
 #if defined(__cplusplus) || defined(c_plusplus)
 }
diff --git a/res/Makefile b/res/Makefile
index 45ef749a3f2c1660b1d8643f28ee61b546ec507d..d54130011faf702a422dc6b893b83b161ab87be6 100644
--- a/res/Makefile
+++ b/res/Makefile
@@ -69,6 +69,7 @@ $(call MOD_ADD_C,res_ari,ari/cli.c ari/config.c ari/ari_websockets.c)
 $(call MOD_ADD_C,res_ari_model,ari/ari_model_validators.c)
 $(call MOD_ADD_C,res_stasis_recording,stasis_recording/stored.c)
 $(call MOD_ADD_C,res_stir_shaken,$(wildcard res_stir_shaken/*.c))
+$(call MOD_ADD_C,res_aeap,$(wildcard res_aeap/*.c))
 
 res_parking.o: _ASTCFLAGS+=$(AST_NO_FORMAT_TRUNCATION)
 snmp/agent.o: _ASTCFLAGS+=-fPIC
diff --git a/res/res_aeap.c b/res/res_aeap.c
index b6c584109fab9d6a0198d1148f0913de87f04b36..e78956e20b7ed9454e110a95dad3248c22a45ecf 100644
--- a/res/res_aeap.c
+++ b/res/res_aeap.c
@@ -17,29 +17,37 @@
  */
 
 /*** MODULEINFO
+	<depend>res_http_websocket</depend>
 	<support_level>core</support_level>
  ***/
 
 #include "asterisk.h"
 
+#include "asterisk/astobj2.h"
 #include "asterisk/module.h"
 #include "asterisk/sorcery.h"
 #include "asterisk/cli.h"
 #include "asterisk/format.h"
 #include "asterisk/format_cap.h"
+#include "asterisk/res_aeap.h"
+
+#include "res_aeap/general.h"
 
 /*** DOCUMENTATION
 	<configInfo name="res_aeap" language="en_US">
 		<synopsis>Asterisk External Application Protocol (AEAP) module for Asterisk</synopsis>
 		<configFile name="aeap.conf">
-			<configObject name="server">
-				<synopsis>AEAP server options</synopsis>
+			<configObject name="client">
+				<synopsis>AEAP client options</synopsis>
 				<configOption name="type">
-					<synopsis>Must be of type 'server'.</synopsis>
+					<synopsis>Must be of type 'client'.</synopsis>
 				</configOption>
-				<configOption name="server_url">
+				<configOption name="url">
 					<synopsis>The URL of the server to connect to.</synopsis>
 				</configOption>
+				<configOption name="protocol">
+					<synopsis>The application protocol.</synopsis>
+				</configOption>
 				<configOption name="codecs">
 				        <synopsis>Optional media codec(s)</synopsis>
 					<description><para>
@@ -56,30 +64,36 @@
 /* Asterisk External Application Protocol sorcery object */
 static struct ast_sorcery *aeap_sorcery;
 
-struct aeap_server
+struct ast_sorcery *ast_aeap_sorcery(void) {
+	return aeap_sorcery;
+}
+
+struct ast_aeap_client_config
 {
 	SORCERY_OBJECT(details);
 	AST_DECLARE_STRING_FIELDS(
 		/*! The URL of the server to connect to */
-		AST_STRING_FIELD(server_url);
+		AST_STRING_FIELD(url);
+		/*! The application protocol */
+		AST_STRING_FIELD(protocol);
 	);
 	/*! An optional list of codecs that will be used if provided */
 	struct ast_format_cap *codecs;
 };
 
-static void aeap_server_destructor(void *obj)
+static void client_config_destructor(void *obj)
 {
-	struct aeap_server *cfg = obj;
+	struct ast_aeap_client_config *cfg = obj;
 
 	ast_string_field_free_memory(cfg);
 	ao2_cleanup(cfg->codecs);
 }
 
-static void *aeap_server_alloc(const char *name)
+static void *client_config_alloc(const char *name)
 {
-	struct aeap_server *cfg;
+	struct ast_aeap_client_config *cfg;
 
-	cfg = ast_sorcery_generic_alloc(sizeof(*cfg), aeap_server_destructor);
+	cfg = ast_sorcery_generic_alloc(sizeof(*cfg), client_config_destructor);
 	if (!cfg) {
 		return NULL;
 	}
@@ -97,32 +111,52 @@ static void *aeap_server_alloc(const char *name)
 	return cfg;
 }
 
-static int aeap_server_apply(const struct ast_sorcery *sorcery, void *obj)
+static int client_config_apply(const struct ast_sorcery *sorcery, void *obj)
 {
-	struct aeap_server *cfg = obj;
+	struct ast_aeap_client_config *cfg = obj;
 
-	if (ast_strlen_zero(cfg->server_url)) {
-		ast_log(LOG_ERROR, "AEAP - Server URL must be present for server '%s'\n", ast_sorcery_object_get_id(cfg));
+	if (ast_strlen_zero(cfg->url)) {
+		ast_log(LOG_ERROR, "AEAP - URL must be present for '%s'\n", ast_sorcery_object_get_id(cfg));
 		return -1;
 	}
 
-	if (!ast_begins_with(cfg->server_url, "ws")) {
-		ast_log(LOG_ERROR, "AEAP - Server URL must be ws or wss for server '%s'\n", ast_sorcery_object_get_id(cfg));
+	if (!ast_begins_with(cfg->url, "ws")) {
+		ast_log(LOG_ERROR, "AEAP - URL must be ws or wss for '%s'\n", ast_sorcery_object_get_id(cfg));
 		return -1;
 	}
 
 	return 0;
 }
 
-static struct aeap_server *aeap_server_get(const char *id)
+const struct ast_format_cap *ast_aeap_client_config_codecs(const struct ast_aeap_client_config *cfg)
 {
-	return ast_sorcery_retrieve_by_id(aeap_sorcery, "server", id);
+	return cfg->codecs;
 }
 
-static struct ao2_container *aeap_server_get_all(void)
+int ast_aeap_client_config_has_protocol(const struct ast_aeap_client_config *cfg,
+	const char *protocol)
 {
-	return ast_sorcery_retrieve_by_fields(aeap_sorcery, "server",
-		AST_RETRIEVE_FLAG_MULTIPLE | AST_RETRIEVE_FLAG_ALL, NULL);
+	return !strcmp(protocol, cfg->protocol);
+}
+
+struct ao2_container *ast_aeap_client_configs_get(const char *protocol)
+{
+	struct ao2_container *container;
+	struct ast_variable *var;
+
+	var = protocol ? ast_variable_new("protocol ==", protocol, "") : NULL;
+
+	container = ast_sorcery_retrieve_by_fields(aeap_sorcery,
+		AEAP_CONFIG_CLIENT, AST_RETRIEVE_FLAG_MULTIPLE | AST_RETRIEVE_FLAG_ALL, var);
+
+	ast_variables_destroy(var);
+
+	return container;
+}
+
+static struct ast_aeap_client_config *client_config_get(const char *id)
+{
+	return ast_sorcery_retrieve_by_id(aeap_sorcery, AEAP_CONFIG_CLIENT, id);
 }
 
 static char *aeap_tab_complete_name(const char *word, struct ao2_container *container)
@@ -145,6 +179,8 @@ static char *aeap_tab_complete_name(const char *word, struct ao2_container *cont
 	}
 	ao2_iterator_destroy(&it);
 
+	ao2_ref(container, -1);
+
 	return NULL;
 }
 
@@ -159,8 +195,7 @@ static int aeap_cli_show(void *obj, void *arg, int flags)
 		return 0;
 	}
 
-	options = ast_variable_list_sort(ast_sorcery_objectset_create2(
-		aeap_sorcery, obj, AST_HANDLER_ONLY_STRING));
+	options = ast_variable_list_sort(ast_sorcery_objectset_create(aeap_sorcery, obj));
 	if (!options) {
 		return 0;
 	}
@@ -179,20 +214,20 @@ static int aeap_cli_show(void *obj, void *arg, int flags)
 	return 0;
 }
 
-static char *aeap_server_show(struct ast_cli_entry *e, int cmd, struct ast_cli_args *a)
+static char *client_config_show(struct ast_cli_entry *e, int cmd, struct ast_cli_args *a)
 {
-	struct aeap_server *cfg;
+	struct ast_aeap_client_config *cfg;
 
 	switch(cmd) {
 	case CLI_INIT:
-		e->command = "aeap show server";
+		e->command = "aeap show client";
 		e->usage =
-			"Usage: aeap show server <id>\n"
-			"       Show the AEAP settings for a given server\n";
+			"Usage: aeap show client <id>\n"
+			"       Show the AEAP settings for a given client\n";
 		return NULL;
 	case CLI_GENERATE:
 		if (a->pos == 3) {
-			return aeap_tab_complete_name(a->word, aeap_server_get_all());
+			return aeap_tab_complete_name(a->word, ast_aeap_client_configs_get(NULL));
 		} else {
 			return NULL;
 		}
@@ -202,23 +237,23 @@ static char *aeap_server_show(struct ast_cli_entry *e, int cmd, struct ast_cli_a
 		return CLI_SHOWUSAGE;
 	}
 
-	cfg = aeap_server_get(a->argv[3]);
+	cfg = client_config_get(a->argv[3]);
 	aeap_cli_show(cfg, a, 0);
 	ao2_cleanup(cfg);
 
 	return CLI_SUCCESS;
 }
 
-static char *aeap_server_show_all(struct ast_cli_entry *e, int cmd, struct ast_cli_args *a)
+static char *client_config_show_all(struct ast_cli_entry *e, int cmd, struct ast_cli_args *a)
 {
 	struct ao2_container *container;
 
 	switch(cmd) {
 	case CLI_INIT:
-		e->command = "aeap show servers";
+		e->command = "aeap show clients";
 		e->usage =
-			"Usage: aeap show servers\n"
-			"       Show all configured AEAP servers\n";
+			"Usage: aeap show clients\n"
+			"       Show all configured AEAP clients\n";
 		return NULL;
 	case CLI_GENERATE:
 		return NULL;
@@ -228,9 +263,9 @@ static char *aeap_server_show_all(struct ast_cli_entry *e, int cmd, struct ast_c
 		return CLI_SHOWUSAGE;
 	}
 
-	container = aeap_server_get_all();
+	container = ast_aeap_client_configs_get(NULL);
 	if (!container || ao2_container_count(container) == 0) {
-		ast_cli(a->fd, "No AEAP servers found\n");
+		ast_cli(a->fd, "No AEAP clients found\n");
 		ao2_cleanup(container);
 		return CLI_SUCCESS;
 	}
@@ -242,12 +277,75 @@ static char *aeap_server_show_all(struct ast_cli_entry *e, int cmd, struct ast_c
 }
 
 static struct ast_cli_entry aeap_cli[] = {
-	AST_CLI_DEFINE(aeap_server_show, "Show AEAP server configuration by id"),
-	AST_CLI_DEFINE(aeap_server_show_all, "Show all AEAP server configurations"),
+	AST_CLI_DEFINE(client_config_show, "Show AEAP client configuration by id"),
+	AST_CLI_DEFINE(client_config_show_all, "Show all AEAP client configurations"),
 };
 
+static struct ast_aeap *aeap_create(const char *id,	const struct ast_aeap_params *params,
+	int connect, int timeout)
+{
+	struct ast_aeap_client_config *cfg;
+	struct ast_aeap *aeap;
+	const char *url = NULL;
+	const char *protocol = NULL;
+
+	cfg = client_config_get(id);
+	if (cfg) {
+		url = cfg->url;
+		protocol = cfg->protocol;
+	}
+
+#ifdef TEST_FRAMEWORK
+	else if (ast_begins_with(id, "_aeap_test_")) {
+		url = "ws://127.0.0.1:8088/ws";
+		protocol = id;
+	}
+#endif
+
+	if (!url && !protocol) {
+		ast_log(LOG_ERROR, "AEAP: unable to get configuration for '%s'\n", id);
+		return NULL;
+	}
+
+	aeap = connect ? ast_aeap_create_and_connect(url, params, url, protocol, timeout) :
+		ast_aeap_create(url, params);
+
+	ao2_cleanup(cfg);
+	return aeap;
+}
+
+struct ast_aeap *ast_aeap_create_by_id(const char *id, const struct ast_aeap_params *params)
+{
+	return aeap_create(id, params, 0, 0);
+}
+
+struct ast_aeap *ast_aeap_create_and_connect_by_id(const char *id,
+	const struct ast_aeap_params *params, int timeout)
+{
+	return aeap_create(id, params, 1, timeout);
+}
+
+struct ast_variable *ast_aeap_custom_fields_get(const char *id)
+{
+	struct ast_aeap_client_config *cfg;
+	struct ast_variable *vars;
+
+	cfg = client_config_get(id);
+	if (!cfg) {
+		ast_log(LOG_WARNING, "AEAP: no client configuration '%s' to get fields\n", id);
+		return NULL;
+	}
+
+	vars = ast_sorcery_objectset_create(aeap_sorcery, cfg);
+
+	ao2_ref(cfg, -1);
+	return vars;
+}
+
 static int reload_module(void)
 {
+	ast_sorcery_reload(aeap_sorcery);
+
 	return 0;
 }
 
@@ -258,28 +356,35 @@ static int unload_module(void)
 
 	ast_cli_unregister_multiple(aeap_cli, ARRAY_LEN(aeap_cli));
 
+	aeap_general_finalize();
+
 	return 0;
 }
 
 static int load_module(void)
 {
+	if (aeap_general_initialize()) {
+		return AST_MODULE_LOAD_DECLINE;
+	}
+
 	if (!(aeap_sorcery = ast_sorcery_open()))
 	{
 		ast_log(LOG_ERROR, "AEAP - failed to open sorcery\n");
 		return AST_MODULE_LOAD_DECLINE;
 	}
 
-	ast_sorcery_apply_default(aeap_sorcery, "server", "config", "aeap.conf,criteria=type=server");
+	ast_sorcery_apply_default(aeap_sorcery, AEAP_CONFIG_CLIENT, "config", "aeap.conf,criteria=type=client");
 
-	if (ast_sorcery_object_register(aeap_sorcery, "server", aeap_server_alloc,
-		NULL, aeap_server_apply)) {
-		ast_log(LOG_ERROR, "AEAP - failed to register server sorcery object\n");
+	if (ast_sorcery_object_register(aeap_sorcery, "client", client_config_alloc,
+		NULL, client_config_apply)) {
+		ast_log(LOG_ERROR, "AEAP - failed to register client sorcery object\n");
 		return AST_MODULE_LOAD_DECLINE;
 	}
 
-	ast_sorcery_object_field_register(aeap_sorcery, "server", "type", "", OPT_NOOP_T, 0, 0);
-	ast_sorcery_object_field_register(aeap_sorcery, "server", "server_url", "", OPT_STRINGFIELD_T, 0, STRFLDSET(struct aeap_server, server_url));
-	ast_sorcery_object_field_register(aeap_sorcery, "server", "codecs", "", OPT_CODEC_T, 1, FLDSET(struct aeap_server, codecs));
+	ast_sorcery_object_field_register(aeap_sorcery, AEAP_CONFIG_CLIENT, "type", "", OPT_NOOP_T, 0, 0);
+	ast_sorcery_object_field_register(aeap_sorcery, AEAP_CONFIG_CLIENT, "url", "", OPT_STRINGFIELD_T, 0, STRFLDSET(struct ast_aeap_client_config, url));
+	ast_sorcery_object_field_register(aeap_sorcery, AEAP_CONFIG_CLIENT, "protocol", "", OPT_STRINGFIELD_T, 0, STRFLDSET(struct ast_aeap_client_config, protocol));
+	ast_sorcery_object_field_register(aeap_sorcery, AEAP_CONFIG_CLIENT, "codecs", "", OPT_CODEC_T, 1, FLDSET(struct ast_aeap_client_config, codecs));
 
 	ast_sorcery_load(aeap_sorcery);
 
@@ -295,4 +400,5 @@ AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_GLOBAL_SYMBOLS | AST_MODFLAG_LOAD_
 	.unload = unload_module,
 	.reload = reload_module,
 	.load_pri = AST_MODPRI_CHANNEL_DEPEND,
+	.requires = "res_http_websocket",
 );
diff --git a/res/res_aeap.exports.in b/res/res_aeap.exports.in
new file mode 100644
index 0000000000000000000000000000000000000000..c1fc5c4988505c157cc29bd2f1772a55f3735a69
--- /dev/null
+++ b/res/res_aeap.exports.in
@@ -0,0 +1,7 @@
+{
+	global:
+		LINKER_SYMBOL_PREFIXaeap_*;
+		LINKER_SYMBOL_PREFIXast_aeap_*;
+	local:
+		*;
+};
diff --git a/res/res_aeap/aeap.c b/res/res_aeap/aeap.c
new file mode 100644
index 0000000000000000000000000000000000000000..9094bbb3dbbfaacf3f19166812399a28f5147f32
--- /dev/null
+++ b/res/res_aeap/aeap.c
@@ -0,0 +1,501 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include <pthread.h>
+
+#include "asterisk/astobj2.h"
+#include "asterisk/strings.h"
+
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#include "logger.h"
+#include "transaction.h"
+#include "transport.h"
+
+#define AEAP_RECV_SIZE 32768
+
+struct aeap_user_data {
+	/*! The user data object */
+	void *obj;
+	/*! A user data identifier */
+	char id[0];
+};
+
+AO2_STRING_FIELD_HASH_FN(aeap_user_data, id);
+AO2_STRING_FIELD_CMP_FN(aeap_user_data, id);
+
+#define USER_DATA_BUCKETS 11
+
+struct ast_aeap {
+	/*! This object's configuration parameters */
+	const struct ast_aeap_params *params;
+	/*! Container for registered user data objects */
+	struct ao2_container *user_data;
+	/*! Transactions container */
+	struct ao2_container *transactions;
+	/*! Transport layer communicator */
+	struct aeap_transport *transport;
+	/*! Id of thread that reads data from the transport */
+	pthread_t read_thread_id;
+};
+
+static int tsx_end(void *obj, void *arg, int flags)
+{
+	aeap_transaction_end(obj, -1);
+
+	return 0;
+}
+
+static void aeap_destructor(void *obj)
+{
+	struct ast_aeap *aeap = obj;
+
+	/* Disconnect things first, which keeps transactions from further executing */
+	ast_aeap_disconnect(aeap);
+
+	aeap_transport_destroy(aeap->transport);
+
+	/*
+	 * Each contained transaction holds a pointer back to this transactions container,
+	 * which is removed upon transaction end. Thus by explicitly ending each transaction
+	 * here we can ensure all references to the transactions container are removed.
+	 */
+	ao2_callback(aeap->transactions, OBJ_UNLINK | OBJ_NODATA | OBJ_MULTIPLE,
+			tsx_end, NULL);
+	ao2_cleanup(aeap->transactions);
+
+	ao2_cleanup(aeap->user_data);
+}
+
+struct ast_aeap *ast_aeap_create(const char *transport_type,
+	const struct ast_aeap_params *params)
+{
+	struct ast_aeap *aeap;
+
+	aeap = ao2_alloc(sizeof(*aeap), aeap_destructor);
+	if (!aeap) {
+		ast_log(LOG_ERROR, "AEAP: unable to create");
+		return NULL;
+	}
+
+	aeap->params = params;
+	aeap->read_thread_id = AST_PTHREADT_NULL;
+
+	aeap->user_data = ao2_container_alloc_hash(AO2_ALLOC_OPT_LOCK_MUTEX, 0, USER_DATA_BUCKETS,
+		aeap_user_data_hash_fn, NULL, aeap_user_data_cmp_fn);
+	if (!aeap->user_data) {
+		aeap_error(aeap, NULL, "unable to create user data container");
+		ao2_ref(aeap, -1);
+		return NULL;
+	}
+
+	aeap->transactions = aeap_transactions_create();
+	if (!aeap->transactions) {
+		aeap_error(aeap, NULL, "unable to create transactions container");
+		ao2_ref(aeap, -1);
+		return NULL;
+	}
+
+	aeap->transport = aeap_transport_create(transport_type);
+	if (!aeap->transport) {
+		aeap_error(aeap, NULL, "unable to create transport");
+		ao2_ref(aeap, -1);
+		return NULL;
+	}
+
+	return aeap;
+}
+
+static struct aeap_user_data *aeap_user_data_create(const char *id, void *obj,
+	ast_aeap_user_obj_cleanup cleanup)
+{
+	struct aeap_user_data *data;
+
+	ast_assert(id != NULL);
+
+	data = ao2_t_alloc_options(sizeof(*data) + strlen(id) + 1, cleanup,
+		AO2_ALLOC_OPT_LOCK_NOLOCK, "");
+	if (!data) {
+		if (cleanup) {
+			cleanup(obj);
+		}
+
+		return NULL;
+	}
+
+	strcpy(data->id, id); /* safe */
+	data->obj = obj;
+
+	return data;
+}
+
+int ast_aeap_user_data_register(struct ast_aeap *aeap, const char *id, void *obj,
+	ast_aeap_user_obj_cleanup cleanup)
+{
+	struct aeap_user_data *data;
+
+	data = aeap_user_data_create(id, obj, cleanup);
+	if (!data) {
+		return -1;
+	}
+
+	if (!ao2_link(aeap->user_data, data)) {
+		ao2_ref(data, -1);
+		return -1;
+	}
+
+	ao2_ref(data, -1);
+	return 0;
+}
+
+void ast_aeap_user_data_unregister(struct ast_aeap *aeap, const char *id)
+{
+	ao2_find(aeap->user_data, id, OBJ_SEARCH_KEY | OBJ_UNLINK | OBJ_NODATA);
+}
+
+void *ast_aeap_user_data_object_by_id(struct ast_aeap *aeap, const char *id)
+{
+	struct aeap_user_data *data;
+	void *obj;
+
+	data = ao2_find(aeap->user_data, id, OBJ_SEARCH_KEY);
+	if (!data) {
+		return NULL;
+	}
+
+	obj = data->obj;
+	ao2_ref(data, -1);
+
+	/*
+	 * Returned object's lifetime is based on how it was registered.
+	 * See public function docs for more info
+	 */
+	return obj;
+}
+
+static int raise_msg_handler(struct ast_aeap *aeap,	const struct ast_aeap_message_handler *handlers,
+	size_t size, struct ast_aeap_message *msg, void *data)
+{
+	ast_aeap_on_message on_message = NULL;
+	size_t i;
+
+	if (!aeap->params->emit_error) {
+		const char *error_msg = ast_aeap_message_error_msg(msg);
+
+		if (error_msg) {
+			aeap_error(aeap, NULL, "%s", error_msg);
+			return -1;
+		}
+
+		/* If no error_msg then it's assumed this is not an error message */
+	}
+
+	for (i = 0; i < size; ++i) {
+		if (ast_strlen_zero(handlers[i].name)) {
+			/* A default handler is specified. Use it if no other match is found */
+			on_message = handlers[i].on_message;
+			continue;
+		}
+
+		if (ast_aeap_message_is_named(msg, handlers[i].name)) {
+			on_message = handlers[i].on_message;
+			break;
+		}
+	}
+
+	if (on_message) {
+		return on_message(aeap, msg, data);
+	}
+
+	/* Respond with un-handled error */
+	ast_aeap_send_msg(aeap, ast_aeap_message_create_error(aeap->params->msg_type,
+		ast_aeap_message_name(msg), ast_aeap_message_id(msg),
+		"Unsupported and/or un-handled message"));
+
+	return 0;
+}
+
+static void raise_msg(struct ast_aeap *aeap, const void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE serial_type)
+{
+	struct ast_aeap_message *msg;
+	struct aeap_transaction *tsx;
+	int res = 0;
+
+	if (!aeap->params || !aeap->params->msg_type ||
+		ast_aeap_message_serial_type(aeap->params->msg_type) != serial_type ||
+		!(msg = ast_aeap_message_deserialize(aeap->params->msg_type, buf, size))) {
+		return;
+	}
+
+	/* See if this msg is involved in a transaction */
+	tsx = aeap_transaction_get(aeap->transactions, ast_aeap_message_id(msg));
+
+	/* If so go ahead and cancel the timeout timer */
+	aeap_transaction_cancel_timer(tsx);
+
+	if (aeap->params->request_handlers && ast_aeap_message_is_request(msg)) {
+		res = raise_msg_handler(aeap, aeap->params->request_handlers, aeap->params->request_handlers_size,
+			msg, tsx ? aeap_transaction_user_obj(tsx) : NULL);
+	} else if (aeap->params->response_handlers && ast_aeap_message_is_response(msg)) {
+		res = raise_msg_handler(aeap, aeap->params->response_handlers, aeap->params->response_handlers_size,
+			msg, tsx ? aeap_transaction_user_obj(tsx) : NULL);
+	}
+
+	/* Complete transaction (Note, removes tsx ref) */
+	aeap_transaction_end(tsx, res);
+
+	ao2_ref(msg, -1);
+}
+
+static void *aeap_receive(void *data)
+{
+	struct ast_aeap *aeap = data;
+	void *buf;
+
+	buf = ast_calloc(1, AEAP_RECV_SIZE);
+	if (!buf) {
+		aeap_error(aeap, NULL, "unable to create read buffer");
+		goto aeap_receive_error;
+	}
+
+	while (aeap_transport_is_connected(aeap->transport)) {
+		enum AST_AEAP_DATA_TYPE rtype;
+		intmax_t size;
+
+		size = aeap_transport_read(aeap->transport, buf, AEAP_RECV_SIZE, &rtype);
+		if (size < 0) {
+			goto aeap_receive_error;
+		}
+
+		if (!size) {
+			continue;
+		}
+
+		switch (rtype) {
+		case AST_AEAP_DATA_TYPE_BINARY:
+			if (aeap->params && aeap->params->on_binary) {
+				aeap->params->on_binary(aeap, buf, size);
+			}
+			break;
+		case AST_AEAP_DATA_TYPE_STRING:
+			ast_debug(3, "AEAP: received message: %s\n", (char *)buf);
+			if (aeap->params && aeap->params->on_string) {
+				aeap->params->on_string(aeap, (const char *)buf, size - 1);
+			}
+			break;
+		default:
+			break;
+		}
+
+		raise_msg(aeap, buf, size, rtype);
+	};
+
+	ast_free(buf);
+	return NULL;
+
+aeap_receive_error:
+	/*
+	 * An unrecoverable error occurred so ensure the aeap and transport reset
+	 * to a disconnected state. We don't want this thread to "join" itself so set
+	 * its id to NULL prior to disconnecting.
+	 */
+	aeap_error(aeap, NULL, "unrecoverable read error, disconnecting");
+
+	ao2_lock(aeap);
+	aeap->read_thread_id = AST_PTHREADT_NULL;
+	ao2_unlock(aeap);
+
+	ast_aeap_disconnect(aeap);
+
+	ast_free(buf);
+
+	if (aeap->params && aeap->params->on_error) {
+		aeap->params->on_error(aeap);
+	}
+
+	return NULL;
+}
+
+int ast_aeap_connect(struct ast_aeap *aeap, const char *url, const char *protocol, int timeout)
+{
+	SCOPED_AO2LOCK(lock, aeap);
+
+	if (aeap_transport_is_connected(aeap->transport)) {
+		/* Should already be connected, so nothing to do */
+		return 0;
+	}
+
+	if (aeap_transport_connect(aeap->transport, url, protocol, timeout)) {
+		aeap_error(aeap, NULL, "unable to connect transport");
+		return -1;
+	}
+
+	if (ast_pthread_create_background(&aeap->read_thread_id, NULL,
+			aeap_receive, aeap)) {
+		aeap_error(aeap, NULL, "unable to start read thread: %s",
+			strerror(errno));
+		ast_aeap_disconnect(aeap);
+		return -1;
+	}
+
+	return 0;
+}
+
+struct ast_aeap *ast_aeap_create_and_connect(const char *type,
+	const struct ast_aeap_params *params, const char *url, const char *protocol, int timeout)
+{
+	struct ast_aeap *aeap;
+
+	aeap = ast_aeap_create(type, params);
+	if (!aeap) {
+		return NULL;
+	}
+
+	if (ast_aeap_connect(aeap, url, protocol, timeout)) {
+		ao2_ref(aeap, -1);
+		return NULL;
+	}
+
+	return aeap;
+}
+
+int ast_aeap_disconnect(struct ast_aeap *aeap)
+{
+	ao2_lock(aeap);
+
+	aeap_transport_disconnect(aeap->transport);
+
+	if (aeap->read_thread_id != AST_PTHREADT_NULL) {
+		/*
+		 * The read thread calls disconnect if an error occurs, so
+		 * unlock the aeap before "joining" to avoid a deadlock.
+		 */
+		ao2_unlock(aeap);
+		pthread_join(aeap->read_thread_id, NULL);
+		ao2_lock(aeap);
+
+		aeap->read_thread_id = AST_PTHREADT_NULL;
+	}
+
+	ao2_unlock(aeap);
+
+	return 0;
+}
+
+static int aeap_send(struct ast_aeap *aeap, const void *buf, uintmax_t size,
+	enum AST_AEAP_DATA_TYPE type)
+{
+	intmax_t num;
+
+	num = aeap_transport_write(aeap->transport, buf, size, type);
+
+	if (num == 0) {
+		/* Nothing written, could be disconnected */
+		return 0;
+	}
+
+	if (num < 0) {
+		aeap_error(aeap, NULL, "error sending data");
+		return -1;
+	}
+
+	if (num < size) {
+		aeap_error(aeap, NULL, "not all data sent");
+		return -1;
+	}
+
+	if (num > size) {
+		aeap_error(aeap, NULL, "sent data truncated");
+		return -1;
+	}
+
+	return 0;
+}
+
+int ast_aeap_send_binary(struct ast_aeap *aeap, const void *buf, uintmax_t size)
+{
+	return aeap_send(aeap, buf, size, AST_AEAP_DATA_TYPE_BINARY);
+}
+
+int ast_aeap_send_msg(struct ast_aeap *aeap, struct ast_aeap_message *msg)
+{
+	void *buf;
+	intmax_t size;
+	int res;
+
+	if (!msg) {
+		aeap_error(aeap, NULL, "no message to send");
+		return -1;
+	}
+
+	if (ast_aeap_message_serialize(msg, &buf, &size)) {
+		aeap_error(aeap, NULL, "unable to serialize outgoing message");
+		ao2_ref(msg, -1);
+		return -1;
+	}
+
+	res = aeap_send(aeap, buf, size, msg->type->serial_type);
+
+	ast_free(buf);
+	ao2_ref(msg, -1);
+
+	return res;
+}
+
+int ast_aeap_send_msg_tsx(struct ast_aeap *aeap, struct ast_aeap_tsx_params *params)
+{
+	struct aeap_transaction *tsx = NULL;
+	int res = 0;
+
+	if (!params) {
+		return -1;
+	}
+
+	if (!params->msg) {
+		aeap_transaction_params_cleanup(params);
+		aeap_error(aeap, NULL, "no message to send");
+		return -1;
+	}
+
+	/* The transaction will take over params cleanup, which includes the msg reference */
+	tsx = aeap_transaction_create_and_add(aeap->transactions,
+		ast_aeap_message_id(params->msg), params, aeap);
+	if (!tsx) {
+		return -1;
+	}
+
+	if (ast_aeap_send_msg(aeap, ao2_bump(params->msg))) {
+		aeap_transaction_end(tsx, -1); /* Removes container, and tsx ref */
+		return -1;
+	}
+
+	if (aeap_transaction_start(tsx)) {
+		aeap_transaction_end(tsx, -1); /* Removes container, and tsx ref */
+		return -1;
+	}
+
+	res = aeap_transaction_result(tsx);
+
+	ao2_ref(tsx, -1);
+
+	return res;
+}
diff --git a/res/res_aeap/general.c b/res/res_aeap/general.c
new file mode 100644
index 0000000000000000000000000000000000000000..7bd180703fb4de4cdf82774936781b97561e57ef
--- /dev/null
+++ b/res/res_aeap/general.c
@@ -0,0 +1,58 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/astobj2.h"
+#include "asterisk/sched.h"
+
+#include "general.h"
+
+/*! \brief Scheduler for transaction timeouts */
+static struct ast_sched_context *sched = NULL;
+
+struct ast_sched_context *aeap_sched_context(void)
+{
+	return sched;
+}
+
+void aeap_general_finalize(void)
+{
+	if (sched) {
+		ast_sched_context_destroy(sched);
+		sched = NULL;
+	}
+}
+
+int aeap_general_initialize(void)
+{
+	sched = ast_sched_context_create();
+	if (!sched) {
+		ast_log(LOG_ERROR, "AEAP scheduler: unable to create context");
+		return -1;
+	}
+
+	if (ast_sched_start_thread(sched)) {
+		ast_log(LOG_ERROR, "AEAP scheduler: unable to start thread");
+		aeap_general_finalize();
+		return -1;
+	}
+
+	return 0;
+}
+
diff --git a/res/res_aeap/general.h b/res/res_aeap/general.h
new file mode 100644
index 0000000000000000000000000000000000000000..52a092b43c3dd79f8a05bf83ca787e99cc03735e
--- /dev/null
+++ b/res/res_aeap/general.h
@@ -0,0 +1,41 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef RES_AEAP_GENERAL_H
+#define RES_AEAP_GENERAL_H
+
+/*!
+ * \brief Retrieve the scheduling context
+ *
+ * \returns The scheduling context
+ */
+struct ast_sched_context *aeap_sched_context(void);
+
+/*!
+ * \brief Initialize general/common AEAP facilities
+ *
+ * \returns 0 on success, -1 on error
+ */
+int aeap_general_initialize(void);
+
+/*!
+ * \brief Finalize/cleanup general AEAP facilities
+ */
+void aeap_general_finalize(void);
+
+#endif /* RES_AEAP_GENERAL_H */
diff --git a/res/res_aeap/logger.h b/res/res_aeap/logger.h
new file mode 100644
index 0000000000000000000000000000000000000000..db264c357fe84dc267ed3d8887c14fe5ec71bf60
--- /dev/null
+++ b/res/res_aeap/logger.h
@@ -0,0 +1,60 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef RES_AEAP_LOGGER_H
+#define RES_AEAP_LOGGER_H
+
+#include "asterisk.h"
+
+#include "asterisk/logger.h"
+#include "asterisk/strings.h"
+
+/*!
+ * \brief Log an Asterisk external application message
+ *
+ * \param level The logging level
+ * \param obj The object being logged
+ * \param name Optional subsystem name
+ * \param fmt Format string
+ * \param ... Parameters for the format string
+ */
+#define aeap_log(level, obj, name, fmt, ...) \
+	ast_log(level, "AEAP%s%s (%p): " fmt "\n", ast_strlen_zero(name) ? "" : " ", \
+			ast_strlen_zero(name) ? "" : name, obj, ##__VA_ARGS__)
+
+/*!
+ * \brief Log an Asterisk external application error
+ *
+ * \param obj The object being logged
+ * \param name Optional subsystem name
+ * \param fmt Format string
+ * \param ... Parameters for the format string
+ */
+#define aeap_error(obj, name, fmt, ...) aeap_log(LOG_ERROR, obj, name, fmt, ##__VA_ARGS__)
+
+/*!
+ * \brief Log an Asterisk external application warning
+ *
+ * \param obj The object being logged
+ * \param name Optional subsystem name
+ * \param fmt Format string
+ * \param ... Parameters for the format string
+ */
+#define aeap_warn(obj, name, fmt, ...) aeap_log(LOG_WARNING, obj, name, fmt, ##__VA_ARGS__)
+
+#endif /* RES_AEAP_LOGGER_H */
diff --git a/res/res_aeap/message.c b/res/res_aeap/message.c
new file mode 100644
index 0000000000000000000000000000000000000000..826e93b664ee3cc4a739aaf08978db93d3ed1a43
--- /dev/null
+++ b/res/res_aeap/message.c
@@ -0,0 +1,270 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/astobj2.h"
+#include "asterisk/utils.h"
+#include "asterisk/uuid.h"
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+enum AST_AEAP_DATA_TYPE ast_aeap_message_serial_type(const struct ast_aeap_message_type *type)
+{
+	ast_assert(type != NULL);
+
+	return type->serial_type;
+}
+
+static void message_destructor(void *obj)
+{
+	struct ast_aeap_message *msg = obj;
+
+	if (msg->type->destruct) {
+		msg->type->destruct(msg);
+	}
+}
+
+static struct ast_aeap_message *message_create(const struct ast_aeap_message_type *type)
+{
+	struct ast_aeap_message *msg;
+
+	msg = ao2_t_alloc_options(type->type_size, message_destructor,
+		AO2_ALLOC_OPT_LOCK_NOLOCK, type->type_name);
+	if (!msg) {
+		ast_log(LOG_ERROR, "AEAP message %s: unable to create\n", type->type_name);
+		return NULL;
+	}
+
+	msg->type = type;
+
+	return msg;
+}
+
+struct ast_aeap_message *ast_aeap_message_create1(const struct ast_aeap_message_type *type,
+	const void *params)
+{
+	struct ast_aeap_message *msg;
+
+	ast_assert(type != NULL);
+	ast_assert(type->construct1 != NULL);
+
+	msg = message_create(type);
+	if (!msg) {
+		return NULL;
+	}
+
+	if (type->construct1(msg, params)) {
+		ast_log(LOG_ERROR, "AEAP message %s: unable to construct1\n", type->type_name);
+		ao2_ref(msg, -1);
+		return NULL;
+	}
+
+	return msg;
+}
+
+struct ast_aeap_message *ast_aeap_message_create2(const struct ast_aeap_message_type *type,
+	const char *msg_type, const char *name, const char *id, const void *params)
+{
+	struct ast_aeap_message *msg;
+
+	ast_assert(type != NULL);
+	ast_assert(type->construct2 != NULL);
+	ast_assert(msg_type != NULL);
+	ast_assert(name != NULL);
+
+	msg = message_create(type);
+	if (!msg) {
+		return NULL;
+	}
+
+	if (type->construct2(msg, msg_type, name, id, params)) {
+		ast_log(LOG_ERROR, "AEAP message %s: unable to construct2\n", type->type_name);
+		ao2_ref(msg, -1);
+		return NULL;
+	}
+
+	return msg;
+}
+
+struct ast_aeap_message *ast_aeap_message_create_request(const struct ast_aeap_message_type *type,
+	const char *name, const char *id, const void *params)
+{
+	struct ast_aeap_message *msg;
+
+	msg = ast_aeap_message_create2(type, "request", name, id, params);
+	if (!msg) {
+		return NULL;
+	}
+
+	if (!id && !ast_aeap_message_id_generate(msg)) {
+		ao2_ref(msg, -1);
+		return NULL;
+	}
+
+	return msg;
+}
+
+struct ast_aeap_message *ast_aeap_message_create_response(const struct ast_aeap_message_type *type,
+	const char *name, const char *id, const void *params)
+{
+	return ast_aeap_message_create2(type, "response", name, id, params);
+}
+
+struct ast_aeap_message *ast_aeap_message_create_error(const struct ast_aeap_message_type *type,
+	const char *name, const char *id, const char *error_msg)
+{
+	struct ast_aeap_message *msg;
+
+	msg = ast_aeap_message_create_response(type, name, id, NULL);
+	if (!msg) {
+		return NULL;
+	}
+
+	if (ast_aeap_message_error_msg_set(msg, error_msg)) {
+		ao2_ref(msg, -1);
+		return NULL;
+	}
+
+	return msg;
+}
+
+struct ast_aeap_message *ast_aeap_message_deserialize(const struct ast_aeap_message_type *type,
+	const void *buf, intmax_t size)
+{
+	struct ast_aeap_message *msg;
+
+	ast_assert(type != NULL);
+	ast_assert(type->deserialize != NULL);
+
+	msg = ast_aeap_message_create1(type, NULL);
+	if (!msg) {
+		return NULL;
+	}
+
+	if (type->deserialize(msg, buf, size)) {
+		ao2_ref(msg, -1);
+		return NULL;
+	}
+
+	return msg;
+}
+
+int ast_aeap_message_serialize(const struct ast_aeap_message *message,
+	void **buf, intmax_t *size)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->serialize ? message->type->serialize(message, buf, size) : 0;
+}
+
+const char *ast_aeap_message_id(const struct ast_aeap_message *message)
+{
+	const char *id = NULL;
+
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	if (message->type->id) {
+		id = message->type->id(message);
+	}
+
+	return id ? id : "";
+}
+
+int ast_aeap_message_id_set(struct ast_aeap_message *message, const char *id)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->id_set ? message->type->id_set(message, id) : 0;
+}
+
+const char *ast_aeap_message_id_generate(struct ast_aeap_message *message)
+{
+	char uuid_str[AST_UUID_STR_LEN];
+
+	ast_uuid_generate_str(uuid_str, sizeof(uuid_str));
+	if (strlen(uuid_str) != (AST_UUID_STR_LEN - 1)) {
+		ast_log(LOG_ERROR, "AEAP message %s failed to generate UUID for message '%s'",
+			message->type->type_name, ast_aeap_message_name(message));
+		return NULL;
+	}
+
+	return ast_aeap_message_id_set(message, uuid_str) ? NULL : ast_aeap_message_id(message);
+}
+
+const char *ast_aeap_message_name(const struct ast_aeap_message *message)
+{
+	const char *name = NULL;
+
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	if (message->type->name) {
+		name = message->type->name(message);
+	}
+
+	return name ? name : "";
+}
+
+int ast_aeap_message_is_named(const struct ast_aeap_message *message, const char *name)
+{
+	return name ? !strcasecmp(ast_aeap_message_name(message), name) : 0;
+}
+
+void *ast_aeap_message_data(struct ast_aeap_message *message)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->data ? message->type->data(message) : NULL;
+}
+
+int ast_aeap_message_is_request(const struct ast_aeap_message *message)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->is_request ? message->type->is_request(message) : 0;
+}
+
+int ast_aeap_message_is_response(const struct ast_aeap_message *message)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->is_response ? message->type->is_response(message) : 0;
+}
+
+const char *ast_aeap_message_error_msg(const struct ast_aeap_message *message)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->error_msg ? message->type->error_msg(message) : NULL;
+}
+
+int ast_aeap_message_error_msg_set(struct ast_aeap_message *message, const char *error_msg)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->error_msg_set ? message->type->error_msg_set(message, error_msg) : 0;
+}
diff --git a/res/res_aeap/message_json.c b/res/res_aeap/message_json.c
new file mode 100644
index 0000000000000000000000000000000000000000..f5cfe9d46134ca268ec01a9cace756c087d461f9
--- /dev/null
+++ b/res/res_aeap/message_json.c
@@ -0,0 +1,191 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/json.h"
+
+#include "asterisk/res_aeap_message.h"
+
+#define JSON_MSG(lvalue, rvalue) struct message_json *lvalue = \
+		((struct message_json *)rvalue)
+
+/*!
+ * \brief Asterisk external application JSON message type
+ */
+struct message_json {
+	/*! The base message type (must be first) */
+	struct ast_aeap_message base;
+	/*! Underlying JSON data structure */
+	struct ast_json *json;
+};
+
+static int message_json_construct1(struct ast_aeap_message *self, const void *params)
+{
+	JSON_MSG(msg, self);
+
+	msg->json = ast_json_ref((struct ast_json *)params) ?: ast_json_object_create();
+
+	return msg->json ? 0 : -1;
+}
+
+static int message_json_construct2(struct ast_aeap_message *self, const char *msg_type,
+	const char *name, const char *id, const void *params)
+{
+	struct ast_json *msg_data;
+	int res;
+
+	msg_data = ast_json_pack("{s:s,s:s*}", msg_type, name, "id", id);
+
+	if (!msg_data) {
+		ast_log(LOG_ERROR, "AEAP message json: failed to create data for '%s: %s'", msg_type, name);
+		return -1;
+	}
+
+	if (params && ast_json_object_update(msg_data, (struct ast_json *)params)) {
+		ast_log(LOG_ERROR, "AEAP message json: failed to update data for '%s: %s'", msg_type, name);
+		ast_json_unref(msg_data);
+		return -1;
+	}
+
+	res = message_json_construct1(self, msg_data);
+	ast_json_unref(msg_data);
+	return res;
+}
+
+static void message_json_destruct(struct ast_aeap_message *self)
+{
+	JSON_MSG(msg, self);
+
+	ast_json_unref(msg->json);
+}
+
+static int message_json_deserialize(struct ast_aeap_message *self, const void *buf, intmax_t size)
+{
+	JSON_MSG(msg, self);
+
+	msg->json = ast_json_load_buf(buf, size, NULL);
+
+	return msg->json ? 0 : -1;
+}
+
+static int message_json_serialize(const struct ast_aeap_message *self, void **buf, intmax_t *size)
+{
+	const JSON_MSG(msg, self);
+
+	*buf = ast_json_dump_string(msg->json);
+	if (!*buf) {
+		*size = 0;
+		return -1;
+	}
+
+	*size = strlen(*buf);
+
+	return 0;
+}
+
+static const char *message_json_id(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+
+	return ast_json_object_string_get(msg->json, "id");
+}
+
+static int message_json_id_set(struct ast_aeap_message *self, const char *id)
+{
+	JSON_MSG(msg, self);
+
+	if (ast_json_object_set(msg->json, "id", ast_json_string_create(id))) {
+		return -1;
+	}
+
+	return 0;
+}
+
+static const char *message_json_name(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+	struct ast_json_iter *iter;
+
+	iter = ast_json_object_iter_at(msg->json, "response");
+	if (!iter) {
+		iter = ast_json_object_iter_at(msg->json, "request");
+	}
+
+	return iter ? ast_json_string_get(ast_json_object_iter_value(iter)) : "";
+}
+
+static void *message_json_data(struct ast_aeap_message *self)
+{
+	JSON_MSG(msg, self);
+
+	return msg->json;
+}
+
+static int message_json_is_request(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+
+	return ast_json_object_iter_at(msg->json, "request") != NULL;
+}
+
+static int message_json_is_response(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+
+	return ast_json_object_iter_at(msg->json, "response") != NULL;
+}
+
+static const char *message_json_error_msg(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+
+	return ast_json_object_string_get(msg->json, "error_msg");
+}
+
+static int message_json_error_msg_set(struct ast_aeap_message *self, const char *error_msg)
+{
+	JSON_MSG(msg, self);
+
+	if (ast_json_object_set(msg->json, "error_msg", ast_json_string_create(error_msg))) {
+		return -1;
+	}
+
+	return 0;
+}
+
+static const struct ast_aeap_message_type message_type_json = {
+	.type_size = sizeof(struct message_json),
+	.type_name = "json",
+	.serial_type = AST_AEAP_DATA_TYPE_STRING,
+	.construct1 = message_json_construct1,
+	.construct2 = message_json_construct2,
+	.destruct = message_json_destruct,
+	.deserialize = message_json_deserialize,
+	.serialize = message_json_serialize,
+	.id = message_json_id,
+	.id_set = message_json_id_set,
+	.name = message_json_name,
+	.data = message_json_data,
+	.is_request = message_json_is_request,
+	.is_response = message_json_is_response,
+	.error_msg = message_json_error_msg,
+	.error_msg_set = message_json_error_msg_set,
+};
+
+const struct ast_aeap_message_type *ast_aeap_message_type_json = &message_type_json;
diff --git a/res/res_aeap/transaction.c b/res/res_aeap/transaction.c
new file mode 100644
index 0000000000000000000000000000000000000000..3f42cb294bddda551aec4aa4c33ac22faf8dba5c
--- /dev/null
+++ b/res/res_aeap/transaction.c
@@ -0,0 +1,284 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/astobj2.h"
+#include "asterisk/sched.h"
+#include "asterisk/utils.h"
+
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#include "general.h"
+#include "logger.h"
+#include "transaction.h"
+
+struct aeap_transaction {
+	/*! Pointer back to owner object */
+	struct ast_aeap *aeap;
+	/*! The container this transaction is in */
+	struct ao2_container *container;
+	/*! Scheduler ID message timeout */
+	int sched_id;
+	/*! Whether or not the handler has been executed */
+	int handled;
+	/*! Used to sync matching received messages */
+	ast_cond_t handled_cond;
+	/*! The result of this transaction */
+	int result;
+	/*! The timeout data */
+	struct ast_aeap_tsx_params params;
+	/*! The transaction identifier */
+	char id[0];
+};
+
+/*! \brief Number of transaction buckets */
+#define AEAP_TRANSACTION_BUCKETS 11
+
+AO2_STRING_FIELD_HASH_FN(aeap_transaction, id);
+AO2_STRING_FIELD_CMP_FN(aeap_transaction, id);
+
+int aeap_transaction_cancel_timer(struct aeap_transaction *tsx)
+{
+	if (tsx && tsx->sched_id != -1) {
+		AST_SCHED_DEL_UNREF(aeap_sched_context(), tsx->sched_id, ao2_ref(tsx, -1));
+		return tsx->sched_id != -1;
+	}
+
+	return 0;
+}
+
+void aeap_transaction_params_cleanup(struct ast_aeap_tsx_params *params)
+{
+	ao2_cleanup(params->msg);
+
+	if (params->obj_cleanup) {
+		params->obj_cleanup(params->obj);
+	}
+}
+
+static void transaction_destructor(void *obj)
+{
+	struct aeap_transaction *tsx = obj;
+
+	/* Ensure timer is canceled */
+	aeap_transaction_cancel_timer(tsx);
+
+	aeap_transaction_params_cleanup(&tsx->params);
+
+	ast_cond_destroy(&tsx->handled_cond);
+}
+
+static struct aeap_transaction *transaction_create(const char *id,
+	struct ast_aeap_tsx_params *params, struct ast_aeap *aeap)
+{
+	struct aeap_transaction *tsx;
+
+	if (!id) {
+		aeap_error(aeap, "transaction", "missing transaction id");
+		aeap_transaction_params_cleanup(params);
+		return NULL;
+	}
+
+	tsx = ao2_alloc(sizeof(*tsx) + strlen(id) + 1, transaction_destructor);
+	if (!tsx) {
+		aeap_error(aeap, "transaction", "unable to create for '%s'", id);
+		aeap_transaction_params_cleanup(params);
+		return NULL;
+	}
+
+	strcpy(tsx->id, id); /* safe */
+	tsx->sched_id = -1;
+
+	ast_cond_init(&tsx->handled_cond, NULL);
+
+	/*
+	 * Currently, transactions, and their lifetimes are fully managed by the given 'aeap'
+	 * object, so do not bump its reference here as we want the 'aeap' object to stop
+	 * transactions and not transactions potentially stopping the 'aeap' object.
+	 */
+	tsx->aeap = aeap;
+	tsx->params = *params;
+
+	return tsx;
+}
+
+static void transaction_end(struct aeap_transaction *tsx, int timed_out, int result)
+{
+	if (!tsx) {
+		return;
+	}
+
+	ao2_lock(tsx);
+
+	tsx->result = result;
+
+	if (tsx->container) {
+		ao2_unlink(tsx->container, tsx);
+		tsx->container = NULL;
+	}
+
+	if (!timed_out) {
+		aeap_transaction_cancel_timer(tsx);
+	} else if (tsx->sched_id != -1) {
+		tsx->sched_id = -1;
+	}
+
+	if (!tsx->handled) {
+		if (timed_out) {
+			if (tsx->params.on_timeout) {
+				tsx->params.on_timeout(tsx->aeap, tsx->params.msg, tsx->params.obj);
+			} else {
+				aeap_error(tsx->aeap, "transaction", "message '%s' timed out",
+					ast_aeap_message_name(tsx->params.msg));
+			}
+		}
+
+		tsx->handled = 1;
+		ast_cond_signal(&tsx->handled_cond);
+	}
+
+	ao2_unlock(tsx);
+
+	ao2_ref(tsx, -1);
+}
+
+static int transaction_raise_timeout(const void *data)
+{
+	/* Ref added added at timer creation removed in end call */
+	transaction_end((struct aeap_transaction *)data, 1, -1);
+
+	return 0;
+}
+
+static int transaction_sched_timer(struct aeap_transaction *tsx)
+{
+	if (tsx->params.timeout <= 0 || tsx->sched_id != -1) {
+		return 0;
+	}
+
+	tsx->sched_id = ast_sched_add(aeap_sched_context(), tsx->params.timeout,
+			transaction_raise_timeout, ao2_bump(tsx));
+	if (tsx->sched_id == -1) {
+		aeap_error(tsx->aeap, "transaction", "unable to schedule timeout for '%s'", tsx->id);
+		ao2_ref(tsx, -1);
+		return -1;
+	}
+
+	return 0;
+}
+
+static void transaction_wait(struct aeap_transaction *tsx)
+{
+	ao2_lock(tsx);
+
+	while (!tsx->handled) {
+		ast_cond_wait(&tsx->handled_cond, ao2_object_get_lockaddr(tsx));
+	}
+
+	ao2_unlock(tsx);
+}
+
+int aeap_transaction_start(struct aeap_transaction *tsx)
+{
+	if (transaction_sched_timer(tsx)) {
+		return -1;
+	}
+
+	if (tsx->params.wait) {
+		/* Wait until transaction completes, or times out */
+		transaction_wait(tsx);
+	}
+
+	return 0;
+}
+
+struct aeap_transaction *aeap_transaction_get(struct ao2_container *transactions, const char *id)
+{
+	return ao2_find(transactions, id, OBJ_SEARCH_KEY);
+}
+
+void aeap_transaction_end(struct aeap_transaction *tsx, int result)
+{
+	transaction_end(tsx, 0, result);
+}
+
+int aeap_transaction_result(struct aeap_transaction *tsx)
+{
+	return tsx->result;
+}
+
+void *aeap_transaction_user_obj(struct aeap_transaction *tsx)
+{
+	return tsx->params.obj;
+}
+
+struct aeap_transaction *aeap_transaction_create_and_add(struct ao2_container *transactions,
+	const char *id, struct ast_aeap_tsx_params *params, struct ast_aeap *aeap)
+{
+	struct aeap_transaction *tsx;
+
+	tsx = transaction_create(id, params, aeap);
+	if (!tsx) {
+		return NULL;
+	}
+
+	if (!ao2_link(transactions, tsx)) {
+		aeap_error(tsx->aeap, "transaction", "unable to add '%s' to container", id);
+		ao2_ref(tsx, -1);
+		return NULL;
+	}
+
+	/*
+	 * Yes, this creates a circular reference. This reference is removed though
+	 * upon transaction end. It's assumed here that the given transactions container
+	 * takes "ownership", and ultimate responsibility of its contained transactions.
+	 * Thus when the given container needs to be unref'ed/freed it must call
+	 * aeap_transaction_end for each transaction prior to doing so.
+	 */
+	/* tsx->container = ao2_bump(transactions); */
+
+	/*
+	 * The transaction needs to know what container manages it, so it can remove
+	 * itself from the given container under certain conditions (e.g. transaction
+	 * timeout).
+	 *
+	 * It's expected that the given container will out live any contained transaction
+	 * (i.e. the container will not itself be destroyed before ensuring all contained
+	 * transactions are ended, and removed). Thus there is no reason to bump the given
+	 * container's reference here.
+	 */
+	tsx->container = transactions;
+
+	return tsx;
+}
+
+struct ao2_container *aeap_transactions_create(void)
+{
+	struct ao2_container *transactions;
+
+	transactions = ao2_container_alloc_hash(AO2_ALLOC_OPT_LOCK_MUTEX, 0, AEAP_TRANSACTION_BUCKETS,
+		aeap_transaction_hash_fn, NULL, aeap_transaction_cmp_fn);
+	if (!transactions) {
+		ast_log(LOG_ERROR, "AEAP transaction: unable to create container\n");
+		return NULL;
+	}
+
+	return transactions;
+}
diff --git a/res/res_aeap/transaction.h b/res/res_aeap/transaction.h
new file mode 100644
index 0000000000000000000000000000000000000000..973ba1a4dd9c2958f6494a3292f78475af1fbe94
--- /dev/null
+++ b/res/res_aeap/transaction.h
@@ -0,0 +1,123 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef RES_AEAP_TRANSACTION_H
+#define RES_AEAP_TRANSACTION_H
+
+#include "asterisk/res_aeap.h"
+
+struct ao2_container;
+struct ast_aeap_tsx_params;
+struct aeap_transaction;
+
+/*!
+ * \brief Create an Asterisk external application transactions container
+ *
+ * \returns A transaction object, or NULL on error
+ */
+struct ao2_container *aeap_transactions_create(void);
+
+/*!
+ * \brief Create a transaction object, and add it to the given container
+ *
+ * \param transactions A transactions container
+ * \param id An id to use for the transaction
+ * \param params Transaction parameters
+ * \param aeap The aeap object that "owns" this transaction
+ *
+ * \returns 0 if successfully create and added, -1 on error
+ */
+struct aeap_transaction *aeap_transaction_create_and_add(struct ao2_container *transactions,
+	const char *id, struct ast_aeap_tsx_params *params, struct ast_aeap *aeap);
+
+/*!
+ * \brief Clean up parameter references, and possibly call optional user object cleanup
+ *
+ * \param params Transaction parameters
+ */
+void aeap_transaction_params_cleanup(struct ast_aeap_tsx_params *params);
+
+/*!
+ * \brief Retrieve a transaction for the id from the container
+ *
+ * \param transactions A transactions container
+ * \param id A transaction id
+ *
+ * \returns an AEAP transaction object, NULL if no transaction is found
+ */
+struct aeap_transaction *aeap_transaction_get(struct ao2_container *transactions,
+	const char *id);
+
+/*!
+ * \brief Start the transaction
+ *
+ * \param tsx The transaction to initiate
+ *
+ * \returns 0 if successfully raised, and handled. Otherwise non zero.
+ */
+int aeap_transaction_start(struct aeap_transaction *tsx);
+
+/*!
+ * \brief End a transaction, and remove it from the given container
+ *
+ * The "result" parameter is a value representing the state (success/failure,
+ * perhaps even something else) of transactional processing upon ending.
+ *
+ * \param tsx A transaction to end
+ * \param result A result to give to the transaction
+ */
+void aeap_transaction_end(struct aeap_transaction *tsx, int result);
+
+/*!
+ * \brief Get a transaction's result
+ *
+ * A transaction's result is a value that represents the relative success (0), or
+ * failure (-1) of a transaction. For example, a timeout is considered a failure
+ * and will elicit a -1.
+ *
+ * This value though is also dependent upon the result of the message handler
+ * associated with the transaction. Meaning if an associated message is handled,
+ * then its result is stored as the transaction result and returned here.
+ *
+ * \param tsx A transaction object
+ *
+ * \returns The transaction result
+ */
+int aeap_transaction_result(struct aeap_transaction *tsx);
+
+/*!
+ * \brief Cancel the transaction timer
+ *
+ * Stops the transaction timer, but does not end/stop the transaction itself
+ *
+ * \param transaction A transaction to cancel the timer on
+ *
+ * \returns 0 if canceled, non zero otherwise
+ */
+int aeap_transaction_cancel_timer(struct aeap_transaction *tsx);
+
+/*!
+ * \brief Retrieve the user object associated with the transaction
+ *
+ * \param transaction A transaction object
+ *
+ * \returns A user object, or NULL if non associated
+ */
+void *aeap_transaction_user_obj(struct aeap_transaction *tsx);
+
+#endif /* RES_AEAP_TRANSACTION_H */
diff --git a/res/res_aeap/transport.c b/res/res_aeap/transport.c
new file mode 100644
index 0000000000000000000000000000000000000000..c032e10ee37c3a0f59de5d19d1a2ffebe81dac6d
--- /dev/null
+++ b/res/res_aeap/transport.c
@@ -0,0 +1,156 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/utils.h"
+
+#include "logger.h"
+#include "transport.h"
+#include "transport_websocket.h"
+
+struct aeap_transport *aeap_transport_create(const char *type)
+{
+	struct aeap_transport *transport = NULL;
+
+	if (!strncasecmp(type, "ws", 2)) {
+		transport = (struct aeap_transport *)aeap_transport_websocket_create();
+	}
+
+	if (!transport) {
+		ast_log(LOG_ERROR, "AEAP transport: failed to create for type '%s'\n", type);
+		return NULL;
+	}
+
+	ast_mutex_init(&transport->read_lock);
+	ast_mutex_init(&transport->write_lock);
+
+	transport->connected = 0;
+
+	return transport;
+}
+
+int aeap_transport_connect(struct aeap_transport *transport, const char *url,
+	const char *protocol, int timeout)
+{
+	int res;
+
+	SCOPED_MUTEX(rlock, &transport->read_lock);
+	SCOPED_MUTEX(wlock, &transport->write_lock);
+
+	if (aeap_transport_is_connected(transport)) {
+		return 0;
+	}
+
+	res = transport->vtable->connect(transport, url, protocol, timeout);
+	if (!res) {
+		transport->connected = 1;
+	}
+
+	return res;
+}
+
+struct aeap_transport *aeap_transport_create_and_connect(const char *type,
+	const char *url, const char *protocol, int timeout)
+{
+	struct aeap_transport *transport = aeap_transport_create(type);
+
+	if (!transport) {
+		return NULL;
+	}
+
+	if (aeap_transport_connect(transport, url, protocol, timeout)) {
+		aeap_transport_destroy(transport);
+		return NULL;
+	}
+
+	return transport;
+}
+
+int aeap_transport_is_connected(struct aeap_transport *transport)
+{
+	/*
+	 * Avoid using a lock to 'read' the 'connected' variable in order to
+	 * keep things slightly more efficient.
+	 */
+	return ast_atomic_fetch_add(&transport->connected, 0, __ATOMIC_RELAXED);
+}
+
+int aeap_transport_disconnect(struct aeap_transport *transport)
+{
+	int res;
+
+	SCOPED_MUTEX(rlock, &transport->read_lock);
+	SCOPED_MUTEX(wlock, &transport->write_lock);
+
+	if (!aeap_transport_is_connected(transport)) {
+		return 0;
+	}
+
+	res = transport->vtable->disconnect(transport);
+
+	/*
+	 * Even though the transport is locked here use atomics to set the value of
+	 * 'connected' since it's possible the variable is being 'read' by another
+	 * thread via the 'is_connected' call.
+	 */
+	ast_atomic_fetch_sub(&transport->connected, 1, __ATOMIC_RELAXED);
+
+	return res;
+}
+
+void aeap_transport_destroy(struct aeap_transport *transport)
+{
+	if (!transport) {
+		return;
+	}
+
+	/* Ensure an orderly disconnect occurs before final destruction */
+	aeap_transport_disconnect(transport);
+
+	transport->vtable->destroy(transport);
+
+	ast_mutex_destroy(&transport->read_lock);
+	ast_mutex_destroy(&transport->write_lock);
+
+	ast_free(transport);
+}
+
+intmax_t aeap_transport_read(struct aeap_transport *transport, void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE *rtype)
+{
+	SCOPED_MUTEX(lock, &transport->read_lock);
+
+	if (!aeap_transport_is_connected(transport)) {
+		return 0;
+	}
+
+	return transport->vtable->read(transport, buf, size, rtype);
+}
+
+intmax_t aeap_transport_write(struct aeap_transport *transport, const void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE wtype)
+{
+	SCOPED_MUTEX(lock, &transport->write_lock);
+
+	if (!aeap_transport_is_connected(transport)) {
+		return 0;
+	}
+
+	return transport->vtable->write(transport, buf, size, wtype);
+}
diff --git a/res/res_aeap/transport.h b/res/res_aeap/transport.h
new file mode 100644
index 0000000000000000000000000000000000000000..622247e5ad1a2467772278890e85693aafdf5188
--- /dev/null
+++ b/res/res_aeap/transport.h
@@ -0,0 +1,209 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef RES_AEAP_TRANSPORT_H
+#define RES_AEAP_TRANSPORT_H
+
+#include <stdint.h>
+
+#include "asterisk/res_aeap.h"
+
+struct aeap_transport;
+
+/*!
+ * \brief Asterisk external application transport virtual table
+ *
+ * Callbacks to be implemented by "derived" transports
+ */
+struct aeap_transport_vtable {
+	/*!
+	 * \brief Connect a transport
+	 *
+	 * \param self The transport object
+	 * \param url The URL to connect to
+	 * \param protocol The connection protocol to use if applicable
+	 * \param timeout How long (in milliseconds) to attempt to connect (-1 equals infinite)
+	 *
+	 * \returns 0 on success, or -1 on error
+	 */
+	int (*connect)(struct aeap_transport *self, const char *url, const char *protocol, int timeout);
+
+	/*!
+	 * \brief Disconnect a transport
+	 *
+	 * \param self The transport object
+	 *
+	 * \returns 0 on success, or -1 on error
+	 */
+	int (*disconnect)(struct aeap_transport *self);
+
+	/*!
+	 * \brief Destroy a transport
+	 *
+	 * \param self The transport object
+	 */
+	void (*destroy)(struct aeap_transport *self);
+
+	/*!
+	 * \brief Read data from a transport
+	 *
+	 * \param self The transport object
+	 * \param buf The buffer data is read read into
+	 * \param size The size of the given data buffer
+	 * \param rtype [out] The type of data read
+	 *
+	 * \returns Total number of bytes read, or less than zero on error
+	 */
+	intmax_t (*read)(struct aeap_transport *self, void *buf, intmax_t size,
+		enum AST_AEAP_DATA_TYPE *rtype);
+
+	/*!
+	 * \brief Write data to a transport
+	 *
+	 * \param self The transport object
+	 * \param buf The data to write
+	 * \param size The size of data to write
+	 * \param wtype The type of data to write
+	 *
+	 * \returns Total number of bytes written, or less than zero on error
+	 */
+	intmax_t (*write)(struct aeap_transport *self, const void *buf, intmax_t size,
+		enum AST_AEAP_DATA_TYPE wtype);
+};
+
+/*!
+ * \brief Asterisk external application transport structure to be
+ *        "derived" by specific transport implementation types
+ *
+ * Transports are assumed to support simultaneous reading and writing,
+ * thus separate read and write locks. A transport type not supporting
+ * such can simply apply the opposing lock during a read or write, i.e.
+ * lock the write lock during a read and vice versa.
+ */
+struct aeap_transport {
+	/*! Transport virtual table */
+	struct aeap_transport_vtable *vtable;
+	/*! Whether or not the transport is connected */
+	unsigned int connected;
+	/*! Lock used when reading */
+	ast_mutex_t read_lock;
+	/*! Lock used when writing */
+	ast_mutex_t write_lock;
+};
+
+/*!
+ * \brief Create an Asterisk external application transport
+ *
+ * \param type The type of transport to create
+ *
+ * \returns An Asterisk external application transport, or NULL on error
+ */
+struct aeap_transport *aeap_transport_create(const char *type);
+
+/*!
+ * \brief Connect a transport
+ *
+ * \param transport The transport to connect
+ * \param url The URL to connect to
+ * \param protocol The connection protocol to use if applicable
+ * \param timeout How long (in milliseconds) to attempt to connect (-1 equals infinite)
+ *
+ * \returns 0 on success, or -1 on error
+ */
+int aeap_transport_connect(struct aeap_transport *transport, const char *url,
+	const char *protocol, int timeout);
+
+/*!
+ * \brief Create an Asterisk external application transport, and connect it
+ *
+ * \param type The type of transport to create
+ * \param url The URL to connect to
+ * \param protocol The connection protocol to use if applicable
+ * \param timeout How long (in milliseconds) to attempt to connect (-1 equals infinite)
+ *
+ * \returns An Asterisk external application transport, or NULL on error
+ */
+struct aeap_transport *aeap_transport_create_and_connect(const char* type,
+	const char *url, const char *protocol, int timeout);
+
+/*!
+ * \brief Disconnect a transport
+ *
+ * \note Locks both the transport's read and write locks before calling transport
+ *       instance's disconnect, and unlocks both before returning.
+ *
+ * \param transport The transport to disconnect
+ *
+ * \returns 0 on success, or -1 on error
+ */
+int aeap_transport_disconnect(struct aeap_transport *transport);
+
+/*!
+ * \brief Whether or not the transport is in a connected state
+ *
+ * \param transport The transport object
+ *
+ * \returns True if connected, false otherwise
+ */
+int aeap_transport_is_connected(struct aeap_transport *transport);
+
+/*!
+ * \brief Destroy a transport
+ *
+ * \param transport The transport to destroy
+ *
+ * \returns 0 on success, or -1 on error
+ */
+void aeap_transport_destroy(struct aeap_transport *transport);
+
+/*!
+ * \brief Read data from the transport
+ *
+ * This is a blocking read, and will not return until the transport
+ * implementation returns.
+ *
+ * \note Locks transport's read lock before calling transport instance's
+ *       read, and unlocks it before returning.
+ *
+ * \param transport The transport to read from
+ * \param buf The buffer data is read into
+ * \param size The size of data given data buffer
+ * \param rtype [out] The type of data read
+ *
+ * \returns Total number of bytes read, or less than zero on error
+ */
+intmax_t aeap_transport_read(struct aeap_transport *transport, void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE *rtype);
+
+/*!
+ * \brief Write data to the transport
+ *
+ * \note Locks transport's write lock before calling transport instance's
+ *       write, and unlocks it before returning.
+ *
+ * \param transport The transport to write to
+ * \param buf The data to write
+ * \param size The size of data to write
+ * \param wtype The type of data to write
+ *
+ * \returns Total number of bytes written, or less than zero on error
+ */
+intmax_t aeap_transport_write(struct aeap_transport *transport, const void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE wtype);
+
+#endif /* RES_AEAP_TRANSPORT_H */
diff --git a/res/res_aeap/transport_websocket.c b/res/res_aeap/transport_websocket.c
new file mode 100644
index 0000000000000000000000000000000000000000..5f1a4066073ba87f674166cc9780f1764a7a741d
--- /dev/null
+++ b/res/res_aeap/transport_websocket.c
@@ -0,0 +1,249 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/http_websocket.h"
+#include "asterisk/utils.h"
+
+#include "logger.h"
+#include "transport.h"
+#include "transport_websocket.h"
+
+#define log_error(obj, fmt, ...) aeap_error(obj, "websocket", fmt, ##__VA_ARGS__)
+
+struct aeap_transport_websocket {
+	/*! Derive from base transport (must be first attribute) */
+	struct aeap_transport base;
+	/*! The underlying websocket */
+	struct ast_websocket *ws;
+};
+
+static int websocket_connect(struct aeap_transport *self, const char *url,
+	const char *protocol, int timeout)
+{
+	struct aeap_transport_websocket *transport = (struct aeap_transport_websocket *)self;
+	enum ast_websocket_result ws_result;
+	struct ast_websocket_client_options ws_options = {
+		.uri = url,
+		.protocols = protocol,
+		.timeout = timeout,
+		.tls_cfg = NULL,
+	};
+
+	transport->ws = ast_websocket_client_create_with_options(&ws_options, &ws_result);
+	if (ws_result != WS_OK) {
+		log_error(self, "connect failure (%d)", (int)ws_result);
+		return -1;
+	}
+
+	return 0;
+}
+
+static int websocket_disconnect(struct aeap_transport *self)
+{
+	struct aeap_transport_websocket *transport = (struct aeap_transport_websocket *)self;
+
+	if (transport->ws) {
+		ast_websocket_unref(transport->ws);
+		transport->ws = NULL;
+	}
+
+	return 0;
+}
+
+static void websocket_destroy(struct aeap_transport *self)
+{
+	/*
+	 * Disconnect takes care of cleaning up the websocket. Note, disconnect
+	 * was called by the base/dispatch interface prior to calling this
+	 * function so nothing to do here.
+	 */
+}
+
+static intmax_t websocket_read(struct aeap_transport *self, void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE *rtype)
+{
+	struct aeap_transport_websocket *transport = (struct aeap_transport_websocket *)self;
+
+	char *payload;
+	uint64_t bytes_read = 0;
+	uint64_t total_bytes_read = 0;
+	enum ast_websocket_opcode opcode;
+	int fragmented = 0;
+
+	*rtype = AST_AEAP_DATA_TYPE_NONE;
+
+	if (ast_websocket_fd(transport->ws) < 0) {
+		log_error(self, "unavailable for reading");
+		/* Ensure this transport is in a disconnected state */
+		aeap_transport_disconnect(self);
+		return -1;
+	}
+
+	/*
+	 * This function is called with the read_lock locked. However, the lock needs to be
+	 * unlocked while waiting for input otherwise a deadlock can occur during disconnect
+	 * (disconnect attempts to grab the lock but can't because read holds it here). So
+	 * unlock it prior to waiting.
+	 */
+	ast_mutex_unlock(&transport->base.read_lock);
+	if (ast_websocket_wait_for_input(transport->ws, -1) <= 0) {
+		ast_mutex_lock(&transport->base.read_lock);
+		log_error(self, "poll failure: %s", strerror(errno));
+		/* Ensure this transport is in a disconnected state */
+		aeap_transport_disconnect(self);
+		return -1;
+	}
+	ast_mutex_lock(&transport->base.read_lock);
+
+	if (!transport->ws) {
+		/*
+		 * It's possible the transport was told to disconnect while waiting for input.
+		 * If so then the websocket will be NULL, so we don't want to continue.
+		 */
+		return 0;
+	}
+
+	do {
+		if (ast_websocket_read(transport->ws, &payload, &bytes_read, &opcode,
+				&fragmented) != 0) {
+			log_error(self, "read failure (%d): %s", opcode, strerror(errno));
+			return -1;
+		}
+
+		if (!bytes_read) {
+			continue;
+		}
+
+		if (total_bytes_read + bytes_read > size) {
+			log_error(self, "attempted to read too many bytes into (%jd) sized buffer", size);
+			return -1;
+		}
+
+		memcpy(buf + total_bytes_read, payload, bytes_read);
+		total_bytes_read += bytes_read;
+
+	} while (opcode == AST_WEBSOCKET_OPCODE_CONTINUATION);
+
+	switch (opcode) {
+	case AST_WEBSOCKET_OPCODE_CLOSE:
+		log_error(self, "closed");
+		return -1;
+	case AST_WEBSOCKET_OPCODE_BINARY:
+		*rtype = AST_AEAP_DATA_TYPE_BINARY;
+		break;
+	case AST_WEBSOCKET_OPCODE_TEXT:
+		*rtype = AST_AEAP_DATA_TYPE_STRING;
+
+		/* Append terminator, but check for overflow first */
+		if (total_bytes_read == size) {
+			log_error(self, "unable to write string terminator");
+			return -1;
+		}
+
+		*((char *)(buf + total_bytes_read)) = '\0';
+		break;
+	default:
+		/* Ignore all other message types */
+		return 0;
+	}
+
+	return total_bytes_read;
+}
+
+static intmax_t websocket_write(struct aeap_transport *self, const void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE wtype)
+{
+	struct aeap_transport_websocket *transport = (struct aeap_transport_websocket *)self;
+	intmax_t res = 0;
+
+	switch (wtype) {
+	case AST_AEAP_DATA_TYPE_BINARY:
+		res = ast_websocket_write(transport->ws, AST_WEBSOCKET_OPCODE_BINARY,
+			(char *)buf, size);
+		break;
+	case AST_AEAP_DATA_TYPE_STRING:
+		res = ast_websocket_write(transport->ws, AST_WEBSOCKET_OPCODE_TEXT,
+			(char *)buf, size);
+		break;
+	default:
+		break;
+	}
+
+	if (res < 0) {
+		log_error(self, "problem writing to websocket (closed)");
+
+		/*
+		 * If the underlying socket is closed then ensure the
+		 * transport is in a disconnected state as well.
+		 */
+		aeap_transport_disconnect(self);
+
+		return res;
+	}
+
+	return size;
+}
+
+static struct aeap_transport_vtable *transport_websocket_vtable(void)
+{
+	static struct aeap_transport_vtable websocket_vtable = {
+		.connect = websocket_connect,
+		.disconnect = websocket_disconnect,
+		.destroy = websocket_destroy,
+		.read = websocket_read,
+		.write = websocket_write,
+	};
+
+	return &websocket_vtable;
+}
+
+/*!
+ * \brief Initialize a transport websocket object, and set its virtual table
+ *
+ * \param transport The transport to initialize
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int transport_websocket_init(struct aeap_transport_websocket *transport)
+{
+	transport->ws = NULL;
+
+	((struct aeap_transport *)transport)->vtable = transport_websocket_vtable();
+
+	return 0;
+}
+
+struct aeap_transport_websocket *aeap_transport_websocket_create(void)
+{
+	struct aeap_transport_websocket *transport;
+
+	transport = ast_calloc(1, sizeof(*transport));
+	if (!transport) {
+		ast_log(LOG_ERROR, "AEAP websocket: unable to create transport websocket");
+		return NULL;
+	}
+
+	if (transport_websocket_init(transport)) {
+		ast_free(transport);
+		return NULL;
+	}
+
+	return transport;
+}
diff --git a/res/res_aeap/transport_websocket.h b/res/res_aeap/transport_websocket.h
new file mode 100644
index 0000000000000000000000000000000000000000..d72657e22e364aa167ef8af00f14a9bd53ad337e
--- /dev/null
+++ b/res/res_aeap/transport_websocket.h
@@ -0,0 +1,34 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef RES_AEAP_TRANSPORT_WEBSOCKET_H
+#define RES_AEAP_TRANSPORT_WEBSOCKET_H
+
+/*!
+ * \brief Asterisk external application protocol websocket transport
+ */
+struct aeap_transport_websocket;
+
+/*!
+ * \brief Creates (heap allocated), and initializes a transport websocket
+ *
+ * \returns A transport websocket object, or NULL on error
+ */
+struct aeap_transport_websocket *aeap_transport_websocket_create(void);
+
+#endif /* RES_AEAP_TRANSPORT_WEBSOCKET_H */
diff --git a/res/res_speech.c b/res/res_speech.c
index d425fde181abbf802a1dbfa4dd0a9fa8a48a9443..2438e7953cf44f4c75b01b2b1dea924c425a2176 100644
--- a/res/res_speech.c
+++ b/res/res_speech.c
@@ -42,7 +42,7 @@ static AST_RWLIST_HEAD_STATIC(engines, ast_speech_engine);
 static struct ast_speech_engine *default_engine = NULL;
 
 /*! \brief Find a speech recognition engine of specified name, if NULL then use the default one */
-static struct ast_speech_engine *find_engine(const char *engine_name)
+struct ast_speech_engine *ast_speech_find_engine(const char *engine_name)
 {
 	struct ast_speech_engine *engine = NULL;
 
@@ -185,7 +185,7 @@ struct ast_speech *ast_speech_new(const char *engine_name, const struct ast_form
 	RAII_VAR(struct ast_format *, best, NULL, ao2_cleanup);
 
 	/* Try to find the speech recognition engine that was requested */
-	if (!(engine = find_engine(engine_name)))
+	if (!(engine = ast_speech_find_engine(engine_name)))
 		return NULL;
 
 	joint = ast_format_cap_alloc(AST_FORMAT_CAP_FLAG_DEFAULT);
@@ -313,7 +313,7 @@ int ast_speech_register(struct ast_speech_engine *engine)
 	}
 
 	/* If an engine is already loaded with this name, error out */
-	if (find_engine(engine->name)) {
+	if (ast_speech_find_engine(engine->name)) {
 		ast_log(LOG_WARNING, "Speech recognition engine '%s' already exists.\n", engine->name);
 		return -1;
 	}
@@ -366,6 +366,36 @@ struct ast_speech_engine *ast_speech_unregister2(const char *engine_name)
 	return engine;
 }
 
+void ast_speech_unregister_engines(
+	int (*should_unregister)(const struct ast_speech_engine *engine, void *data), void *data,
+	void (*on_unregistered)(void *obj))
+{
+	struct ast_speech_engine *engine = NULL;
+
+	if (!should_unregister) {
+		return;
+	}
+
+	AST_RWLIST_WRLOCK(&engines);
+	AST_RWLIST_TRAVERSE_SAFE_BEGIN(&engines, engine, list) {
+		if (should_unregister(engine, data)) {
+			/* We have our engine... removed it */
+			AST_RWLIST_REMOVE_CURRENT(list);
+			/* If this was the default engine, we need to pick a new one */
+			if (engine == default_engine) {
+				default_engine = AST_RWLIST_FIRST(&engines);
+			}
+			ast_verb(2, "Unregistered speech recognition engine '%s'\n", engine->name);
+			/* All went well */
+			if (on_unregistered) {
+				on_unregistered(engine);
+			}
+		}
+	}
+	AST_RWLIST_TRAVERSE_SAFE_END;
+	AST_RWLIST_UNLOCK(&engines);
+}
+
 static int unload_module(void)
 {
 	/* We can not be unloaded */
diff --git a/res/res_speech_aeap.c b/res/res_speech_aeap.c
new file mode 100644
index 0000000000000000000000000000000000000000..d81d6e5dcba34c9fab26a5b803e3cb7f3938e905
--- /dev/null
+++ b/res/res_speech_aeap.c
@@ -0,0 +1,731 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*! \file
+ *
+ * \brief Asterisk External Application Speech Engine
+ *
+ */
+
+/*** MODULEINFO
+	<support_level>core</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include "asterisk/astobj2.h"
+#include "asterisk/config.h"
+#include "asterisk/format.h"
+#include "asterisk/format_cap.h"
+#include "asterisk/json.h"
+#include "asterisk/module.h"
+#include "asterisk/speech.h"
+#include "asterisk/sorcery.h"
+
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#define SPEECH_AEAP_VERSION "0.1.0"
+#define SPEECH_PROTOCOL "speech_to_text"
+
+#define CONNECTION_TIMEOUT 2000
+
+#define log_error(obj, fmt, ...) \
+	ast_log(LOG_ERROR, "AEAP speech (%p): " fmt "\n", obj, ##__VA_ARGS__)
+
+static struct ast_json *custom_fields_to_params(const struct ast_variable *variables)
+{
+	const struct ast_variable *i;
+	struct ast_json *obj;
+
+	if (!variables) {
+		return NULL;
+	}
+
+	obj = ast_json_object_create();
+	if (!obj) {
+		return NULL;
+	}
+
+	for (i = variables; i; i = i->next) {
+		if (i->name[0] == '@' && i->name[1]) {
+			ast_json_object_set(obj, i->name + 1, ast_json_string_create(i->value));
+		}
+	}
+
+	return obj;
+}
+
+/*!
+ * \internal
+ * \brief Create, and send a request to the external application
+ *
+ * Create, then sends a request to an Asterisk external application, and then blocks
+ * until a response is received or a time out occurs. Since this method waits until
+ * receiving a response the returned result is guaranteed to be pass/fail based upon
+ * a response handler's result.
+ *
+ * \param name The name of the request to send
+ * \param json The core json request data
+ * \param data Optional user data to associate with request/response
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int speech_aeap_send_request(struct ast_aeap *aeap, const char *name,
+	struct ast_json *json, void *obj)
+{
+	/*
+	 * Wait for a response. Also since we're blocking,
+	 * data is expected to be on the stack so no cleanup required.
+	 */
+	struct ast_aeap_tsx_params tsx_params = {
+		.timeout = 1000,
+		.wait = 1,
+		.obj = obj,
+	};
+
+	/* "steals" the json ref */
+	tsx_params.msg = ast_aeap_message_create_request(
+		ast_aeap_message_type_json, name, NULL, json);
+	if (!tsx_params.msg) {
+		return -1;
+	}
+
+	/* Send "steals" the json msg ref */
+	return ast_aeap_send_msg_tsx(aeap, &tsx_params);
+}
+
+/*!
+ * \internal
+ * \brief Create, and send a "get" request to an external application
+ *
+ * Basic structure of the JSON message to send:
+ *
+ *   { param: [<param>, ...] }
+ *
+ * \param speech The speech engine
+ * \param param The name of the parameter to retrieve
+ * \param data User data passed to the response handler
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int speech_aeap_get(struct ast_speech *speech, const char *param, void *data)
+{
+	if (!param) {
+		return -1;
+	}
+
+	/* send_request handles json ref */
+	return speech_aeap_send_request(speech->data,
+		"get", ast_json_pack("{s:[s]}", "params", param), data);
+}
+
+struct speech_param {
+	const char *name;
+	const char *value;
+};
+
+/*!
+ * \internal
+ * \brief Create, and send a "set" request to an external application
+ *
+ * Basic structure of the JSON message to send:
+ *
+ *   { params: { <name> : <value> }  }
+ *
+ * \param speech The speech engine
+ * \param name The name of the parameter to set
+ * \param value The value of the parameter to set
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int speech_aeap_set(struct ast_speech *speech, const char *name, const char *value)
+{
+	if (!name) {
+		return -1;
+	}
+
+	/* send_request handles json ref */
+	return speech_aeap_send_request(speech->data,
+		"set", ast_json_pack("{s:{s:s}}", "params", name, value), NULL);
+}
+
+static int handle_response_set(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	return 0;
+}
+
+struct speech_setting {
+	const char *param;
+	size_t len;
+	char *buf;
+};
+
+static int handle_setting(struct ast_aeap *aeap, struct ast_json_iter *iter,
+	struct speech_setting *setting)
+{
+	const char *value;
+
+	if (strcmp(ast_json_object_iter_key(iter), setting->param)) {
+		log_error(aeap, "Unable to 'get' speech setting for '%s'", setting->param);
+		return -1;
+	}
+
+	value = ast_json_string_get(ast_json_object_iter_value(iter));
+	if (!value) {
+		log_error(aeap, "No value for speech setting '%s'", setting->param);
+		return -1;
+	}
+
+	ast_copy_string(setting->buf, value, setting->len);
+	return 0;
+}
+
+static int handle_results(struct ast_aeap *aeap, struct ast_json_iter *iter,
+	struct ast_speech_result **speech_results)
+{
+	struct ast_speech_result *result = NULL;
+	struct ast_json *json_results;
+	struct ast_json *json_result;
+	size_t i;
+
+	json_results = ast_json_object_iter_value(iter);
+	if (!json_results || !speech_results) {
+		log_error(aeap, "Unable to 'get' speech results");
+		return -1;
+	}
+
+	for (i = 0; i < ast_json_array_size(json_results); ++i) {
+		if (!(result = ast_calloc(1, sizeof(*result)))) {
+			continue;
+		}
+
+		json_result = ast_json_array_get(json_results, i);
+
+		result->text = ast_strdup(ast_json_object_string_get(json_result, "text"));
+		result->score = ast_json_object_integer_get(json_result, "score");
+		result->grammar = ast_strdup(ast_json_object_string_get(json_result, "grammar"));
+		result->nbest_num = ast_json_object_integer_get(json_result, "best");
+		if (*speech_results) {
+			AST_LIST_NEXT(result, list) = *speech_results;
+			*speech_results = result;
+		} else {
+			*speech_results = result;
+		}
+	}
+
+	return 0;
+}
+
+/*!
+ * \internal
+ * \brief Handle a "get" response from an external application
+ *
+ * Basic structure of the expected JSON message to received:
+ *
+ *   {
+ *     response: "get"
+ *     "params" : { <name>: <value> | [ <results> ] }
+ *   }
+ *
+ * \param speech The speech engine
+ * \param param The name of the parameter to retrieve
+ * \param data User data passed to the response handler
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int handle_response_get(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	struct ast_json_iter *iter;
+
+	iter = ast_json_object_iter(ast_json_object_get(ast_aeap_message_data(message), "params"));
+	if (!iter) {
+		log_error(aeap, "no 'get' parameters returned");
+		return -1;
+	}
+
+	if (!strcmp(ast_json_object_iter_key(iter), "results")) {
+		return handle_results(aeap, iter, data);
+	}
+
+	return handle_setting(aeap, iter, data);
+}
+
+static int handle_response_setup(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	struct ast_format *format = data;
+	struct ast_json *json = ast_aeap_message_data(message);
+	const char *codec_name;
+
+	if (!json) {
+		log_error(aeap, "no 'setup' object returned");
+		return -1;
+	}
+
+	json = ast_json_object_get(json, "codecs");
+	if (!json || ast_json_array_size(json) == 0) {
+		log_error(aeap, "no 'setup' codecs available");
+		return -1;
+	}
+
+	codec_name = ast_json_object_string_get(ast_json_array_get(json, 0), "name");
+	if (!codec_name || strcmp(codec_name, ast_format_get_codec_name(format))) {
+		log_error(aeap, "setup  codec '%s' unsupported", ast_format_get_codec_name(format));
+		return -1;
+	}
+
+	return 0;
+}
+
+static const struct ast_aeap_message_handler response_handlers[] = {
+	{ "setup", handle_response_setup },
+	{ "get", handle_response_get },
+	{ "set", handle_response_set },
+};
+
+static int handle_request_set(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	struct ast_json_iter *iter;
+	const char *error_msg = NULL;
+
+	iter = ast_json_object_iter(ast_json_object_get(ast_aeap_message_data(message), "params"));
+	if (!iter) {
+		error_msg = "no parameter(s) requested";
+	} else if (!strcmp(ast_json_object_iter_key(iter), "results")) {
+		struct ast_speech *speech = ast_aeap_user_data_object_by_id(aeap, "speech");
+
+		if (!speech) {
+			error_msg = "no associated speech object";
+		} else if (handle_results(aeap, iter, &speech->results)) {
+			error_msg = "unable to handle results";
+		} else {
+			ast_speech_change_state(speech, AST_SPEECH_STATE_DONE);
+		}
+	} else {
+		error_msg = "can only set 'results'";
+	}
+
+	if (error_msg) {
+		log_error(aeap, "set - %s", error_msg);
+		message = ast_aeap_message_create_error(ast_aeap_message_type_json,
+			ast_aeap_message_name(message), ast_aeap_message_id(message), error_msg);
+	} else {
+		message = ast_aeap_message_create_response(ast_aeap_message_type_json,
+			ast_aeap_message_name(message), ast_aeap_message_id(message), NULL);
+	}
+
+	ast_aeap_send_msg(aeap, message);
+
+	return 0;
+}
+
+static const struct ast_aeap_message_handler request_handlers[] = {
+	{ "set", handle_request_set },
+};
+
+static struct ast_aeap_params speech_aeap_params = {
+	.response_handlers = response_handlers,
+	.response_handlers_size = ARRAY_LEN(response_handlers),
+	.request_handlers = request_handlers,
+	.request_handlers_size = ARRAY_LEN(request_handlers),
+};
+
+/*!
+ * \internal
+ * \brief Create, and connect to an external application and send initial setup
+ *
+ * Basic structure of the JSON message to send:
+ *
+ *   {
+ *     "request": "setup"
+ *     "codecs": [
+ *         {
+ *             "name": <name>,
+ *             "attributes": { <name>: <value>, ..., }
+ *         },
+ *         ...,
+ *     ],
+ *     "params": { <name>: <value>, ..., }
+ *   }
+ *
+ * \param speech The speech engine
+ * \param format The format codec to use
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int speech_aeap_engine_create(struct ast_speech *speech, struct ast_format *format)
+{
+	struct ast_aeap *aeap;
+	struct ast_variable *vars;
+	struct ast_json *json;
+
+	aeap = ast_aeap_create_and_connect_by_id(
+		speech->engine->name, &speech_aeap_params, CONNECTION_TIMEOUT);
+	if (!aeap) {
+		return -1;
+	}
+
+	speech->data = aeap;
+
+	/* Don't allow unloading of this module while an external application is in use */
+	ast_module_ref(ast_module_info->self);
+
+	vars = ast_aeap_custom_fields_get(speech->engine->name);
+
+	/* While the protocol allows sending of codec attributes, for now don't */
+	json = ast_json_pack("{s:s,s:[{s:s}],s:o*}", "version", SPEECH_AEAP_VERSION, "codecs",
+		"name", ast_format_get_codec_name(format), "params", custom_fields_to_params(vars));
+
+	ast_variables_destroy(vars);
+
+	if (ast_aeap_user_data_register(aeap, "speech", speech, NULL)) {
+		ast_module_unref(ast_module_info->self);
+		return -1;
+	}
+
+	/* send_request handles json ref */
+	if (speech_aeap_send_request(speech->data, "setup", json, format)) {
+		ast_module_unref(ast_module_info->self);
+		return -1;
+	}
+
+	/*
+	 * Add a reference to the engine here, so if it happens to get unregistered
+	 * while executing it won't disappear.
+	 */
+	ao2_ref(speech->engine, 1);
+
+	return 0;
+}
+
+static int speech_aeap_engine_destroy(struct ast_speech *speech)
+{
+	ao2_ref(speech->engine, -1);
+	ao2_cleanup(speech->data);
+
+	ast_module_unref(ast_module_info->self);
+
+	return 0;
+}
+
+static int speech_aeap_engine_write(struct ast_speech *speech, void *data, int len)
+{
+	return ast_aeap_send_binary(speech->data, data, len);
+}
+
+static int speech_aeap_engine_dtmf(struct ast_speech *speech, const char *dtmf)
+{
+	return speech_aeap_set(speech, "dtmf", dtmf);
+}
+
+static int speech_aeap_engine_start(struct ast_speech *speech)
+{
+	ast_speech_change_state(speech, AST_SPEECH_STATE_READY);
+
+	return 0;
+}
+
+static int speech_aeap_engine_change(struct ast_speech *speech, const char *name, const char *value)
+{
+	return speech_aeap_set(speech, name, value);
+}
+
+static int speech_aeap_engine_get_setting(struct ast_speech *speech, const char *name,
+	char *buf, size_t len)
+{
+	struct speech_setting setting = {
+		.param = name,
+		.len = len,
+		.buf = buf,
+	};
+
+	return speech_aeap_get(speech, name, &setting);
+}
+
+static int speech_aeap_engine_change_results_type(struct ast_speech *speech,
+	enum ast_speech_results_type results_type)
+{
+	return speech_aeap_set(speech, "results_type",
+		ast_speech_results_type_to_string(results_type));
+}
+
+static struct ast_speech_result *speech_aeap_engine_get(struct ast_speech *speech)
+{
+	struct ast_speech_result *results = NULL;
+
+	if (speech->results) {
+		return speech->results;
+	}
+
+	if (speech_aeap_get(speech, "results", &results)) {
+		return NULL;
+	}
+
+	return results;
+}
+
+static void speech_engine_destroy(void *obj)
+{
+	struct ast_speech_engine *engine = obj;
+
+	ao2_cleanup(engine->formats);
+	ast_free(engine->name);
+}
+
+static struct ast_speech_engine *speech_engine_alloc(const char *name)
+{
+	struct ast_speech_engine *engine;
+
+	engine = ao2_t_alloc_options(sizeof(*engine), speech_engine_destroy,
+		AO2_ALLOC_OPT_LOCK_NOLOCK, name);
+	if (!engine) {
+		ast_log(LOG_ERROR, "AEAP speech: unable create engine '%s'\n", name);
+		return NULL;
+	}
+
+	engine->name = ast_strdup(name);
+	if (!engine->name) {
+		ao2_ref(engine, -1);
+		return NULL;
+	}
+
+	engine->create = speech_aeap_engine_create;
+	engine->destroy = speech_aeap_engine_destroy;
+	engine->write = speech_aeap_engine_write;
+	engine->dtmf = speech_aeap_engine_dtmf;
+	engine->start = speech_aeap_engine_start;
+	engine->change = speech_aeap_engine_change;
+	engine->get_setting = speech_aeap_engine_get_setting;
+	engine->change_results_type = speech_aeap_engine_change_results_type;
+	engine->get = speech_aeap_engine_get;
+
+	engine->formats = ast_format_cap_alloc(AST_FORMAT_CAP_FLAG_DEFAULT);
+
+	return engine;
+}
+
+static void speech_engine_alloc_and_register(const char *name, const struct ast_format_cap *formats)
+{
+	struct ast_speech_engine *engine;
+
+	engine = speech_engine_alloc(name);
+	if (!engine) {
+		return;
+	}
+
+	if (formats && ast_format_cap_append_from_cap(engine->formats,
+			formats, AST_MEDIA_TYPE_AUDIO)) {
+		ast_log(LOG_WARNING, "AEAP speech: Unable to add engine '%s' formats\n", name);
+		ao2_ref(engine, -1);
+		return;
+	}
+
+	if (ast_speech_register(engine)) {
+		ast_log(LOG_WARNING, "AEAP speech: Unable to register engine '%s'\n", name);
+		ao2_ref(engine, -1);
+	}
+}
+
+#ifdef TEST_FRAMEWORK
+
+static void speech_engine_alloc_and_register2(const char *name, const char *codec_names)
+{
+	struct ast_speech_engine *engine;
+
+	engine = speech_engine_alloc(name);
+	if (!engine) {
+		return;
+	}
+
+	if (codec_names && ast_format_cap_update_by_allow_disallow(engine->formats, codec_names, 1)) {
+		ast_log(LOG_WARNING, "AEAP speech: Unable to add engine '%s' codecs\n", name);
+		ao2_ref(engine, -1);
+		return;
+	}
+
+	if (ast_speech_register(engine)) {
+		ast_log(LOG_WARNING, "AEAP speech: Unable to register engine '%s'\n", name);
+		ao2_ref(engine, -1);
+	}
+}
+
+#endif
+
+static int unload_engine(void *obj, void *arg, int flags)
+{
+	if (ast_aeap_client_config_has_protocol(obj, SPEECH_PROTOCOL)) {
+		ao2_cleanup(ast_speech_unregister2(ast_sorcery_object_get_id(obj)));
+	}
+
+	return 0;
+}
+
+static int load_engine(void *obj, void *arg, int flags)
+{
+	const char *id;
+	const struct ast_format_cap *formats;
+	const struct ast_speech_engine *engine;
+
+	if (!ast_aeap_client_config_has_protocol(obj, SPEECH_PROTOCOL)) {
+		return 0;
+	}
+
+	id = ast_sorcery_object_get_id(obj);
+	formats = ast_aeap_client_config_codecs(obj);
+	if (!formats) {
+		formats = ast_format_cap_alloc(AST_FORMAT_CAP_FLAG_DEFAULT);
+		if (!formats) {
+			ast_log(LOG_ERROR, "AEAP speech: unable to allocate default engine format for '%s'\n", id);
+			return 0;
+		}
+	}
+
+	engine = ast_speech_find_engine(id);
+	if (!engine) {
+		speech_engine_alloc_and_register(id, formats);
+		return 0;
+	}
+
+	if (ast_format_cap_identical(formats, engine->formats)) {
+		/* Same name, same formats then nothing changed */
+		return 0;
+	}
+
+	ao2_ref(ast_speech_unregister2(engine->name), -1);
+	speech_engine_alloc_and_register(id, formats);
+
+	return 0;
+}
+
+static int matches_engine(void *obj, void *arg, int flags)
+{
+	const struct ast_speech_engine *engine = arg;
+
+	return strcmp(ast_sorcery_object_get_id(obj), engine->name) ? 0 : CMP_MATCH;
+}
+
+static int should_unregister(const struct ast_speech_engine *engine, void *data)
+{
+	void *obj;
+
+	if (engine->create != speech_aeap_engine_create) {
+		/* Only want to potentially unregister AEAP speech engines */
+		return 0;
+	}
+
+#ifdef TEST_FRAMEWORK
+	if (!strcmp("_aeap_test_speech_", engine->name)) {
+		/* Don't remove the test engine */
+		return 0;
+	}
+#endif
+
+	obj = ao2_callback(data, 0, matches_engine, (void*)engine);
+
+	if (obj) {
+		ao2_ref(obj, -1);
+		return 0;
+	}
+
+	/* If no match in given container then unregister engine */
+	return 1;
+}
+
+static void speech_observer_loaded(const char *object_type)
+{
+	struct ao2_container *container;
+
+	if (strcmp(object_type, AEAP_CONFIG_CLIENT)) {
+		return;
+	}
+
+	container = ast_aeap_client_configs_get(SPEECH_PROTOCOL);
+	if (!container) {
+		return;
+	}
+
+	/*
+	 * An AEAP module reload has occurred. First
+	 * remove all engines that no longer exist.
+	 */
+	ast_speech_unregister_engines(should_unregister, container, __ao2_cleanup);
+
+	/* Now add or update engines */
+	ao2_callback(container, 0, load_engine, NULL);
+	ao2_ref(container, -1);
+}
+
+/*! \brief Observer for AEAP reloads */
+static const struct ast_sorcery_observer speech_observer = {
+	.loaded = speech_observer_loaded,
+};
+
+static int unload_module(void)
+{
+	struct ao2_container *container;
+
+#ifdef TEST_FRAMEWORK
+	ao2_cleanup(ast_speech_unregister2("_aeap_test_speech_"));
+#endif
+
+	ast_sorcery_observer_remove(ast_aeap_sorcery(), AEAP_CONFIG_CLIENT, &speech_observer);
+
+	container = ast_aeap_client_configs_get(SPEECH_PROTOCOL);
+	if (container) {
+		ao2_callback(container, 0, unload_engine, NULL);
+		ao2_ref(container, -1);
+	}
+
+	return 0;
+}
+
+static int load_module(void)
+{
+	struct ao2_container *container;
+
+	speech_aeap_params.msg_type = ast_aeap_message_type_json;
+
+	container = ast_aeap_client_configs_get(SPEECH_PROTOCOL);
+	if (container) {
+		ao2_callback(container, 0, load_engine, NULL);
+		ao2_ref(container, -1);
+	}
+
+	/*
+	 * Add an observer since a named speech server must be created,
+	 * registered, and eventually removed for all AEAP client
+	 * configuration matching the "speech_to_text" protocol.
+	*/
+	if (ast_sorcery_observer_add(ast_aeap_sorcery(), AEAP_CONFIG_CLIENT, &speech_observer)) {
+		return AST_MODULE_LOAD_DECLINE;
+	}
+
+#ifdef TEST_FRAMEWORK
+	speech_engine_alloc_and_register2("_aeap_test_speech_", "ulaw");
+#endif
+
+	return AST_MODULE_LOAD_SUCCESS;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_LOAD_ORDER, "Asterisk External Application Speech Engine",
+	.support_level = AST_MODULE_SUPPORT_CORE,
+	.load = load_module,
+	.unload = unload_module,
+	.load_pri = AST_MODPRI_CHANNEL_DEPEND,
+	.requires = "res_speech,res_aeap",
+);
diff --git a/tests/test_aeap.c b/tests/test_aeap.c
new file mode 100644
index 0000000000000000000000000000000000000000..899da7a75879740a3a0fec079f48256d7842c73c
--- /dev/null
+++ b/tests/test_aeap.c
@@ -0,0 +1,252 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*** MODULEINFO
+	<depend>TEST_FRAMEWORK</depend>
+	<depend>res_aeap</depend>
+	<support_level>core</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include "asterisk/test.h"
+#include "asterisk/module.h"
+#include "asterisk/file.h"
+#include "asterisk/http_websocket.h"
+#include "asterisk/json.h"
+
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#define CATEGORY "/res/aeap/"
+
+#define ADDR "127.0.0.1:8088"
+#define AEAP_TRANSPORT_TYPE "ws"
+#define AEAP_REMOTE_URL "ws://" ADDR "/ws"
+#define AEAP_REMOTE_PROTOCOL "echo"
+#define AEAP_MESSAGE_ID "foo"
+#define AEAP_CONNECTION_TIMEOUT 2000
+
+AST_TEST_DEFINE(create_and_connect)
+{
+	RAII_VAR(struct ast_aeap *, aeap, NULL, ao2_cleanup);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test creating and connecting to an AEAP application";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	ast_test_validate(test, (aeap = ast_aeap_create_and_connect(AEAP_TRANSPORT_TYPE,
+		NULL, AEAP_REMOTE_URL, AEAP_REMOTE_PROTOCOL, AEAP_CONNECTION_TIMEOUT)));
+
+	return AST_TEST_PASS;
+}
+
+static void handle_string(struct ast_aeap *aeap, const char *buf, intmax_t size)
+{
+	int *passed = ast_aeap_user_data_object_by_id(aeap, AEAP_MESSAGE_ID);
+
+	if (strstr(buf, AEAP_MESSAGE_ID)) {
+		++*passed;
+	}
+}
+
+static void handle_timeout(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	int *passed = ast_aeap_user_data_object_by_id(aeap, AEAP_MESSAGE_ID);
+
+	++*passed;
+}
+
+AST_TEST_DEFINE(send_msg_handle_string)
+{
+	int passed = 0;
+	RAII_VAR(struct ast_aeap *, aeap, NULL, ao2_cleanup);
+	struct ast_aeap_tsx_params tsx_params = {0};
+	struct ast_aeap_params aeap_params = {
+		.on_string = handle_string,
+	};
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test an AEAP application string handler";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	tsx_params.timeout = 2000; /* Test will end by timing out */
+	tsx_params.on_timeout = handle_timeout;
+	tsx_params.wait = 1;
+
+	ast_test_validate(test, (aeap = ast_aeap_create_and_connect(AEAP_TRANSPORT_TYPE,
+		&aeap_params, AEAP_REMOTE_URL, AEAP_REMOTE_PROTOCOL, AEAP_CONNECTION_TIMEOUT)));
+
+	ast_test_validate(test, (!ast_aeap_user_data_register(aeap, AEAP_MESSAGE_ID, &passed, NULL)));
+	ast_test_validate(test, (tsx_params.msg = ast_aeap_message_create_request(
+		ast_aeap_message_type_json, "foo", AEAP_MESSAGE_ID, NULL)));
+	ast_test_validate(test, ast_aeap_send_msg_tsx(aeap, &tsx_params)); /* Returns fail on timeout */
+	ast_aeap_user_data_unregister(aeap, AEAP_MESSAGE_ID);
+
+	return passed == 2 ? AST_TEST_PASS : AST_TEST_FAIL;
+}
+
+static int handle_msg(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	int *passed = ast_aeap_user_data_object_by_id(aeap, AEAP_MESSAGE_ID);
+
+	*passed = !strcmp(ast_aeap_message_id(message), AEAP_MESSAGE_ID) &&
+		ast_aeap_message_is_named(message, data);
+
+	if (!*passed) {
+		ast_log(LOG_ERROR, "Name '%s' did not equal '%s' for message '%s'",
+			ast_aeap_message_name(message), (char *)data, ast_aeap_message_id(message));
+	}
+
+	return 0;
+}
+
+static const struct ast_aeap_message_handler handlers[] = {
+	{ "foo", handle_msg },
+};
+
+AST_TEST_DEFINE(send_msg_handle_response)
+{
+	int passed = 0;
+	RAII_VAR(struct ast_aeap *, aeap, NULL, ao2_cleanup);
+	char *name = "foo";
+	struct ast_aeap_params aeap_params = {
+		.response_handlers = handlers,
+		.response_handlers_size = ARRAY_LEN(handlers),
+	};
+	struct ast_aeap_tsx_params tsx_params = {0};
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test an AEAP application response handler";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	aeap_params.msg_type = ast_aeap_message_type_json;
+
+	tsx_params.timeout = 2000;
+	tsx_params.wait = 1;
+	tsx_params.obj = name;
+
+	ast_test_validate(test, (aeap = ast_aeap_create_and_connect(AEAP_TRANSPORT_TYPE,
+		&aeap_params, AEAP_REMOTE_URL, AEAP_REMOTE_PROTOCOL, AEAP_CONNECTION_TIMEOUT)));
+	ast_test_validate(test, (!ast_aeap_user_data_register(aeap, AEAP_MESSAGE_ID, &passed, NULL)));
+	ast_test_validate(test, (tsx_params.msg = ast_aeap_message_create_response(
+		ast_aeap_message_type_json, name, AEAP_MESSAGE_ID, NULL)));
+	ast_test_validate(test, !ast_aeap_send_msg_tsx(aeap, &tsx_params));
+	ast_aeap_user_data_unregister(aeap, AEAP_MESSAGE_ID);
+
+	return passed ? AST_TEST_PASS : AST_TEST_FAIL;
+}
+
+AST_TEST_DEFINE(send_msg_handle_request)
+{
+	int passed = 0;
+	RAII_VAR(struct ast_aeap *, aeap, NULL, ao2_cleanup);
+	char *name = "foo";
+	struct ast_aeap_params aeap_params = {
+		.request_handlers = handlers,
+		.request_handlers_size = ARRAY_LEN(handlers),
+	};
+	struct ast_aeap_tsx_params tsx_params = {0};
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test an AEAP application request handler";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	aeap_params.msg_type = ast_aeap_message_type_json;
+
+	tsx_params.timeout = 2000;
+	tsx_params.wait = 1;
+	tsx_params.obj = name;
+
+	ast_test_validate(test, (aeap = ast_aeap_create_and_connect(AEAP_TRANSPORT_TYPE,
+		&aeap_params, AEAP_REMOTE_URL, AEAP_REMOTE_PROTOCOL, AEAP_CONNECTION_TIMEOUT)));
+	ast_test_validate(test, (!ast_aeap_user_data_register(aeap, AEAP_MESSAGE_ID, &passed, NULL)));
+	ast_test_validate(test, (tsx_params.msg = ast_aeap_message_create_request(
+		ast_aeap_message_type_json, name, AEAP_MESSAGE_ID, NULL)));
+	ast_test_validate(test, !ast_aeap_send_msg_tsx(aeap, &tsx_params));
+	ast_aeap_user_data_unregister(aeap, AEAP_MESSAGE_ID);
+
+	return passed ? AST_TEST_PASS : AST_TEST_FAIL;
+}
+
+static struct ast_http_server *http_server;
+
+static int load_module(void)
+{
+	if (!(http_server = ast_http_test_server_get("aeap transport http server", NULL))) {
+		return AST_MODULE_LOAD_DECLINE;
+	}
+
+	AST_TEST_REGISTER(create_and_connect);
+	AST_TEST_REGISTER(send_msg_handle_string);
+	AST_TEST_REGISTER(send_msg_handle_response);
+	AST_TEST_REGISTER(send_msg_handle_request);
+
+	return AST_MODULE_LOAD_SUCCESS;
+}
+
+static int unload_module(void)
+{
+	AST_TEST_UNREGISTER(send_msg_handle_request);
+	AST_TEST_UNREGISTER(send_msg_handle_response);
+	AST_TEST_UNREGISTER(send_msg_handle_string);
+	AST_TEST_UNREGISTER(create_and_connect);
+
+	ast_http_test_server_discard(http_server);
+
+	return 0;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_DEFAULT, "Asterisk External Application Protocol Object Tests",
+	.support_level = AST_MODULE_SUPPORT_CORE,
+	.load = load_module,
+	.unload = unload_module,
+	.requires = "res_aeap",
+);
diff --git a/tests/test_aeap_speech.c b/tests/test_aeap_speech.c
new file mode 100644
index 0000000000000000000000000000000000000000..2e658c1f3991c97ee37aedc45998438b35f97c7d
--- /dev/null
+++ b/tests/test_aeap_speech.c
@@ -0,0 +1,287 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*** MODULEINFO
+	<depend>TEST_FRAMEWORK</depend>
+	<depend>res_aeap</depend>
+	<support_level>core</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include "asterisk/test.h"
+#include "asterisk/module.h"
+#include "asterisk/file.h"
+#include "asterisk/format_cap.h"
+#include "asterisk/http.h"
+#include "asterisk/http_websocket.h"
+#include "asterisk/json.h"
+#include "asterisk/speech.h"
+
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#define ADDR "127.0.0.1:8088"
+
+static int speech_test_server_setup(struct ast_json *req, struct ast_json *resp)
+{
+	struct ast_json *params;
+
+	if (ast_json_object_set(resp, "codecs", ast_json_ref(ast_json_object_get(req, "codecs")))) {
+		return -1;
+	}
+
+	params = ast_json_object_get(req, "params"); /* Optional */
+	if (params && ast_json_object_set(resp, "params", ast_json_ref(params))) {
+		return -1;
+	}
+
+	return 0;
+}
+
+#define TEST_SPEECH_RESULTS_TEXT "foo"
+#define TEST_SPEECH_RESULTS_SCORE 7
+#define TEST_SPEECH_RESULTS_GRAMMAR "bar"
+#define TEST_SPEECH_RESULTS_BEST 1
+
+static int speech_test_server_get(struct ast_json *req, struct ast_json *resp)
+{
+	const char *param;
+	struct ast_json *json = NULL;
+
+	param = ast_json_string_get(ast_json_array_get(ast_json_object_get(req, "params"), 0));
+	if (!param) {
+		return -1;
+	}
+
+	if (!strcmp(param, "results")) {
+		json = ast_json_pack("{s:[{s:s,s:i,s:s,s:i}]}",
+			param,
+			"text", TEST_SPEECH_RESULTS_TEXT,
+			"score", TEST_SPEECH_RESULTS_SCORE,
+			"grammar", TEST_SPEECH_RESULTS_GRAMMAR,
+			"best", TEST_SPEECH_RESULTS_BEST);
+	} else {
+		/* Assume setting */
+		json = ast_json_pack("{s:s}", param, "bar");
+	}
+
+	if (!json || ast_json_object_set(resp, "params", json)) {
+		return -1;
+	}
+
+	return 0;
+}
+
+static int speech_test_server_set(struct ast_json *req, struct ast_json *resp)
+{
+	if (ast_json_object_set(resp, "params", ast_json_ref(ast_json_object_get(req, "params")))) {
+		return -1;
+	}
+
+	return 0;
+}
+
+static int speech_test_server_handle_request(struct ast_websocket *ws, const void *buf, uint64_t size)
+{
+	struct ast_json *req;
+	struct ast_json *resp;
+	const char *name;
+	char *resp_buf;
+	int res = 0;
+
+	req = ast_json_load_buf(buf, size, NULL);
+	if (!req) {
+		ast_log(LOG_ERROR, "speech test handle request: unable to load json\n");
+		return -1;
+	}
+
+	name = ast_json_object_string_get(req, "request");
+	if (!name) {
+		ast_log(LOG_ERROR, "speech test handle request: no name\n");
+		ast_json_unref(req);
+		return -1;
+	}
+
+	resp = ast_json_pack("{s:s, s:s}", "response", name,
+		"id", ast_json_object_string_get(req, "id"));
+	if (!resp) {
+		ast_log(LOG_ERROR, "speech test handle request: unable to create response '%s'\n", name);
+		ast_json_unref(req);
+		return -1;
+	}
+
+	if (!strcmp(name, "setup")) {
+		res = speech_test_server_setup(req, resp);
+	} else if (!strcmp(name, "get")) {
+		res = speech_test_server_get(req, resp);
+	} else if (!strcmp(name, "set")) {
+		res = speech_test_server_set(req, resp);
+	} else {
+		ast_log(LOG_ERROR, "speech test handle request: unsupported request '%s'\n", name);
+		return -1;
+	}
+
+	if (res) {
+		ast_log(LOG_ERROR, "speech test handle request: unable to build response '%s'\n", name);
+		ast_json_unref(resp);
+		ast_json_unref(req);
+		return -1;
+	}
+
+	resp_buf = ast_json_dump_string(resp);
+	ast_json_unref(resp);
+
+	if (!resp_buf) {
+		ast_log(LOG_ERROR, "speech test handle request: unable to dump response '%s'\n", name);
+		ast_json_unref(req);
+		return -1;
+	}
+
+	res = ast_websocket_write_string(ws, resp_buf);
+	if (res) {
+		ast_log(LOG_ERROR, "speech test handle request: unable to write response '%s'\n", name);
+	}
+
+	ast_json_unref(req);
+	ast_free(resp_buf);
+
+	return res;
+}
+
+static void speech_test_server_cb(struct ast_websocket *ws, struct ast_variable *parameters,
+	struct ast_variable *headers)
+{
+	int res;
+
+	if (ast_fd_set_flags(ast_websocket_fd(ws), O_NONBLOCK)) {
+		ast_websocket_unref(ws);
+		return;
+	}
+
+	while ((res = ast_websocket_wait_for_input(ws, -1)) > 0) {
+		char *payload;
+		uint64_t payload_len;
+		enum ast_websocket_opcode opcode;
+		int fragmented;
+
+		if (ast_websocket_read(ws, &payload, &payload_len, &opcode, &fragmented)) {
+			ast_log(LOG_ERROR, "speech test: Read failure in server loop\n");
+			break;
+		}
+
+		switch (opcode) {
+			case AST_WEBSOCKET_OPCODE_CLOSE:
+				ast_websocket_unref(ws);
+				return;
+			case AST_WEBSOCKET_OPCODE_BINARY:
+				ast_websocket_write(ws, opcode, payload, payload_len);
+				break;
+			case AST_WEBSOCKET_OPCODE_TEXT:
+				ast_debug(3, "payload=%.*s\n", (int)payload_len, payload);
+				if (speech_test_server_handle_request(ws, payload, payload_len)) {
+					ast_websocket_unref(ws);
+					return;
+				}
+				break;
+			default:
+				break;
+		}
+	}
+	ast_websocket_unref(ws);
+}
+
+AST_TEST_DEFINE(res_speech_aeap_test)
+{
+	RAII_VAR(struct ast_format_cap *, cap, NULL, ao2_cleanup);
+	RAII_VAR(struct ast_speech_result *, results, NULL, ast_speech_results_free);
+	struct ast_speech *speech = NULL;
+	enum ast_test_result_state res = AST_TEST_PASS;
+	char buf[8] = "";
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = "/res/aeap/speech/";
+		info->summary = "test the speech AEAP interface";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	ast_test_validate(test, !ast_websocket_add_protocol("_aeap_test_speech_", speech_test_server_cb));
+
+	ast_test_validate(test, (cap = ast_format_cap_alloc(AST_FORMAT_CAP_FLAG_DEFAULT)));
+	ast_test_validate(test, !ast_format_cap_update_by_allow_disallow(cap, "ulaw", 1));
+
+	ast_test_validate_cleanup(test, (speech = ast_speech_new("_aeap_test_speech_", cap)), res, cleanup);
+	ast_speech_start(speech);
+	ast_test_validate_cleanup(test, !ast_speech_dtmf(speech, "1"), res, cleanup);
+	ast_test_validate_cleanup(test, !ast_speech_change(speech, "foo", "bar"), res, cleanup);
+	ast_test_validate_cleanup(test, !ast_speech_change_results_type(
+		speech, AST_SPEECH_RESULTS_TYPE_NBEST), res, cleanup);
+
+	ast_test_validate_cleanup(test, !ast_speech_get_setting(
+		speech, "foo", buf, sizeof(buf)), res, cleanup);
+	ast_test_validate_cleanup(test, !strcmp(buf, "bar"), res, cleanup);
+
+	ast_test_validate_cleanup(test, (results = ast_speech_results_get(speech)), res, cleanup);
+	ast_test_validate_cleanup(test, !strcmp(results->text, TEST_SPEECH_RESULTS_TEXT), res, cleanup);
+	ast_test_validate_cleanup(test, results->score == TEST_SPEECH_RESULTS_SCORE, res, cleanup);
+	ast_test_validate_cleanup(test, !strcmp(results->grammar, TEST_SPEECH_RESULTS_GRAMMAR), res, cleanup);
+	ast_test_validate_cleanup(test, results->nbest_num == TEST_SPEECH_RESULTS_BEST, res, cleanup);
+
+cleanup:
+	if (speech) {
+		ast_speech_destroy(speech);
+	}
+	ast_websocket_remove_protocol("_aeap_test_speech_", speech_test_server_cb);
+
+	return res;
+}
+
+static struct ast_http_server *http_server;
+
+static int load_module(void)
+{
+	if (!(http_server = ast_http_test_server_get("aeap transport http server", NULL))) {
+		return AST_MODULE_LOAD_DECLINE;
+	}
+
+	AST_TEST_REGISTER(res_speech_aeap_test);
+
+	return AST_MODULE_LOAD_SUCCESS;
+}
+
+static int unload_module(void)
+{
+	AST_TEST_UNREGISTER(res_speech_aeap_test);
+
+	ast_http_test_server_discard(http_server);
+
+	return 0;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_DEFAULT, "Asterisk External Application Protocol Speech test(s)",
+	.support_level = AST_MODULE_SUPPORT_CORE,
+	.load = load_module,
+	.unload = unload_module,
+	.requires = "res_speech_aeap",
+);
diff --git a/tests/test_aeap_transaction.c b/tests/test_aeap_transaction.c
new file mode 100644
index 0000000000000000000000000000000000000000..f1879adb43d6297a9984a4a06d70127bb99b36ba
--- /dev/null
+++ b/tests/test_aeap_transaction.c
@@ -0,0 +1,179 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*** MODULEINFO
+	<depend>TEST_FRAMEWORK</depend>
+	<depend>res_aeap</depend>
+	<support_level>core</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include <pthread.h>
+
+#include "asterisk/lock.h"
+#include "asterisk/test.h"
+#include "asterisk/module.h"
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#include "../res/res_aeap/general.h"
+#include "../res/res_aeap/transaction.h"
+
+#define CATEGORY "/res/aeap/transaction/"
+
+#define AEAP_TRANSACTION_ID "foo"
+
+static void handle_timeout(struct ast_aeap *aeap, struct ast_aeap_message *msg, void *obj)
+{
+	int *passed = obj;
+
+	++*passed;
+}
+
+static void *end_transaction(void *data)
+{
+	/* Delay a second before ending transaction */
+	struct timespec delay = { 1, 0 };
+	int *passed = aeap_transaction_user_obj(data);
+
+	while (nanosleep(&delay, &delay));
+
+	++*passed;
+	aeap_transaction_end(data, 0);
+
+	return NULL;
+}
+
+static enum ast_test_result_state exec(struct ast_test *test,
+	struct ast_aeap_tsx_params *params)
+{
+	pthread_t thread_id = AST_PTHREADT_NULL;
+	struct ao2_container *tsxs = NULL;
+	struct aeap_transaction *tsx = NULL;
+	enum ast_test_result_state res = AST_TEST_FAIL;
+	int passed = 0;
+
+	tsxs = aeap_transactions_create();
+	if (!tsxs) {
+		ast_test_status_update(test, "Failed to create transactions object\n");
+		goto exec_cleanup;
+	}
+
+	params->wait = 1;
+	params->obj = &passed;
+
+	tsx = aeap_transaction_create_and_add(tsxs, AEAP_TRANSACTION_ID, params, NULL);
+	if (!tsx) {
+		ast_test_status_update(test, "Failed to create transaction object\n");
+		goto exec_cleanup;
+	}
+
+	if (ast_pthread_create(&thread_id, NULL, end_transaction, ao2_bump(tsx))) {
+		ast_test_status_update(test, "Failed to create response thread\n");
+		ao2_ref(tsx, -1);
+		goto exec_cleanup;
+	}
+
+	if (aeap_transaction_start(tsx)) {
+		ast_test_status_update(test, "Failed to start transaction request\n");
+		goto exec_cleanup;
+	}
+
+	if (passed == 1) {
+		res = AST_TEST_PASS;
+	}
+
+exec_cleanup:
+
+	if (thread_id != AST_PTHREADT_NULL) {
+		pthread_cancel(thread_id);
+		pthread_join(thread_id, NULL);
+	}
+
+	aeap_transaction_end(tsx, 0);
+	ao2_cleanup(tsxs);
+
+	return res;
+}
+
+AST_TEST_DEFINE(transaction_exec)
+{
+	struct ast_aeap_tsx_params params = {
+		.timeout = 5000, /* Give plenty of time for test thread to end */
+	};
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test creating a basic AEAP transaction request";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	return exec(test, &params);
+}
+
+AST_TEST_DEFINE(transaction_exec_timeout)
+{
+	struct ast_aeap_tsx_params params = {
+		.timeout = 100, /* Ensure timeout occurs before test thread ends */
+		.on_timeout = handle_timeout,
+	};
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test creating a AEAP transaction request that times out";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	return exec(test, &params);
+}
+
+static int load_module(void)
+{
+	AST_TEST_REGISTER(transaction_exec);
+	AST_TEST_REGISTER(transaction_exec_timeout);
+
+	return AST_MODULE_LOAD_SUCCESS;
+}
+
+static int unload_module(void)
+{
+	AST_TEST_UNREGISTER(transaction_exec_timeout);
+	AST_TEST_UNREGISTER(transaction_exec);
+
+	return 0;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_DEFAULT, "Asterisk External Application Protocol Transaction Tests",
+	.support_level = AST_MODULE_SUPPORT_CORE,
+	.load = load_module,
+	.unload = unload_module,
+	.requires = "res_aeap",
+);
diff --git a/tests/test_aeap_transport.c b/tests/test_aeap_transport.c
new file mode 100644
index 0000000000000000000000000000000000000000..675ecf2088ae105476313177508809963dc77ae9
--- /dev/null
+++ b/tests/test_aeap_transport.c
@@ -0,0 +1,249 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*** MODULEINFO
+	<depend>TEST_FRAMEWORK</depend>
+	<depend>res_aeap</depend>
+	<support_level>core</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include "asterisk/http.h"
+#include "asterisk/test.h"
+#include "asterisk/module.h"
+
+#include "../res/res_aeap/transport.h"
+
+#define CATEGORY "/res/aeap/transport/"
+
+#define ADDR "127.0.0.1:8088"
+#define TRANSPORT_URL "ws://" ADDR "/ws"
+#define TRANSPORT_URL_INVALID "ws://" ADDR "/invalid"
+#define TRANSPORT_PROTOCOL "echo"
+#define TRANSPORT_PROTOCOL_INVALID "invalid"
+#define TRANSPORT_TIMEOUT 2000
+
+AST_TEST_DEFINE(transport_create_invalid)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test creating an AEAP invalid transport type";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	/* Transport is expected to be NULL here */
+	ast_test_validate(test, !(transport = aeap_transport_create("invalid")));
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(transport_create)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test creating an AEAP transport";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	/* Type is based off the scheme, so just pass in the URL here */
+	ast_test_validate(test, (transport = aeap_transport_create(TRANSPORT_URL)));
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(transport_connect)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test connecting to an AEAP transport";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	/* Type is based off the scheme, so just pass in the URL for the type */
+	ast_test_validate(test, (transport = aeap_transport_create_and_connect(
+		TRANSPORT_URL, TRANSPORT_URL, TRANSPORT_PROTOCOL, TRANSPORT_TIMEOUT)));
+
+	ast_test_validate(test, aeap_transport_is_connected(transport));
+	ast_test_validate(test, !aeap_transport_disconnect(transport));
+	ast_test_validate(test, !aeap_transport_is_connected(transport));
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(transport_connect_fail)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test connecting failure for an AEAP transport";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	/* Test invalid address */
+	ast_test_validate(test, (transport = aeap_transport_create(TRANSPORT_URL)));
+
+	ast_test_validate(test, aeap_transport_connect(transport,
+		TRANSPORT_URL_INVALID, TRANSPORT_PROTOCOL, TRANSPORT_TIMEOUT));
+
+	ast_test_validate(test, !aeap_transport_is_connected(transport));
+
+	aeap_transport_destroy(transport);
+
+	/* Test invalid protocol */
+	ast_test_validate(test, (transport = aeap_transport_create(TRANSPORT_URL)));
+
+	ast_test_validate(test, aeap_transport_connect(transport,
+		TRANSPORT_URL, TRANSPORT_PROTOCOL_INVALID, TRANSPORT_TIMEOUT));
+
+	ast_test_validate(test, !aeap_transport_is_connected(transport));
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(transport_binary)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+	int num = 38;
+	enum AST_AEAP_DATA_TYPE rtype;
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test binary I/O from an AEAP transport";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	ast_test_validate(test, (transport = aeap_transport_create_and_connect(
+		TRANSPORT_URL, TRANSPORT_URL, TRANSPORT_PROTOCOL, TRANSPORT_TIMEOUT)));
+
+	ast_test_validate(test, aeap_transport_write(transport, &num, sizeof(num),
+		AST_AEAP_DATA_TYPE_BINARY) == sizeof(num));
+	ast_test_validate(test, aeap_transport_read(transport, &num,
+		sizeof(num), &rtype) == sizeof(num));
+	ast_test_validate(test, rtype == AST_AEAP_DATA_TYPE_BINARY);
+	ast_test_validate(test, num == 38);
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(transport_string)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+	char buf[16];
+	enum AST_AEAP_DATA_TYPE rtype;
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test string I/O from an AEAP transport";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	ast_test_validate(test, (transport = aeap_transport_create_and_connect(
+		TRANSPORT_URL, TRANSPORT_URL, TRANSPORT_PROTOCOL, TRANSPORT_TIMEOUT)));
+
+	ast_test_validate(test, aeap_transport_write(transport, "foo bar baz", 11,
+		AST_AEAP_DATA_TYPE_STRING) == 11);
+	ast_test_validate(test, aeap_transport_read(transport, buf,
+		sizeof(buf) / sizeof(char), &rtype) == 11);
+	ast_test_validate(test, rtype == AST_AEAP_DATA_TYPE_STRING);
+	ast_test_validate(test, !strcmp(buf, "foo bar baz"));
+
+	return AST_TEST_PASS;
+}
+
+static struct ast_http_server *http_server;
+
+static int load_module(void)
+{
+	if (!(http_server = ast_http_test_server_get("aeap transport http server", NULL))) {
+		return AST_MODULE_LOAD_DECLINE;
+	}
+
+	AST_TEST_REGISTER(transport_string);
+	AST_TEST_REGISTER(transport_binary);
+	AST_TEST_REGISTER(transport_connect_fail);
+	AST_TEST_REGISTER(transport_connect);
+	AST_TEST_REGISTER(transport_create);
+	AST_TEST_REGISTER(transport_create_invalid);
+
+	return AST_MODULE_LOAD_SUCCESS;
+}
+
+static int unload_module(void)
+{
+	AST_TEST_UNREGISTER(transport_string);
+	AST_TEST_UNREGISTER(transport_binary);
+	AST_TEST_UNREGISTER(transport_connect_fail);
+	AST_TEST_UNREGISTER(transport_connect);
+	AST_TEST_UNREGISTER(transport_create);
+	AST_TEST_UNREGISTER(transport_create_invalid);
+
+	ast_http_test_server_discard(http_server);
+
+	return 0;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_DEFAULT, "Asterisk External Application Protocol Transport Tests",
+	.support_level = AST_MODULE_SUPPORT_CORE,
+	.load = load_module,
+	.unload = unload_module,
+	.requires = "res_aeap",
+);