diff --git a/bulk-actions-cron-offload.php b/bulk-actions-cron-offload.php
index f3d05fae4508850319666fc6922f182ebe3d02f4..3431ffc4018ccd93fa16049198f606d4f01d7212 100644
--- a/bulk-actions-cron-offload.php
+++ b/bulk-actions-cron-offload.php
@@ -20,6 +20,7 @@ require __DIR__ . '/includes/utils.php';
 
 // Plugin functionality.
 require __DIR__ . '/includes/class-main.php';
+require __DIR__ . '/includes/class-custom-action.php';
 require __DIR__ . '/includes/class-delete-all.php';
 require __DIR__ . '/includes/class-delete-permanently.php';
 require __DIR__ . '/includes/class-edit.php';
diff --git a/includes/class-custom-action.php b/includes/class-custom-action.php
new file mode 100644
index 0000000000000000000000000000000000000000..7f37d6dcc4be1b4286c2bbf694fa3369455e4f68
--- /dev/null
+++ b/includes/class-custom-action.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Offload custom actions
+ *
+ * @package Bulk_Actions_Cron_Offload
+ */
+
+namespace Automattic\WP\Bulk_Actions_Cron_Offload;
+
+/**
+ * Class Custom_Action
+ */
+class Custom_Action {
+	/**
+	 * Common hooks and such
+	 */
+	use Bulk_Actions;
+
+	/**
+	 * Class constants
+	 */
+	const ACTION = 'custom';
+
+	const ADMIN_NOTICE_KEY = 'bulk_actions_cron_offload_custom';
+
+	/**
+	 * Cron callback to run a custom bulk action
+	 *
+	 * Because bulk actions work off of a redirect by default, custom actions are
+	 * processed in the filter for the redirect destination, normally allowing
+	 * for customized messaging.
+	 *
+	 * @param object $vars Bulk-request variables.
+	 */
+	public static function process_via_cron( $vars ) {
+		// Provide for capabilities checks.
+		wp_set_current_user( $vars->user_id );
+
+		// TODO: capture and repopulate $_REQUEST?
+		// Rebuild something akin to the URL this would normally be filtering.
+		$return_url = sprintf( '/wp-admin/%1$s.php', $vars->current_screen->base );
+		$return_url = add_query_arg( array(
+			'post_type'   => $vars->post_type,
+			'post_status' => $vars->post_status,
+		), $return_url );
+
+		// Run the custom action as Core does. See note above.
+		$return_url = apply_filters( 'handle_bulk_actions-' . $vars->current_screen->id, $return_url, $vars->action, $vars->posts ); // Core violates its own standard by using a hyphen in the hook name. @codingStandardsIgnoreLine
+
+		// Can't get much more than this in terms of success or failure.
+		$results = compact( 'return_url', 'vars' );
+		do_action( 'bulk_actions_cron_offload_custom_request_completed', $results, $vars );
+	}
+
+	/**
+	 * Provide post-redirect success message
+	 *
+	 * @retun string
+	 */
+	public static function admin_notice_success_message() {
+		return __( 'Success! The selected posts will be processed shortly.', 'bulk-actions-cron-offload' );
+	}
+
+	/**
+	 * Provide post-redirect error message
+	 *
+	 * @retun string
+	 */
+	public static function admin_notice_error_message() {
+		return __( 'The requested processing is already pending for the chosen posts.', 'bulk-actions-cron-offload' );
+	}
+
+	/**
+	 * Provide message when posts are hidden pending processing
+	 *
+	 * @return string
+	 */
+	public static function admin_notice_hidden_pending_processing() {
+		return __( 'Some items that would normally be shown here are waiting to be processed. These items are hidden until processing completes.', 'bulk-actions-cron-offload' );
+	}
+}
+
+Custom_Action::register_hooks();
diff --git a/includes/class-delete-all.php b/includes/class-delete-all.php
index a7d15c765f08225acf36c446eacb438816b121d8..ec51f75536063ba495e6e86ec4da2cc11a94f4e7 100644
--- a/includes/class-delete-all.php
+++ b/includes/class-delete-all.php
@@ -64,8 +64,6 @@ class Delete_All {
 		$count = 0;
 
 		if ( is_array( $post_ids ) && ! empty( $post_ids ) ) {
-			require_once ABSPATH . '/wp-admin/includes/post.php';
-
 			$deleted    = array();
 			$locked     = array();
 			$auth_error = array();
@@ -120,7 +118,7 @@ class Delete_All {
 		if ( 'edit' === $screen->base && isset( $_REQUEST['post_status'] ) && 'trash' === $_REQUEST['post_status'] ) {
 			if ( Main::get_action_next_scheduled( self::ACTION, $screen->post_type ) ) {
 				$type    = 'warning';
-				$message = __( 'A pending request to empty the trash will be processed soon.', 'bulk-actions-cron-offload' );
+				$message = self::admin_notice_hidden_pending_processing();
 			}
 		}
 
@@ -145,6 +143,15 @@ class Delete_All {
 		return __( 'A request to empty the trash is already pending for this post type.', 'bulk-actions-cron-offload' );
 	}
 
+	/**
+	 * Provide translated message when posts are hidden pending processing
+	 *
+	 * @return string
+	 */
+	public static function admin_notice_hidden_pending_processing() {
+		return __( 'A pending request to empty the trash will be processed soon.', 'bulk-actions-cron-offload' );
+	}
+
 	/**
 	 * When a delete is pending for a given post type, hide those posts in the admin
 	 *
diff --git a/includes/class-delete-permanently.php b/includes/class-delete-permanently.php
index 74005932a2c9c912ff3d907cd77f49a617f6a979..6b0419a156a42ae3a7a5f8cac64ba20b67362cc1 100644
--- a/includes/class-delete-permanently.php
+++ b/includes/class-delete-permanently.php
@@ -32,8 +32,6 @@ class Delete_Permanently {
 		$count = 0;
 
 		if ( is_array( $vars->posts ) && ! empty( $vars->posts ) ) {
-			require_once ABSPATH . '/wp-admin/includes/post.php';
-
 			$deleted    = array();
 			$locked     = array();
 			$auth_error = array();
@@ -88,7 +86,7 @@ class Delete_Permanently {
 		if ( 'edit' === $screen->base && isset( $_REQUEST['post_status'] ) && 'trash' === $_REQUEST['post_status'] ) {
 			if ( Main::get_post_ids_for_pending_events( self::ACTION, $screen->post_type, 'trash' ) ) {
 				$type    = 'warning';
-				$message = __( 'Some items that would normally be shown here are waiting to be deleted permanently. These items are hidden until then.', 'bulk-actions-cron-offload' );
+				$message = self::admin_notice_hidden_pending_processing();
 			}
 		}
 
@@ -113,6 +111,15 @@ class Delete_Permanently {
 		return __( 'The selected posts are already scheduled to be deleted.', 'bulk-actions-cron-offload' );
 	}
 
+	/**
+	 * Provide translated message when posts are hidden pending processing
+	 *
+	 * @return string
+	 */
+	public static function admin_notice_hidden_pending_processing() {
+		return __( 'Some items that would normally be shown here are waiting to be deleted permanently. These items are hidden until then.', 'bulk-actions-cron-offload' );
+	}
+
 	/**
 	 * When a delete is pending for a given post type, hide those posts in the admin
 	 *
diff --git a/includes/class-edit.php b/includes/class-edit.php
index 5361b30bec6a4cd1f98da36c7b1bcbb73d0f656b..595f984fc9cdfa2c2f468ba67fae152aa5bc6afb 100644
--- a/includes/class-edit.php
+++ b/includes/class-edit.php
@@ -35,9 +35,6 @@ class Edit {
 			return;
 		}
 
-		// We want to use `bulk_edit_posts()`.
-		require_once ABSPATH . '/wp-admin/includes/post.php';
-
 		// `bulk_edit_posts()` takes an array, normally `$_REQUEST`, so we convert back.
 		$request_array = get_object_vars( $vars );
 		unset( $request_array['action'] );
@@ -75,34 +72,6 @@ class Edit {
 		do_action( 'bulk_actions_cron_offload_edit_request_completed', $results, $vars );
 	}
 
-	/**
-	 * Let the user know what's going on
-	 *
-	 * Not used for post-request redirect
-	 */
-	public static function admin_notices() {
-		$screen = get_current_screen();
-
-		$type    = '';
-		$message = '';
-
-		if ( 'edit' === $screen->base ) {
-			if ( isset( $_REQUEST['post_status'] ) && 'trash' === $_REQUEST['post_status'] ) {
-				return;
-			}
-
-			$status  = isset( $_REQUEST['post_status'] ) ? $_REQUEST['post_status'] : 'all';
-			$pending = Main::get_post_ids_for_pending_events( self::ACTION, $screen->post_type, $status );
-
-			if ( ! empty( $pending ) ) {
-				$type    = 'warning';
-				$message = __( 'Some items that would normally be shown here are waiting to be edited. These items are hidden until they are processed.', 'bulk-actions-cron-offload' );
-			}
-		}
-
-		Main::render_admin_notice( $type, $message );
-	}
-
 	/**
 	 * Provide post-redirect success message
 	 *
@@ -122,25 +91,12 @@ class Edit {
 	}
 
 	/**
-	 * When an edit is pending for a given post type, hide those posts in the admin
+	 * Provide notice when posts are hidden pending edits
 	 *
-	 * @param string $where Posts' WHERE clause.
-	 * @param object $q WP_Query object.
 	 * @return string
 	 */
-	public static function hide_posts( $where, $q ) {
-		if ( 'trash' === $q->get( 'post_status' ) ) {
-			return $where;
-		}
-
-		$post__not_in = Main::get_post_ids_for_pending_events( self::ACTION, $q->get( 'post_type' ), $q->get( 'post_status' ) );
-
-		if ( ! empty( $post__not_in ) ) {
-			$post__not_in = implode( ',', $post__not_in );
-			$where       .= ' AND ID NOT IN(' . $post__not_in . ')';
-		}
-
-		return $where;
+	public static function admin_notice_hidden_pending_processing() {
+		return __( 'Some items that would normally be shown here are waiting to be edited. These items are hidden until they are processed.', 'bulk-actions-cron-offload' );
 	}
 }
 
diff --git a/includes/class-main.php b/includes/class-main.php
index 2705149ca224fd789f4ad7a74d96c509d0f76553..e0c6a2fa011d2d9ce4639b95189b684906af42a5 100644
--- a/includes/class-main.php
+++ b/includes/class-main.php
@@ -32,6 +32,7 @@ class Main {
 	public static function load() {
 		add_action( self::CRON_EVENT, array( __CLASS__, 'do_cron' ) );
 
+		// TODO: add for upload.php and edit-comments.php. Anything else?
 		add_action( 'load-edit.php', array( __CLASS__, 'intercept' ) );
 
 		add_action( 'admin_notices', array( __CLASS__, 'admin_notices' ) );
@@ -62,13 +63,12 @@ class Main {
 		$vars   = self::capture_vars();
 		$action = self::build_hook( $vars->action );
 
-		if ( ! self::bulk_action_allowed( $vars->action ) ) {
-			return;
-		}
-
-		// Nothing to do, unless we're emptying the trash.
-		if ( empty( $vars->posts ) && 'delete_all' !== $vars->action ) {
-			self::do_admin_redirect( self::ADMIN_NOTICE_KEY, false );
+		// What kind of action is this?
+		if ( self::is_core_action( $vars->action ) ) {
+			// Nothing to do, unless we're emptying the trash.
+			if ( empty( $vars->posts ) && 'delete_all' !== $vars->action ) {
+				self::do_admin_redirect( self::ADMIN_NOTICE_KEY, false );
+			}
 		}
 
 		// Pass request to a class to handle offloading to cron, UX, etc.
@@ -99,11 +99,24 @@ class Main {
 	 * Capture relevant variables
 	 */
 	private static function capture_vars() {
-		$vars = array_merge( array( 'action', 'user_id' ), self::get_supported_vars() );
+		$vars = array( 'action', 'custom_action', 'user_id', 'current_screen' ); // Extra data that normally would be available from the context.
+		$vars = array_merge( $vars, self::get_supported_vars() );
 		$vars = (object) array_fill_keys( $vars, null );
 
+		// All permissions checks must be re-implemented!
 		$vars->user_id = get_current_user_id();
 
+		// Some dynamic hooks need screen data, but we don't need help and other private data.
+		// Fortunately, Core's private convention is used in the \WP_Screen class.
+		$screen = get_current_screen();
+		$screen = get_object_vars( $screen );
+		$screen = array_filter( $screen, function( $key ) {
+			return 0 !== strpos( $key, '_' );
+		}, ARRAY_FILTER_USE_KEY );
+		$vars->current_screen = (object) $screen;
+		unset( $screen );
+
+		// Remainder of data comes from $_REQUEST.
 		if ( isset( $_REQUEST['delete_all'] ) || isset( $_REQUEST['delete_all2'] ) ) {
 			$vars->action = 'delete_all';
 		} elseif ( isset( $_REQUEST['action'] ) && '-1' !== $_REQUEST['action'] ) {
@@ -174,6 +187,12 @@ class Main {
 			$vars->keep_private = true;
 		}
 
+		// Standardize custom actions.
+		if ( ! self::is_core_action( $vars->action ) ) {
+			$vars->custom_action = $vars->action;
+			$vars->action        = 'custom';
+		}
+
 		return $vars;
 	}
 
@@ -202,13 +221,13 @@ class Main {
 	}
 
 	/**
-	 * Validate action
+	 * Is this one of Core's default actions, or a custom action
 	 *
 	 * @param  string $action Action parsed from request vars.
 	 * @return bool
 	 */
-	public static function bulk_action_allowed( $action ) {
-		$allowed_actions = array(
+	public static function is_core_action( $action ) {
+		$core_actions = array(
 			'delete', // class Delete_Permanently.
 			'delete_all', // class Delete_All.
 			'edit', // class Edit.
@@ -216,7 +235,7 @@ class Main {
 			'untrash', // class Restore_From_Trash.
 		);
 
-		return in_array( $action, $allowed_actions, true );
+		return in_array( $action, $core_actions, true );
 	}
 
 	/**
@@ -235,6 +254,10 @@ class Main {
 	 * @return string
 	 */
 	public static function build_hook( $action ) {
+		if ( ! self::is_core_action( $action ) ) {
+			$action = 'custom';
+		}
+
 		return self::ACTION . $action;
 	}
 
diff --git a/includes/class-move-to-trash.php b/includes/class-move-to-trash.php
index 74588f750f8d4fcc2ea9258fe0a4810f6aa56185..8ccf2f0d587bb010e919ba3cb1906a6ca738dc58 100644
--- a/includes/class-move-to-trash.php
+++ b/includes/class-move-to-trash.php
@@ -32,8 +32,6 @@ class Move_To_Trash {
 		$count = 0;
 
 		if ( is_array( $vars->posts ) && ! empty( $vars->posts ) ) {
-			require_once ABSPATH . '/wp-admin/includes/post.php';
-
 			$trashed    = array();
 			$locked     = array();
 			$auth_error = array();
@@ -74,34 +72,6 @@ class Move_To_Trash {
 		}
 	}
 
-	/**
-	 * Let the user know what's going on
-	 *
-	 * Not used for post-request redirect
-	 */
-	public static function admin_notices() {
-		$screen = get_current_screen();
-
-		$type    = '';
-		$message = '';
-
-		if ( 'edit' === $screen->base ) {
-			if ( isset( $_REQUEST['post_status'] ) && 'trash' === $_REQUEST['post_status'] ) {
-				return;
-			}
-
-			$status  = isset( $_REQUEST['post_status'] ) ? $_REQUEST['post_status'] : 'all';
-			$pending = Main::get_post_ids_for_pending_events( self::ACTION, $screen->post_type, $status );
-
-			if ( ! empty( $pending ) ) {
-				$type    = 'warning';
-				$message = __( 'Some items that would normally be shown here are waiting to be moved to the trash. These items are hidden until they are moved.', 'bulk-actions-cron-offload' );
-			}
-		}
-
-		Main::render_admin_notice( $type, $message );
-	}
-
 	/**
 	 * Provide post-redirect success message
 	 *
@@ -121,25 +91,12 @@ class Move_To_Trash {
 	}
 
 	/**
-	 * When a move is pending for a given post type, hide those posts in the admin
+	 * Provide translated message when posts are hidden pending move
 	 *
-	 * @param string $where Posts' WHERE clause.
-	 * @param object $q WP_Query object.
 	 * @return string
 	 */
-	public static function hide_posts( $where, $q ) {
-		if ( 'trash' === $q->get( 'post_status' ) ) {
-			return $where;
-		}
-
-		$post__not_in = Main::get_post_ids_for_pending_events( self::ACTION, $q->get( 'post_type' ), $q->get( 'post_status' ) );
-
-		if ( ! empty( $post__not_in ) ) {
-			$post__not_in = implode( ',', $post__not_in );
-			$where       .= ' AND ID NOT IN(' . $post__not_in . ')';
-		}
-
-		return $where;
+	public static function admin_notice_hidden_pending_processing() {
+		return __( 'Some items that would normally be shown here are waiting to be moved to the trash. These items are hidden until they are moved.', 'bulk-actions-cron-offload' );
 	}
 }
 
diff --git a/includes/class-restore-from-trash.php b/includes/class-restore-from-trash.php
index c3bc23459d392864795a1278e9399697aadbb23b..ff1a764885bdc0e90a4881fff14e0086e4b40b0f 100644
--- a/includes/class-restore-from-trash.php
+++ b/includes/class-restore-from-trash.php
@@ -32,8 +32,6 @@ class Restore_From_Trash {
 		$count = 0;
 
 		if ( is_array( $vars->posts ) && ! empty( $vars->posts ) ) {
-			require_once ABSPATH . '/wp-admin/includes/post.php';
-
 			$restored   = array();
 			$locked     = array();
 			$auth_error = array();
@@ -88,7 +86,7 @@ class Restore_From_Trash {
 		if ( 'edit' === $screen->base && isset( $_REQUEST['post_status'] ) && 'trash' === $_REQUEST['post_status'] ) {
 			if ( Main::get_post_ids_for_pending_events( self::ACTION, $screen->post_type, 'trash' ) ) {
 				$type    = 'warning';
-				$message = __( 'Some items that would normally be shown here are waiting to be restored from the trash. These items are hidden until they are restored.', 'bulk-actions-cron-offload' );
+				$message = self::admin_notice_hidden_pending_processing();
 			}
 		}
 
@@ -113,6 +111,15 @@ class Restore_From_Trash {
 		return __( 'The selected posts are already scheduled to be restored.', 'bulk-actions-cron-offload' );
 	}
 
+	/**
+	 * Provide translated message when posts are hidden pending restoration
+	 *
+	 * @return string
+	 */
+	public static function admin_notice_hidden_pending_processing() {
+		return __( 'Some items that would normally be shown here are waiting to be restored from the trash. These items are hidden until they are restored.', 'bulk-actions-cron-offload' );
+	}
+
 	/**
 	 * When a restore is pending for a given post type, hide those posts in the admin
 	 *
diff --git a/includes/trait-bulk-actions.php b/includes/trait-bulk-actions.php
index 4580755d8cde157294f8e4533da4ee10050b0a3a..a504c2bfe4ec5fb9b249743ef2495a7cbf3280f5 100644
--- a/includes/trait-bulk-actions.php
+++ b/includes/trait-bulk-actions.php
@@ -19,7 +19,7 @@ trait Bulk_Actions {
 		add_action( Main::build_cron_hook( self::ACTION ), array( __CLASS__, 'process_via_cron' ) );
 
 		add_action( 'admin_notices', array( __CLASS__, 'render_admin_notices' ) );
-		add_filter( 'posts_where', array( __CLASS__, 'hide_posts' ), 999, 2 );
+		add_filter( 'posts_where', array( __CLASS__, 'hide_posts_common' ), 999, 2 );
 
 		add_filter( 'removable_query_args', array( __CLASS__, 'remove_notice_arg' ) );
 
@@ -47,6 +47,18 @@ trait Bulk_Actions {
 		}
 	}
 
+	/**
+	 * Prepare environment for individual actions
+	 *
+	 * @param object $vars Bulk-request variables.
+	 */
+	public static function process_via_cron( $vars ) {
+		// Normally processed in the admin context.
+		require_once( ABSPATH . 'wp-admin/includes/admin.php' );
+
+		parent::process_via_cron( $vars );
+	}
+
 	/**
 	 * Render the post-redirect notice, or hand off to class for other notices
 	 */
@@ -67,6 +79,34 @@ trait Bulk_Actions {
 		self::admin_notices();
 	}
 
+	/**
+	 * Let the user know what's going on
+	 *
+	 * Not used for post-request redirect
+	 */
+	public static function admin_notices() {
+		$screen = get_current_screen();
+
+		$type    = '';
+		$message = '';
+
+		if ( 'edit' === $screen->base ) {
+			if ( isset( $_REQUEST['post_status'] ) && 'trash' === $_REQUEST['post_status'] ) {
+				return;
+			}
+
+			$status  = isset( $_REQUEST['post_status'] ) ? $_REQUEST['post_status'] : 'all';
+			$pending = Main::get_post_ids_for_pending_events( self::ACTION, $screen->post_type, $status );
+
+			if ( ! empty( $pending ) ) {
+				$type    = 'warning';
+				$message = self::admin_notice_hidden_pending_processing();
+			}
+		}
+
+		Main::render_admin_notice( $type, $message );
+	}
+
 	/**
 	 * Provide translated success message for bulk action
 	 *
@@ -86,13 +126,22 @@ trait Bulk_Actions {
 	}
 
 	/**
-	 * When an edit is pending for a given post type, hide those posts in the admin
+	 * Provide translated message when posts are hidden pending processing
+	 *
+	 * @return string
+	 */
+	public static function admin_notice_hidden_pending_processing() {
+		return '';
+	}
+
+	/**
+	 * When a process is pending for a given post type, hide those posts in the admin
 	 *
 	 * @param string $where Posts' WHERE clause.
 	 * @param object $q WP_Query object.
 	 * @return string
 	 */
-	public static function hide_posts( $where, $q ) {
+	public static function hide_posts_common( $where, $q ) {
 		if ( ! is_admin() || ! $q->is_main_query() ) {
 			return $where;
 		}
@@ -101,7 +150,29 @@ trait Bulk_Actions {
 			return $where;
 		}
 
-		return parent::hide_posts( $where, $q );
+		return self::hide_posts( $where, $q );
+	}
+
+	/**
+	 * Hide posts pending processing
+	 *
+	 * @param string $where Posts' WHERE clause.
+	 * @param object $q WP_Query object.
+	 * @return string
+	 */
+	public static function hide_posts( $where, $q ) {
+		if ( 'trash' === $q->get( 'post_status' ) ) {
+			return $where;
+		}
+
+		$post__not_in = Main::get_post_ids_for_pending_events( self::ACTION, $q->get( 'post_type' ), $q->get( 'post_status' ) );
+
+		if ( ! empty( $post__not_in ) ) {
+			$post__not_in = implode( ',', $post__not_in );
+			$where       .= ' AND ID NOT IN(' . $post__not_in . ')';
+		}
+
+		return $where;
 	}
 
 	/**