The moment an agent can create content, someone will eventually ask it to create a lot of content, fast. This lesson is about making sure “fast” never means “live without a human looking at it first.” The pattern is simple to state and easy to get wrong in the details: agents create drafts, humans publish.
A registered ability from Course 2 that creates a post, plus a connected client from one of the earlier lessons in this course. You should already understand permission_callback and capability checks from Course 2, this lesson builds a second, independent layer on top of that, not a replacement for it.
Step 1: don’t expose a status parameter to the model at all
The most common mistake is registering a “create post” ability with a status field
in its input schema, reasoning that the agent can just be told to always pass
"draft". Don’t rely on that. Models follow instructions well most of the time, not
all of the time, and a system prompt or a confused multi-step request can result in
"publish" slipping through. The fix is structural: don’t give the model the option.
// File: inc/abilities/publishing.php
wp_register_ability( 'my-plugin/create-draft-post', array(
'label' => 'Create a draft blog post',
'description' => 'Creates a new post in draft status from a title and body. '
. 'Posts created through this tool are never published automatically, '
. 'a human must review and publish them from wp-admin.',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'title' => array( 'type' => 'string' ),
'body' => array( 'type' => 'string' ),
),
'required' => array( 'title', 'body' ),
// Notice: no "status" property here at all.
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
'status' => array( 'type' => 'string' ),
'edit_url' => array( 'type' => 'string' ),
),
),
'execute_callback' => 'my_plugin_create_draft_post',
'permission_callback' => 'my_plugin_can_edit_posts',
'meta' => array( 'mcp' => array( 'public' => true ) ),
) );
// File: inc/abilities/publishing.php (continued)
function my_plugin_create_draft_post( $input ) {
$post_id = wp_insert_post( array(
'post_title' => sanitize_text_field( $input['title'] ),
'post_content' => wp_kses_post( $input['body'] ),
'post_status' => 'draft', // Hardcoded. Not derived from $input.
'post_type' => 'post',
), true );
if ( is_wp_error( $post_id ) ) {
return $post_id;
}
return array(
'post_id' => $post_id,
'status' => 'draft',
'edit_url' => get_edit_post_link( $post_id, 'raw' ),
);
}
The post_status value comes from a literal string in your PHP, not from anything
the model supplied. Even if a client somehow sent a status field in its call
(clients can send extra properties beyond what a schema requires), your
execute_callback never reads it. This is the layer that actually enforces the rule,
the ability description is documentation for the model, the hardcoded status is the
guarantee for you.
Step 2: if you need a “publish” ability, make it a distinct, separate ability
Don’t add a publish boolean to the same ability. If publishing directly through an
agent is something you actually want to support, register it as its own ability, with
its own permission_callback requiring a higher capability (publish_posts rather
than just edit_posts), and its own explicit description making clear this one goes
live immediately.
// File: inc/abilities/publishing.php (continued)
wp_register_ability( 'my-plugin/publish-existing-draft', array(
'label' => 'Publish an existing draft post',
'description' => 'Publishes a post that is currently in draft status, making it '
. 'publicly visible immediately. Only use this after the user has explicitly '
. 'confirmed they want this specific post published.',
'input_schema' => array(
'type' => 'object',
'properties' => array( 'post_id' => array( 'type' => 'integer' ) ),
'required' => array( 'post_id' ),
),
'execute_callback' => 'my_plugin_publish_draft',
'permission_callback' => 'my_plugin_can_publish_posts', // stricter than edit_posts
'meta' => array( 'mcp' => array( 'public' => true ) ),
) );
Splitting these into two abilities does real work beyond code cleanliness: it means whoever manages your site’s user roles can grant “create drafts” to a broad set of users or an automation account, while keeping “publish” restricted to a much smaller set of trusted humans, entirely through the WordPress capability system you already understand.
Step 3: build a review surface for what agents have drafted
Draft-only is safe but not useful on its own if nobody ever looks at the queue. This doesn’t need to be complicated, a saved view or a small admin page listing drafts created by your agent’s Application Password user is enough.
// File: inc/admin/agent-drafts-list.php
function my_plugin_get_agent_drafts() {
return get_posts( array(
'post_status' => 'draft',
'author_name' => 'agent-assistant', // the Application Password's user
'numberposts' => 20,
'orderby' => 'date',
'order' => 'DESC',
) );
}
Surface that list somewhere a human actually checks, a dashboard widget, a weekly digest email, or just a bookmarked wp-admin filter on Posts by that author. The point is closing the loop: an agent creating drafts nobody reviews is functionally the same risk as an agent that publishes directly, the review step just never happens.
Test it
Ask your connected client to create several draft posts, including at least one attempt where you explicitly ask it to publish directly, to confirm the boundary holds.
You: "Draft a short post about our new store hours."
You: "Now publish that post."
Draft-only status is a content workflow safeguard, not an access control mechanism.
An agent running under a compromised or overly broad Application Password can still
draft junk content at scale, read data it shouldn’t, or worse, if its
permission_callback isn’t scoped correctly. This lesson’s pattern and Course 2’s
capability checks solve different problems, you need both.
Recap
Safe agent publishing comes down to two structural choices: never let the model
control a post’s status through an input parameter, hardcode draft status inside
execute_callback instead, and if you want agent-driven publishing at all, make it a
separate, more tightly permissioned ability rather than a flag on the same one. Pair
that with an actual review surface so drafts get looked at, not just accumulated.
Resources & further reading
- Abilities API reference, developer.wordpress.org
- wp_insert_post() function reference, developer.wordpress.org
- MCP Adapter repository, GitHub