| name | create-vip-workflow-agent |
| description | Scaffold a VIP Workflow agent plugin. Use when the user wants to create a research agent, a story discovery source, an AI-stage agent, or a combined agent that provides multiple capabilities for VIP Workflow. |
Create a VIP Workflow Agent
An agent is a standalone WordPress plugin that extends VIP Workflow. All agents appear as cards on the unified Integrations > Agents tab. An agent can provide one or more of these capabilities:
- Research — runs after an editor has a seed, inside an ideation project. Returns cards (articles, discussions, media) that populate the project's mood board.
- Discovery — runs before an editor has a seed, on the ideation landing page. Returns story prompts that editors can click to spawn a new project.
- Stage — runs when a post enters an AI-owned workflow stage. Returns a pass/fail verdict the stage's routing map turns into an exit transition; execution errors follow the stage's optional error route or fail in place.
A single plugin can provide research only, discovery only, stage only, or a combination. See docs/specs/shipped/unified-assistants-tab.md for the underlying architecture.
Decide the Shape First
Before writing any code, decide which capabilities the agent offers:
| Capability | When it runs | Returns | Registration hook | Required callbacks |
|---|
| Research | Inside a project (after seed) | Cards for mood board | wp_abilities_api_init | execute_callback |
| Discovery | Landing page (before seed) | Story prompts | vip_workflow_register_discovery_providers | seed + one or both of recommend / search (+ filters if search) |
| Stage | When a post enters an AI-owned workflow stage | { status: pass|fail, summary } | vip_workflow_register_abilities | execute_callback |
Discovery itself has two independent sub-features declared in the features array:
recommend — returns a curated list for the landing page. Called on page load, no user query. Think "what's newsworthy right now."
search — returns results matching a user query and filter selections, powering the "Browse more…" modal. Requires filters callback alongside.
A discovery provider can declare recommend only, search only, or both. Most full-featured sources do both.
Stage Agents (AI-owned workflow stages)
A stage agent runs automatically when a post enters a workflow stage that has been marked as AI-owned. It inspects the post, optionally rewrites the content (saving a revision), and returns an outcome. The stage's routing table then maps that outcome to one of the stage's configured transitions — so the agent participates in transitions exactly like a human stage (bump back, advance to a human, or move on).
A stage agent is a normal ability plus an agent manifest. It has different requirements from research and discovery agents:
- Input contract — take
post_id; read the post through VIPWorkflow\Abilities\Agents\StageAgent::read_post() so permissions and empty-content errors match core stage agents.
- Stage metadata — set
meta.stage_eligible => true and include 'stage' in meta.supports alongside 'workflow'. The Sequence editor's agent picker and /vip-workflow/v1/abilities?context=stage only list abilities with both signals.
- Agent manifest — register on
vip_workflow_register_assistant_meta with capabilities => array( 'stage' ). This marks the card as Available in AI stage only when the referenced ability is also stage-eligible.
- Output contract — return
{ status, summary, ... } where status is either pass or fail. Stage agents make a binary editorial judgment: return pass when the post is safe to continue on the success path, or fail (with a clear summary/issues payload) when it should not. Return a WP_Error on failure; the runner routes that through the stage's error destination.
- Mutability contract — read-only agents should declare
annotations.readonly => true. Mutating agents must write through StageAgent: use StageAgent::write_content() to rewrite the body, or StageAgent::write_block_notes() to annotate blocks with native editorial notes (comment_type => 'note', anchored via the block's metadata.noteId). Both sanitize content and attribute the change to the acting user. A note-writing agent is not read-only (annotations.readonly => false). Native notes render in the editor's Comments panel for post types that declare editor => notes support (post/page do by default; a custom type needs add_post_type_support( $type, 'notes' )).
Routing (authored on the stage, not the agent): agent.routing = { pass, fail, error }, where each value is a status key that must be one of that stage's configured transitions. Every key is optional, error included: when error is routed, the runner sends a WP_Error, an invalid contract result, or any unrouted outcome there; without it, an errored run fails in place and the editor offers a "go back to the previous stage" action. The engine (StageAgentRunner) runs the agent asynchronously on stage entry, gates human transitions while it runs (and while it sits failed with a go-back available), and performs the exit transition as the agent — humans can only ever take the routed destinations.
In the Sequence editor these are authored on the canvas, not in a form: choosing an agent in the stage inspector is what makes the stage AI-owned, and the node then carries three colored source handles — green pass, red fail, amber error. Dragging one onto another stage sets that outcome's destination and creates the transition it travels on. The inspector reads the routes back but does not edit them.
An AI stage's other transitions are disabled. The agent owns the way out, so any transition no outcome routes along is inert: StatusManager::get_available_transitions() withholds it (the block editor offers no buttons while the agent works), and the canvas draws it dashed and grey. Nothing is deleted — the transition keeps its roles, tools, and notifications, and goes live again the moment an outcome is routed along it or the agent is taken off the stage. The withholding is scoped to a run actually being in flight: a failed run, or a stage that gained its agent while posts were already sitting in it, hands the transitions back rather than stranding the post.
Audit logging and revision attribution for agent stages are tracked separately.
Agent stages are not reproducible. StageAgent::generate() requests no sampling temperature and offers no way to ask for one, so identical inputs may produce different output — including a different pass/fail verdict on the same post. Mechanical stages (reformatting, tag sanity) briefly pinned temperature to 0 and did promise stable output; that promise is withdrawn. Newer Claude models refuse any request carrying the option, answering with HTTP 400 rather than ignoring it, and the AI Client's model metadata cannot be used to send it selectively — the Anthropic provider applies one hardcoded option list to every model it enumerates, advertising temperature as supported even where the API rejects it. Sending it only to models believed to accept it would be a guess, so it is not sent at all. Do not write an agent whose correctness depends on run-to-run stability.
Requirements
Before starting, gather from the user:
- Shape — research, discovery, stage, or a combination?
- For discovery — does it support
recommend, search, or both?
- For stage — read-only or mutating? A binary
pass/fail judgment, and what data should appear in the result payload?
- For stage — what post inputs and settings does it need? Stage agents should always require
post_id; optional settings belong in the agent's settings_schema or in the stage's agent.settings.
- Data source — what API or service? (e.g., Reddit, PubMed, Foresight News, a wire service, an internal CMS, or an AI prompt over the current post)
- Plugin slug — e.g.,
workflow-agent-reddit, workflow-agent-copy-edit, or workflow-qwoted for combined
- Namespaced IDs — ability ID like
workflow-agent-reddit/reddit (research), workflow-agent-copy-edit/copy-edit (stage), or provider slug like my-news-source (discovery)
- Icon — an icon slug (e.g.
'search', 'calendar'), from the set in
src/admin/components/ideation/assistant-icon.js. Not an emoji: the admin
renders these through @wordpress/icons, and an unknown slug renders nothing.
- Credentials? — does it need an API key? Determines
settings_schema and availability_callback / is_configured. Where does the key come from — the agent's own card fields (RequirementFactory::in_card()), a service the plugin reads through VIPWorkflow\AI\Credentials (RequirementFactory::missing_credential()), or nowhere the user can act on (unsupported_environment() / dependency())? See Availability
Plugin Structure
workflow-{name}/
workflow-{name}.php # Main plugin file (single file is fine for most agents)
The plugin directory lives alongside vip-workflow/ in the same parent directory (not inside it).
Naming conventions (not enforced, but keep things scannable):
- Research-only:
workflow-agent-{name} (e.g., workflow-agent-wikipedia)
- Stage-only:
workflow-agent-{name} (e.g., workflow-agent-copy-edit)
- Discovery-only:
workflow-discovery-{name} (e.g., workflow-discovery-newswire)
- Both:
workflow-{name} (e.g., workflow-qwoted)
Boilerplate: Research-Only
Use this when the agent only runs during ideation on the mood board.
<?php
declare( strict_types=1 );
namespace WorkflowAgent{PascalName};
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'wp_abilities_api_init', __NAMESPACE__ . '\register' );
function register(): void {
if ( ! function_exists( 'vip_workflow_register_ability' ) ) {
return;
}
vip_workflow_register_ability(
'workflow-agent-{name}/{slug}',
array(
'label' => __( '{Display Name}', 'workflow-agent-{name}' ),
'description' => __( '{One-line description of what this searches.}', 'workflow-agent-{name}' ),
'category' => 'research',
'input_schema' => array(
'type' => 'object',
'properties' => (
=> ( => ),
=> ( => ),
=> ( => ),
=> ( => ),
=> ( => ),
),
=> ( ),
),
=> (
=> ,
=> (
=> ( => ),
=> ( => ),
),
),
=> . ,
=> {
( );
},
=> (
=> ,
=> ,
=> ,
=> ,
=> ( , ),
=> ( , ),
),
)
);
}
{
= [] ?? ();
( ! ( [] ) ) {
= ( [] );
} {
= [] ?? ();
( ( ) ) {
= ( [] ?? );
}
}
= ( );
( ( ) ) {
( => (), => );
}
= ();
( ( , , ) ) {
= ( , ( ) );
}
(
=> ,
=> ( , ( ) ),
);
}
{
= ( ( => ), );
= ( , ( => ) );
( ( ) || ( ) !== ) {
();
}
= ( ( ), );
= [] ?? ();
= ();
( ( , , ) ) {
[] = (
=> ,
=> ,
=> ,
=> [] ?? ,
=> [] ?? ,
=> [] ?? ,
=> [] ?? ,
=> ,
=> [] ?? ,
=> [] ?? ,
=> [] ?? ,
=> ,
);
}
;
}
Research Card Fields
Every card must include:
source_type — 'article', 'image', 'video', 'discussion', or 'document'
origin — identifies where this card came from (your source name)
title — display title
url — link to the original source
Recommended: excerpt, content, domain, author, date, image, score, source.
These fields are the card's identity. A stored source's id is derived from
the card — the URL when it has one, otherwise title plus content — so
returning the same card on a later run updates nothing and inserts nothing
rather than adding a duplicate. Two consequences for your agent:
- Return a stable
url for anything that has one. A URL that changes between
runs (a cache-busting query param, a session id) forks into a new card
each time.
- If your agent generates content rather than finding it, put the full body in
content. Cards with no URL are identified by title plus body, so two
generated cards that differ only in a field you left out will collapse into
one and the second will be silently dropped.
Optional grouping: if your agent returns related cards (e.g., an article + its comments), give them the same group_id string. The mood board will render them as a linked pair.
Boilerplate: Stage-Capable
Use this when the agent only runs as an AI-owned workflow stage.
<?php
declare( strict_types=1 );
namespace WorkflowAgent{PascalName};
use VIPWorkflow\Abilities\Agents\StageAgent;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'vip_workflow_register_abilities', __NAMESPACE__ . '\register' );
add_action( 'vip_workflow_register_assistant_meta', __NAMESPACE__ . '\register_agent_meta' );
function register(): void {
if ( ! function_exists( 'vip_workflow_register_ability' ) || ! class_exists( StageAgent::class ) ) {
return;
}
vip_workflow_register_ability(
'workflow-agent-{name}/{slug}',
array(
'label' => __( '{Display Name}', 'workflow-agent-{name}' ),
=> ( , ),
=> ,
=> (
=> ,
=> ,
=> (
=> (
=> ,
=> ( , ),
),
),
=> ( ),
),
=> (
=> ,
=> ,
=> ( , ),
=> (
=> (
=> ,
=> ( , ),
),
=> ( => ),
=> ( => ),
),
),
=> . ,
=> {
( );
},
=> (
=> ,
=> ,
=> ,
=> ,
=> ,
=> ( , ),
=> ,
=> (
=> ,
=> ,
=> ,
),
),
)
);
}
{
->(
,
(
=> ( , ),
=> ( , ),
=> ,
=> ( ),
=> ( ),
)
);
}
{
= ?? ();
= () ( [] ?? );
( ! ) {
( , ( , ) );
}
= ::( );
( ( ) ) {
;
}
}
The unified agent REST entry will include capabilities: [ 'stage' ] and available_in_ai_stage: true when the ability is registered and stage-eligible.
Boilerplate: Discovery-Only
Use this when the agent only surfaces prompts on the landing page.
<?php
declare( strict_types=1 );
namespace WorkflowDiscovery{PascalName};
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'vip_workflow_register_discovery_providers', __NAMESPACE__ . '\register' );
function register( $registry ): void {
$registry->register( '{provider-slug}', array(
'label' => __( '{Display Name}', 'workflow-discovery-{name}' ),
'description' => __( '{One-line description of what this provider surfaces.}', 'workflow-discovery-{name}' ),
'icon' => 'search',
'features' => array( 'recommend', 'search' ),
'callbacks' => array(
'recommend' => __NAMESPACE__ . ,
=> . ,
=> . ,
=> . ,
),
) );
}
{
= ( , ( => ) );
( ( ) || ( ) !== ) {
();
}
= ( ( ), );
= [] ?? ();
( . , ( , , ) );
}
{
= [] ?? ;
= [] ?? ();
= ( ( => ), );
= ( , ( => ) );
( ( ) || ( ) !== ) {
();
}
= ( ( ), );
= [] ?? ();
( . , );
}
{
(
(
=> ,
=> ( , ),
=> ,
=> (
( => , => ),
),
),
);
}
{
= ( [] );
( ! ( [] ) ) {
[] = [];
}
( ! ( [] ) ) {
[] = ( , ( , ( [] ) ) );
}
( ! ( [] ) ) {
[] = ( , ( , [] ) );
}
( , );
}
{
(
=> . ( [] ?? ),
=> ,
=> [] ?? ,
=> [] ?? ,
=> [] ?? ,
=> [] ?? ,
=> [] ?? ,
=> [] ?? (),
=> [] ?? ,
=> [] ?? (),
);
}
Story Prompt Shape
Every prompt must include:
id — provider-namespaced unique identifier (e.g., foresight-703721)
provider — the provider slug
title — display title
Recommended: description, url, date, date_end, tags, importance, meta.
importance is provider-defined. The UI renders badges based on it (key_event, top_story, normal) but does not enforce a universal scale. meta is freeform and provider-specific — use it for structured data your seed generation or settings UI needs (event types, regions, contacts, embargo info, etc.).
Boilerplate: Research + Discovery
Use this when one plugin provides both capabilities (e.g., Qwoted surfaces journalist requests as prompts and finds expert sources during research).
Register both independently, then declare a unified agent manifest so the Agents tab renders a single card covering both with a shared label, icon, description, and settings form.
<?php
declare( strict_types=1 );
namespace Workflow{PascalName};
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'wp_abilities_api_init', __NAMESPACE__ . '\register_ability' );
function register_ability(): void {
if ( ! function_exists( 'vip_workflow_register_ability' ) ) {
return;
}
vip_workflow_register_ability(
'workflow-{name}/{ability-slug}',
array(
'label' => __( '{Display Name}', 'workflow-{name}' ),
'description' => __( '{Research description.}', 'workflow-{name}' ),
'category' => 'research',
// ... input_schema, output_schema, execute_callback, permission_callback
'meta' => array(
'type' => 'research',
=> ,
=> ,
),
)
);
}
( , . );
{
->( , (
=> ( , ),
=> ,
=> ( , ),
=> (
=> . ,
=> . ,
=> . ,
=> . ,
),
) );
}
( , . );
{
->( , (
=> ( , ),
=> ( , ),
=> ,
=> ( ),
=> ( ),
=> (
=> (
=> ,
=> ,
=> ,
=> ,
),
),
) );
}
Without a manifest, the two registrations would show up as two separate cards. The manifest is only needed when one plugin spans both capabilities. Single-capability plugins are auto-detected and need no manifest.
Availability
An agent that needs configuration declares an availability_callback — in meta for an ability, as a top-level key for a discovery provider. Write it against the structured shape from the start: returning a bare false gets the card a generic "required settings are not configured" line that names nothing and links nowhere.
Return either:
| Return value | Meaning |
|---|
true | Dependencies are met. Return this the moment they are — including when only one member of an any group is satisfied. |
VIPWorkflow\Abilities\Availability::unmet( ... ) | Dependencies are not met, carrying the unmet requirements. |
false | Legacy shape. Still supported and silent, but the card can only render the generic line. |
The callback owns satisfaction. It is the only code with credential access, so nothing downstream re-derives it: a requirement group contains only unmet members, and Availability::unmet() is never returned for a dependency that is actually satisfied.
Build requirements with RequirementFactory, not by hand. Hand-construction means supplying an id, a kind, two message registers, source attribution, and a destination; the factory derives all of it, and resolves the destination against the install rather than hardcoding a screen that may not exist. Construct a Requirement directly only when no factory fits — for example a key read from your own wp-config.php constant, which needs Destination::none() naming that constant.
| Factory method | Use when |
|---|
in_card( $id, $admin_reason, $user_message, $hint, $sources ) | The key is entered in the agent's own card fields (settings_schema). This is the usual case for a third-party agent. |
missing_credential( $service, $service_label, $sources ) | The key is one the plugin reads through VIPWorkflow\AI\Credentials. Derives everything from the service slug, and resolves to Settings → Connectors or to the wp-config.php constant name depending on the install. |
dependency( $id, $admin_reason, $user_message, $sources ) | A prerequisite other than a credential is missing (e.g. no provider is registered). |
unsupported_environment( $id, $admin_reason, $user_message, $sources ) | The environment cannot support the capability, so there is nothing to configure. |
Group members with RequirementGroup::all() when every one is needed, or RequirementGroup::any() when one is enough — an any group renders as a single "configure at least one of" block rather than N separate blockers.
use VIPWorkflow\Abilities\Availability;
use VIPWorkflow\Abilities\RequirementFactory;
use VIPWorkflow\Abilities\RequirementGroup;
function check_availability(): bool|Availability {
if ( is_configured() ) {
return true;
}
return Availability::unmet(
RequirementGroup::all(
RequirementFactory::in_card(
'settings:workflow-{name}',
__( '{Display Name} sign-in details are missing. Add the API key below.', 'workflow-{name}' ),
__( '{Display Name} is not connected. Ask an administrator to connect it.', 'workflow-{name}' ),
__( 'Complete the API key field below.', 'workflow-{name}' ),
array( __( '{Display Name}', ) )
)
)
);
}
{
= ::();
= ->( );
! ( [] );
}
Two message registers, and you must supply both. Agent execution is gated on edit_posts, while the Agents screen and Settings → Connectors both require manage_options. The $admin_reason may name a screen; the $user_message must not — it is what an editor sees mid-workflow, and pointing them at a page they cannot open is a dead instruction. Which register is emitted is decided at the read boundary, not by you.
Note the separation the Agents card preserves: enabled is an admin preference, available is whether dependencies are satisfied. Returning false from availability_callback does not disable an agent, and an admin disabling an agent does not make it unavailable.
Settings UI (Optional)
If the agent needs configuration, there are two paths:
Schema-based (recommended). Declare settings_schema — in the ability meta for research-only, in the provider config for discovery-only, or in the unified manifest for both. The Agents tab auto-renders the form via SchemaSettings. No JS needed.
Fully custom React UI. Inject a component via the unified JS filter:
import { addFilter } from '@wordpress/hooks';
addFilter(
'vipWorkflow.assistantSettings',
'workflow-{name}',
( component, assistant, { disabled, onHasChangesChange, onSaveRef } ) => {
if ( ! assistant.ability_ids?.includes( 'workflow-{name}/{slug}' ) ) {
return component;
}
return (
<SettingsForm
assistant={ assistant }
disabled={ disabled }
onHasChangesChange={ onHasChangesChange }
onSaveRef={ onSaveRef }
/>
);
}
);
The filter receives the full unified agent entry (slug, capabilities, ability_ids, provider_slugs, options, settings_schema, …) plus a callbacks object. The card always passes that object, on every path, so destructure it directly — it is never undefined. It carries three members:
disabled (bool) — the agent is switched off.
onHasChangesChange( bool ) — drives the Save button's enabled state.
onSaveRef( fn ) — registers a handler invoked when the user clicks Save.
You must honor disabled. Everything your component renders describes how the agent behaves when it runs, so a switched-off agent offers none of it: pass disabled to every control, and never report true through onHasChangesChange while it is set. The card can only switch off the controls it renders itself, and your component replaces those outright — a component that ignores the flag lets a reader configure and save an agent that never runs, which is exactly the bug the flag exists to close. The Enabled toggle stays live; it is the way back.
Settings persist via POST /vip-workflow/v1/assistants/{slug}/settings with { enabled?: bool, options?: object }. The registry writes through to the underlying legacy options (vip_workflow_ability_settings[ability_id], vip_discovery_provider_settings, vip_discovery_provider_{slug}), so existing consumers keep working unchanged.
The legacy vipWorkflow.assistantSettingsComponent (research) and vip_workflow_discovery_provider_settings (discovery) filters are still honored for backward compatibility, but new code should always use vipWorkflow.assistantSettings. Both carry the same obligation: the legacy assistants filter receives the same three-member callbacks object, and the legacy discovery filter returns a component type that the card renders with a disabled prop alongside providerSlug.
Caching
Cache external API responses server-side using WordPress transients:
- Research search results: 5–15 min TTL (keyed by query)
- Discovery recommendations: 15–30 min TTL
- Discovery search results: 5–10 min TTL (keyed by query + filters hash)
- Filter definitions: 24 hour TTL (taxonomy data changes rarely)
- Auth tokens: cache until expiry minus a buffer
Registration Rules
Research ability:
- Ability name must be namespaced:
plugin-slug/ability-slug
category must be 'research'
meta.type must be 'research'
meta.show_in_rest must be true
- Hook into
wp_abilities_api_init (not init)
- Guard with
function_exists( 'vip_workflow_register_ability' ) so the plugin degrades gracefully
Discovery provider:
- Provider slug must be unique across all plugins
features must contain 'recommend' and/or 'search'
- If
recommend is declared, the recommend callback must be callable
- If
search is declared, both search and filters callbacks must be callable
- The
seed callback is always required
- Hook into
vip_workflow_register_discovery_providers (receives the registry instance)
availability_callback is optional; if omitted the provider is always considered available. When present it should return true or an Availability — see Availability
Unified manifest (multi-capability plugins):
slug must match the plugin's directory/text-domain slug
ability_ids must reference abilities the plugin actually registers
provider_slugs must reference providers the plugin actually registers
capabilities may include 'stage' when the plugin has at least one registered stage-eligible ability; the registry ignores unsupported manifest claims
- Hook into
vip_workflow_register_assistant_meta (receives the registry instance)
Stage ability:
- Ability name must be namespaced:
plugin-slug/ability-slug
category should be 'vip-workflow'
meta.type must be 'agent'
meta.supports must include both 'workflow' and 'stage'
meta.stage_eligible must be true
input_schema must require post_id
output_schema must require status and summary, with status limited to pass and fail
- Hook into
vip_workflow_register_abilities so core stage helpers are already loaded
- Register a manifest on
vip_workflow_register_assistant_meta with capabilities => array( 'stage' ) so the Agents card is marked available in AI stages when the ability metadata also qualifies
- Read-only agents should declare
annotations.readonly => true; mutating agents must write through StageAgent::write_content() (rewrite the body) or StageAgent::write_block_notes() (attach native block notes), and declare annotations.readonly => false
Testing
- Place the plugin directory alongside
vip-workflow/.
- Activate it in WordPress admin.
- Go to Integrations > Agents to verify it appears as a single card and can be enabled.
- Configure any settings, then:
- Research: Create a new ideation project and confirm the agent runs on the mood board.
- Stage: Open a Sequence, select a stage node, and confirm the agent appears in the inspector's Agent picker; choosing it turns the node purple and gives it the three outcome handles.
- Stage: Drag each outcome handle onto a destination stage, then save and reload the Sequence to confirm
agent.ability_id, agent.settings, and agent.routing persist.
- Stage: Transition a post into the AI-owned stage and confirm the runner stores the result and routes by
pass, fail, or error.
- Discovery (recommend): Visit the Ideation landing page and confirm curated prompts appear.
- Discovery (search): Click "Browse more…" to open the search modal and test queries + filters.
- Discovery (prompt selection): Click a prompt and confirm a new project is created with the expected seed text.