Build Real Assistants

Letting Agents Create and Publish Content Safely

⏱ 15 min

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.

What you'll learn in this lesson
Why draft-only should be the default, not a setting
The difference between a status parameter with a default and a status parameter the agent can override.
Enforcing draft status inside execute_callback
Not trusting the model to pass the right status argument.
Building a lightweight review workflow
Giving a human an easy way to see and act on what an agent has drafted.
What "safe" doesn't mean
Draft-only isn't a substitute for permission checks or capability scoping, it's an additional layer.
Prerequisites

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."
What a correctly scoped setup does here
The first request creates a draft
Confirmed by the returned edit_url and status: "draft" in the tool result.
The second request either fails or requires a separate, explicit tool
If you didn't register a publish ability, the model should report it can't do that, not attempt a workaround.
wp-admin confirms the post is still a draft
The real source of truth, not the client's chat transcript.
A draft-only default doesn't replace permission checks

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

← Building a Conversational WordPress Admin Assistant Designing Confirmation-Gated (Human-in-the-Loop) Agent Actions →