Building Custom Flow Steps

📅 Published on

📝 Last updated

Documentation

»

Developer Guides

»

Building Custom Flow Steps

Groundhogg 4.9 rebuilt the flow editor. The canvas, the settings panels and the step picker are now drawn in JavaScript, and every edit is saved as a small operation in the background instead of posting the whole flow. Step types are still registered in PHP the same way, and most custom steps keep working, but there are new ways to draw a step’s settings in JavaScript and to let AI agents build your steps through the Abilities API.

This guide shows how to register a step type, how to draw its settings with the new Funnel.registerStepType() API, and how to expose it to AI agents through the Abilities API. Updating an add-on written for an earlier version? See 4.9 Breaking Changes for Developers for what changed and might need attention.

Requirements: Groundhogg 4.9 or later. Add-on code must still run on PHP 7.4, so avoid PHP 8-only syntax such as match, the nullsafe operator ?->, named arguments and union types.

How a step type fits together

PieceRequired?What it does
A PHP class extending a step base classYesName, icon, settings, title, and what happens when a contact reaches the step.
get_settings_schema()RecommendedHow each setting is sanitized, its default, and its value on a new step. Replaces a custom save().
Funnel.registerStepType()OptionalDraws the settings in JavaScript and shows the title on the canvas instantly while a change saves.
Step_Type_Schema::extend()OptionalLets agents build and edit the step with groundhogg/create-flow and groundhogg/edit-flow.

1. Register the step type in PHP

Register step types on the groundhogg/steps/init action. If your add-on extends Groundhogg\Extension, override register_funnel_steps(), which is hooked to it for you.

// In your Extension subclass
public function register_funnel_steps( $manager ) {
	$manager->add_step( new \MyAddon\Steps\Add_Points() );
}

// Or without the Extension base class
add_action( 'groundhogg/steps/init', function ( $manager ) {
	$manager->add_step( new \MyAddon\Steps\Add_Points() );
} );

Here is a complete action that adds points to a contact. Actions extend Groundhogg\Steps\Actions\Action; triggers extend Groundhogg\Steps\Benchmarks\Benchmark; logic steps extend Groundhogg\Steps\Logic\Logic or Branch_Logic.

namespace MyAddon\Steps;

use Groundhogg\Step;
use Groundhogg\Steps\Actions\Action;
use function Groundhogg\html;

class Add_Points extends Action {

	public function get_type() {
		return 'my_add_points'; // unique, snake_case, prefixed with your add-on
	}

	public function get_name() {
		return __( 'Add Points', 'my-addon' );
	}

	public function get_description() {
		return __( 'Add points to the contact\'s score.', 'my-addon' );
	}

	public function get_sub_group() {
		return 'crm'; // which group of the step picker it's listed in
	}

	public function get_icon() {
		return MY_ADDON_ASSETS_URL . 'images/add-points.svg';
	}

	/**
	 * The settings, as they're drawn on the server. Name inputs with setting_name_prefix(),
	 * so they're saved with the step.
	 */
	public function settings( $step ) {

		html( 'p', [], esc_html__( 'Add this many points...', 'my-addon' ) );

		echo html()->input( [
			'type'  => 'number',
			'min'   => 1,
			'id'    => $this->setting_id_prefix( 'points' ),
			'name'  => $this->setting_name_prefix( 'points' ),
			'value' => $this->get_setting( 'points' ),
		] );
	}

	/**
	 * How each setting is sanitized when it's saved, its default, and what a new step starts with.
	 */
	public function get_settings_schema() {
		return [
			'points' => [
				'sanitize' => 'absint',
				'default'  => 0,
				'initial'  => 10,
			],
		];
	}

	/**
	 * Warnings shown on the step in the editor.
	 */
	public function validate_settings( Step $step ) {
		if ( ! $this->get_setting( 'points' ) ) {
			$step->add_error( 'no_points', __( 'Choose how many points to add.', 'my-addon' ) );
		}
	}

	/**
	 * The title shown on the canvas. Basic formatting (b, strong, em, i, u, code) is allowed.
	 */
	public function generate_step_title( $step ) {
		return sprintf( __( 'Add <b>%d</b> points', 'my-addon' ), absint( $this->get_setting( 'points' ) ) );
	}

	/**
	 * What happens when a contact reaches the step. Return true, or a WP_Error.
	 */
	public function run( $contact, $event ) {
		$points = absint( $this->get_setting( 'points' ) );

		$contact->update_meta( 'points', absint( $contact->get_meta( 'points' ) ) + $points );

		return true;
	}
}

The settings schema

Each key of get_settings_schema() is a setting, stored as step meta and read with $this->get_setting( 'key' ). With a schema you don’t need a save() method.

KeyMeaning
sanitizeA callable that cleans the posted value before it’s stored.
defaultReturned by get_setting() when nothing (or an empty value) is stored.
initialThe value a brand new step starts with, in the editor and when built by an agent.
if_undefinedStored when the setting isn’t posted at all, like an unchecked checkbox.

If a setting holds the ID of another step (for example a step to jump to), declare it, so the editor and the abilities refuse to delete the step it points at:

protected function get_step_reference_settings(): array {
	return [ 'next' ];
}

2. Draw the settings in JavaScript

Plain PHP inputs named with setting_name_prefix() keep working with no JavaScript at all. When the settings need more than plain inputs (pickers, conditional fields, live previews), register the step type in JavaScript with Funnel.registerStepType().

Load your script in the flow editor

Register the script so it depends on groundhogg-admin-funnel-editor, and enqueue it on the flow editor.

// In your Extension subclass
public function register_admin_scripts( $is_minified, $dot_min ) {
	wp_register_script( 'my-addon-funnel-steps', MY_ADDON_ASSETS_URL . 'js/funnel-steps.js', [
		'groundhogg-admin-funnel-editor',
	], MY_ADDON_VERSION, true );
}

public function funnel_editor_scripts( $funnel ) {
	wp_enqueue_script( 'my-addon-funnel-steps' );
}

// Or without the Extension base class, on these hooks:
// groundhogg/scripts/after_register_admin_scripts and groundhogg/admin/funnels/editor_scripts

Register the step type

( () => {

  const { Div, Pg, Input } = MakeEl
  const { __, sprintf } = wp.i18n

  const pointsOf = meta => parseInt( meta.points ) || 0

  Funnel.registerStepType( 'my_add_points', {

    // What a new step starts with until the server's settings arrive
    defaults: {
      points: 10,
    },

    // Drawn where the PHP settings() output would be. Call update() to save a change.
    settings: ( { ID, meta }, update ) => Div( { className: 'display-flex column gap-10' }, [
      Pg( {}, __( 'Add this many points...', 'my-addon' ) ),
      Input( {
        id     : `step_${ ID }_points`,
        type   : 'number',
        min    : 1,
        value  : pointsOf( meta ),
        onInput: e => update( { points: parseInt( e.target.value ) || 0 } ),
      } ),
    ] ),

    // Shown on the canvas while a change is saving. Keep it the same as generate_step_title().
    title: ( { meta } ) => sprintf( __( 'Add <b>%d</b> points', 'my-addon' ), pointsOf( meta ) ),

    // Shown on the step while a change is saving, like validate_settings()
    validate: ( { meta } ) => pointsOf( meta ) ? [] : [
      { code: 'no_points', message: __( 'Choose how many points to add.', 'my-addon' ) },
    ],
  } )

} )()
OptionDescription
settings( step, update )Returns the settings UI, built with MakeEl. It’s drawn inside the panel’s .step-type-settings element, where PHP settings() prints. update( { key: value } ) stores the change and saves it a moment after the user stops typing.
title( step )The title shown on the canvas while a change saves. Return undefined to keep the last one. Once the server replies, its title is shown.
validate( step )Warnings, [ { code, message } ], shown while a change saves.
branches( step )For branching logic steps: [ { key, name, classes } ], so branches added or renamed show up right away.
defaultsThe settings a step added in the editor starts with, until the server’s arrive.
onDuplicate( step )Extra choices to send when the step is duplicated, or a promise of them.

Things to know

  • Only the step being edited is drawn by your JavaScript. When the user leaves the step, the server’s HTML from settings() is put back. Keep settings() in PHP outputting the same inputs or placeholders.
  • Settings are saved from the open panel. Values passed to update(), and inputs inside the panel named steps[ID][key], are sent to the server, which saves them with the step’s save() or schema. Nothing outside the panel is sent.
  • Escape what you put in as HTML. MakeEl treats string children as HTML. Escape user content with Groundhogg.element.escHTML(), or clean it with Groundhogg.element.sanitizeHTML().
  • The older API still works. Funnel.registerStepCallbacks( type, { onActive( step ) { … } } ) runs when the step’s panel opens, after explicit saves, and after undo or redo. Keep using it for settings that need the old panel, like a TinyMCE editor.
  • Don’t change the canvas markup. The canvas is redrawn from data after every change, so anything a script adds to it is lost. To add a class, a label or an icon to a step, use the groundhogg/steps/sortable/classes, …/sortable/labels and …/sortable/inside PHP hooks.

3. Expose the step to abilities

Groundhogg’s flow abilities, groundhogg/create-flow, groundhogg/get-flow and groundhogg/edit-flow, let an AI agent build and change flows. An agent can only build your step type, and set its settings, once you describe those settings for agents.

Describe it on the groundhogg/abilities/register_step_types action with Step_Type_Schema::extend():

use Groundhogg\Abilities\Schemas\Step_Type_Schema;

add_action( 'groundhogg/abilities/register_step_types', function () {

	Step_Type_Schema::extend( 'my_add_points', [
		'type'                 => 'object',
		'additionalProperties' => false,
		'required'             => [ 'points' ],
		'properties'           => [
			'points' => [
				'type'        => 'integer',
				'minimum'     => 1,
				'description' => __( 'How many points to add to the contact\'s score.', 'my-addon' ),
			],
		],
	] );
} );

Now an agent can call groundhogg/create-flow with a step like { "type": "my_add_points", "settings": { "points": 25 } }, and groundhogg/get-flow returns the same shape.

The parameters

Step_Type_Schema::extend(
	string    $type,             // a step type registered on groundhogg/steps/init
	array     $settings_schema,  // JSON Schema for the step's `settings`, as an agent passes them
	array|callable $branch_keys = [],  // branching steps only, see below
	?callable $resolver = null,  // agent input -> stored settings
	?callable $exporter = null   // stored settings -> agent shape, for get-flow
);
  • The settings schema describes what an agent passes. Every sub-schema needs a type, and use definitions rather than $defs for shared parts, since WordPress drops $defs when it prepares schemas for clients. Write the descriptions for an agent deciding what to pass.
  • Register on the action, not earlier. WordPress builds its abilities the first time anything asks for them, and each ability builds its schema from what’s registered at that moment. The action fires at the right time, after every step type is registered. Calling extend() at file load or on groundhogg/steps/init isn’t reliable.

When the stored settings differ: resolver and exporter

If the settings an agent passes aren’t stored as they are, translate them with a resolver, and translate them back with an exporter so get-flow returns what edit-flow accepts. For example, an agent passes a tag name, and the step stores its ID:

Step_Type_Schema::extend( 'my_tag_step', [
	'type'       => 'object',
	'properties' => [
		'tag' => [ 'type' => 'string', 'description' => __( 'The name of an existing tag.', 'my-addon' ) ],
	],
], [],
	// resolver: agent input -> what's stored as step meta, or a WP_Error to refuse it
	function ( array $settings, array $declared ) {
		// find the tag by name, without creating it
		$ids = \Groundhogg\parse_tag_list( [ $settings['tag'] ?? '' ], 'ID', false );

		if ( empty( $ids ) ) {
			return new WP_Error( 'unknown_tag', __( 'There\'s no tag with that name.', 'my-addon' ) );
		}

		return [ 'settings' => [ 'tag_id' => $ids[0] ] ];
	},
	// exporter: stored meta -> the shape the resolver accepts
	function ( array $settings, \Groundhogg\Step $step ) {
		$tag = new \Groundhogg\Tag( absint( $step->get_meta( 'tag_id' ) ) );

		return $tag->exists() ? [ 'tag' => $tag->get_name() ] : [];
	}
);
  • The resolver returns [ 'settings' => [ … ] ], the meta to store. Settings that depend on the final order of the flow can go in 'deferred_settings', written after the steps are placed.
  • To point at another step created in the same call, use Step_Type_Schema::resolve_step_reference() with the $declared map.
  • get-flow only keeps stored keys that are named in the settings schema before it calls the exporter. If your stored keys differ, provide an exporter, or the step will look unconfigured to agents.
  • Whatever the exporter returns must be accepted by the resolver again, so a flow can be read with get-flow and changed with edit-flow.

Branching steps

For a logic step with branches, pass the branch keys an agent can put steps in: a fixed array like [ 'yes', 'no' ], or a callable function ( array $settings ): array when the branches come from the step’s own settings. The callable is also called with an empty array to list the type, so return the branches that always exist.

Form integrations are automatic

Step types that extend Groundhogg\Steps\Benchmarks\Form_Integration (like the Contact Form 7, Gravity Forms and WPForms triggers) are described for abilities by core, with a form picker and a field map, and the flow editor draws their settings in JavaScript. They need no changes, but keep get_forms_for_select_2(), get_form_fields() and normalize_field() working without a current step, since they’re called outside of one.

Check it

  • Ask an agent connected over MCP to call groundhogg/list-step-types for your type. It shows the settings schema agents see.
  • Build a flow with your step using groundhogg/create-flow, read it back with groundhogg/get-flow, and pass the result to groundhogg/edit-flow. All three should agree.
  • Open the flow in the editor and check the step’s settings and title.

Updating an existing add-on? See 4.9 Breaking Changes for Developers for what changed since 4.7 and what might need attention.

Was this helpful?

Let us know if this document answered your question. That’s the only way we can improve.