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..01f6921d956b4c1f1c94a548a37a2782195fdd5b
--- /dev/null
+++ b/includes/class-custom-action.php
@@ -0,0 +1,127 @@
+<?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 ) {
+		// Normally processed in the admin context.
+		require_once( ABSPATH . 'wp-admin/includes/admin.php' );
+
+		// 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 );
+
+		//
+		$results = compact( 'return_url', 'vars' );
+		do_action( 'bulk_actions_cron_offload_custom_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 processed. These items are hidden until processing completes.', 'bulk-actions-cron-offload' );
+			}
+		}
+
+		Main::render_admin_notice( $type, $message );
+	}
+
+	/**
+	 * 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' );
+	}
+
+	/**
+	 * When an edit 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 ) {
+		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;
+	}
+}
+
+Custom_Action::register_hooks();
diff --git a/includes/class-main.php b/includes/class-main.php
index 0b280d78e1d528ba129c806c8c1b66955c30c9e7..13a7b11e8b2ff790916eefb757fda58ef2fc3676 100644
--- a/includes/class-main.php
+++ b/includes/class-main.php
@@ -63,14 +63,12 @@ class Main {
 		$vars   = self::capture_vars();
 		$action = self::build_hook( $vars->action );
 
-		// What kind of actions is this?
+		// 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 );
 			}
-		} else {
-			// Do something special to offload custom things?
 		}
 
 		// Pass request to a class to handle offloading to cron, UX, etc.
@@ -101,7 +99,7 @@ class Main {
 	 * Capture relevant variables
 	 */
 	private static function capture_vars() {
-		$vars = array( 'action', 'user_id', 'current_screen' ); // Extra data that normally would be available from the context.
+		$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 );
 
@@ -189,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;
 	}
 
@@ -250,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;
 	}