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
| Piece | Required? | What it does |
|---|---|---|
| A PHP class extending a step base class | Yes | Name, icon, settings, title, and what happens when a contact reaches the step. |
get_settings_schema() | Recommended | How each setting is sanitized, its default, and its value on a new step. Replaces a custom save(). |
Funnel.registerStepType() | Optional | Draws the settings in JavaScript and shows the title on the canvas instantly while a change saves. |
Step_Type_Schema::extend() | Optional | Lets 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.
| Key | Meaning |
|---|---|
sanitize | A callable that cleans the posted value before it’s stored. |
default | Returned by get_setting() when nothing (or an empty value) is stored. |
initial | The value a brand new step starts with, in the editor and when built by an agent. |
if_undefined | Stored 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' ) },
],
} )
} )()
| Option | Description |
|---|---|
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. |
defaults | The 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. Keepsettings()in PHP outputting the same inputs or placeholders. - Settings are saved from the open panel. Values passed to
update(), and inputs inside the panel namedsteps[ID][key], are sent to the server, which saves them with the step’ssave()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 withGroundhogg.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/labelsand…/sortable/insidePHP 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 usedefinitionsrather than$defsfor shared parts, since WordPress drops$defswhen 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 ongroundhogg/steps/initisn’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$declaredmap. get-flowonly 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-flowand changed withedit-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-typesfor your type. It shows the settings schema agents see. - Build a flow with your step using
groundhogg/create-flow, read it back withgroundhogg/get-flow, and pass the result togroundhogg/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.
