Skip to content

example agents

github-actions[bot] edited this page Sep 24, 2026 · 4 revisions

AI Agents — extend and invoke from a plugin

Status: Experimental. The whole module sits behind the agents extended option (OpenStation Preferences → Features → Extended options, admin-only). While the flag is off none of these hooks or routes exist — the one thing that stays is the WP Explorer Agents section, which is always listed and renders read-only, with a way into the Features tab for admins.

An agent is a login-blocked wp_users row whose definition (description, system prompt, ability allowlist, triggers, model override, rate limit) lives as user meta on that row. Full contract: Hooks Reference — AI Agents.

Alongside the definition an agent carries two identity fields, which is what makes a roster of them readable rather than a list of settings:

Field What it is
vibes One short line of voice, capped at 120 characters. Appended to the instructions before a run, and after them so a workflow beats a personality.
face A partial Mio look. Rendered to an SVG on disk and served as the agent's avatar everywhere get_avatar() runs, including the wp-admin Users list and comment attribution. See Mio is a species.
faceSeed The seed the face was rolled from. Provenance, not the face itself.

Both travel through createAgent / updateAgent like any other field. Abilities and triggers both go in the create call. There is no need for a second request to attach either.

Give agents a new tool

Agents pick their tools from the WordPress Abilities API — register an ability and it appears in every agent's Tools picker automatically:

add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/count-drafts',
		array(
			'label'               => __( 'Count drafts', 'my-plugin' ),
			'description'         => 'Count the current draft posts.',
			'category'            => 'my-plugin',
			'input_schema'        => array( 'type' => 'object', 'properties' => array() ),
			'output_schema'       => array(
				'type'       => 'object',
				'properties' => array( 'drafts' => array( 'type' => 'integer' ) ),
			),
			'execute_callback'    => function () {
				return array( 'drafts' => (int) wp_count_posts()->draft );
			},
			// Evaluated against the AGENT user during a run.
			'permission_callback' => function () {
				return current_user_can( 'edit_posts' );
			},
			// Truthful annotation — drives the read-only badge in the
			// picker (and offers the ability to the AI Copilot too).
			'meta'                => array(
				'annotations' => array( 'readonly' => true ),
			),
		)
	);
} );

The agent runs the tool as itself: the permission_callback sees the agent's role, so an agent whose role lacks edit_posts cannot call this even when it is on the allowlist.

Invoke an agent server-side

$agents = openstation_agent_get_agents();
if ( $agents ) {
	$result = openstation_agent_invoke(
		$agents[0]->ID,
		'Summarize the last comment on the site.',
		array(
			'source'  => 'my-plugin/summary',
			// The human behind the run: it is capped at their capabilities.
			'invoker' => get_current_user_id(),
		)
	);
	if ( ! is_wp_error( $result ) ) {
		// $result = array( 'text' => ..., 'toolCalls' => [...], 'turns' => N )
	}
}

invoker defaults to the current user, which is right inside a request. Pass it whenever a person asked for the run, and when the run happens later (a cron event, a queued job) pass the id you stored for them, because get_current_user_id() is 0 there. A run with no invoker (cron, a hook, WP-CLI with no --user) is a system run: nothing caps it, so the agent acts with its full role. Read A run is ceilinged at the invoker's capabilities before you wire one up.

Every successful run fires openstation_agent_completed with the same result plus your context array.

Audit every definition change

User meta has no revisions — these actions are the audit trail:

add_action( 'openstation_agent_updated', function ( $agent_id, $changed, $actor_id ) {
	foreach ( $changed as $field => $delta ) {
		my_plugin_audit_log(
			sprintf(
				'Agent #%d %s changed by #%d: %s -> %s',
				$agent_id,
				$field,
				$actor_id,
				wp_json_encode( $delta['from'] ),
				wp_json_encode( $delta['to'] )
			)
		);
	}
}, 10, 3 );

openstation_agent_created and openstation_agent_deleted complete the set.

Declare a custom trigger kind

Trigger configuration is stored per-agent now; intakes beyond chat, Send to and Drag & drop arrive in later phases. Declaring a kind gives it a card in the Triggers pane and the create flow's Summon step: an On switch, or the entity-kind checkboxes when its config_schema has an entityKinds property. Set 'wired' => false to keep it out of the UI until your intake exists; stored rows survive either way.

add_filter( 'openstation_agent_trigger_kinds', function ( $kinds ) {
	$kinds[] = array(
		'slug'          => 'my-plugin-webhook',
		'label'         => __( 'My webhook', 'my-plugin' ),
		'description'   => __( 'Run when my-plugin receives a webhook.', 'my-plugin' ),
		'icon'          => 'dashicons-rest-api',
		'config_schema' => array(
			'type'       => 'object',
			'properties' => array(
				'event' => array( 'type' => 'string' ),
			),
		),
	);
	return $kinds;
} );

Read it back with openstation_agent_get_triggers( $agent_id ) and wire your own intake to openstation_agent_invoke().

Open a chat with an agent from JS

wp.os.whenReady( () => {
	const store = wp.os.createSharedStore(
		'desktop-mode/agents-chat',
		() => ( { activeAgent: null, transcripts: {} } ),
	);
	store.state.activeAgent = {
		id: 12,
		name: 'Audit Agent',
		description: 'Audits drafts.',
		avatarUrl: '',
	};
	store.notify();
	wp.os.openWindow( 'desktop-mode-agent-run', { source: 'my-plugin' } );
} );

Hand the agent an object

A transcript row may carry an attachment — the entity the message is about. The chat renders it as a card instead of the message text, and clicking the card opens that object's admin screen in its own window. The drag-drop and "Send to" intakes both set it; a plugin pushing its own row can too:

store.state.transcripts[ 12 ].push( {
	role: 'user',
	// The prose is what the RUNNER reads — keep it explicit.
	text: 'Review the post "Hello world" (id 188).',
	at: Date.now(),
	// The card is what the USER sees.
	attachment: { kind: 'post', id: 188, title: 'Hello world' },
} );
store.notify();

kind is one of post, page, media, user, comment. The attachment is persisted with the conversation, so a reopened transcript still shows the card.

Safety knobs

// Tighten who may invoke agents (default: edit_posts).
add_filter( 'openstation_agents_user_can_invoke', function () {
	return current_user_can( 'manage_options' );
} );

// Platform-wide default rate limit (default: 60 runs/hour/agent).
add_filter( 'openstation_agent_default_rate_limit', fn () => 10 );

// Redact tool output before it re-enters the model context.
add_filter( 'openstation_agent_tool_result', function ( $output, $slug ) {
	if ( is_array( $output ) ) {
		unset( $output['user_email'] );
	}
	return $output;
}, 10, 2 );

Queue work from a chat or another client

Keep the returned jobId until the answer arrives. The same UUID and input can be submitted again if the submission response is lost; WordPress returns the existing job rather than repeating its abilities.

const requestId = crypto.randomUUID();
const path = `${restRoot}desktop-mode/v1/agents/${agentId}`;
const response = await wp.os.fetch(`${path}/invoke`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': restNonce },
    body: JSON.stringify({ message: 'Review this post.', async: true, requestId })
});
const job = await response.json(); // HTTP 202: { jobId, status, pollAfter, … }
// After at least job.pollAfter seconds, read status. Await each request before
// scheduling another; stop at completed/failed and back off on network errors.
const statusResponse = await wp.os.fetch(`${path}/jobs/${job.jobId}`, {
    headers: { 'X-WP-Nonce': restNonce }, cache: 'no-store'
}, { source: 'my-plugin/agent-status', silent: true });
const status = await statusResponse.json();
// status.result carries the existing invocation result when completed.

These endpoints require the invoking user's authentication. Jobs preserve the human's capability ceiling, even when a cron worker starts without a session. See async agent jobs for scheduling, retention and host requirements.

Home

Guides

Migration notes

More

Examples

All examples

Clone this wiki locally