Skip to content

Latest commit

 

History

History
517 lines (370 loc) · 50.4 KB

channels-workflows-operations.md

File metadata and controls

517 lines (370 loc) · 50.4 KB

Channels, Workflows, and Operations

This page documents the external-client, workflow, routine, transcript, approval, and operational contracts that let consumers connect Agents API to real transports and background execution while keeping product behavior outside the substrate.

Source evidence: src/Channels/*, src/Workflows/*, src/Routines/*, src/Transcripts/*, src/Approvals/*, existing docs in docs/external-clients.md, docs/bridge-authorization.md, docs/remote-bridge-protocol.md, tests/channels-smoke.php, tests/remote-bridge-smoke.php, tests/webhook-safety-smoke.php, tests/workflow-*.php, tests/agents-workflow-ability-smoke.php, tests/routine-smoke.php, tests/approval-*.php, and tests/conversation-transcript-lock-smoke.php.

External clients and direct channels

AgentsAPI\AI\Channels\WP_Agent_Channel is the abstract base class for WordPress-hosted messaging channel plugins. It maps external surfaces such as Slack, Telegram, Discord, WhatsApp, email, or local relays onto the canonical chat ability contract.

The job-side channel pipeline is:

validate inbound payload
-> extract message text
-> look up external conversation session
-> on_processing_start()
-> run agents/chat ability
-> on_processing_end()
-> persist returned session_id
-> deliver assistant replies or errors
-> on_complete()

Subclasses provide transport identity and I/O:

Method Responsibility
get_external_id_provider() Stable provider/channel id such as slack or telegram_bot.
get_external_id() External conversation/thread id, or null when isolation is unavailable.
get_client_name() Human-readable lowercase client name for context.
extract_message( array $data ) Convert transport payload into user text.
send_response( string $text ) Deliver assistant text.
send_error( string $text ) Deliver processing errors.
get_job_action() Async job hook used by receive().

Optional overrides include validate(), on_processing_start(), on_processing_end(), on_complete(), extract_attachments(), extract_external_message_id(), get_room_kind(), get_connector_id(), client_context_source(), and send_notification().

WP_Agent_Channel::SILENT_SKIP_CODE lets validation drop well-formed but intentionally ignored events, such as self messages or allow-list misses, without sending a user-facing error.

Canonical chat payload

build_chat_payload() dispatches to the ability selected by the wp_agent_channel_chat_ability filter, defaulting to AGENTS_CHAT_ABILITY / agents/chat.

Representative input:

array(
	'agent'          => 'example-agent',
	'message'        => 'Hello from Slack',
	'session_id'     => 'previous-session-id-or-null',
	'attachments'    => array(),
	'client_context' => array(
		'source'                       => 'channel',
		'connector_id'                 => 'slack',
		'client_name'                  => 'slack',
		'external_provider'            => 'slack',
		'external_conversation_id'     => 'opaque-thread-id',
		'external_message_id'          => 'opaque-message-id',
		'room_kind'                    => 'dm',
	),
)

The channel can consume either a single reply or assistant messages from the ability result. It stores a returned session_id before sending replies so delivery failures do not lose session continuity. agents/chat also exposes a canonical run_id; runtimes receive a generated run_id in the input when callers omit one, and responses include that same ID unless the runtime returns its own.

Agents API registers a default agents/chat runtime as a fallback at wp_agent_chat_handler priority 1000. The fallback resolves provider and model from the chat input first, then from the registered agent's default config, and returns a validation error when either value is missing. Consumer runtimes normally register their own handler at the default priority 10, so they win before the fallback runs.

Chat run control

Agents API owns the generic run-control ability contracts and default run-control storage. Every agents/chat run receives a run_id, stores status when a session is known, accepts best-effort cancellation, and accepts queued follow-up messages. Runtimes may override the hooks below only when they can provide stronger behavior such as immediate provider aborts or a custom queue worker.

Ability Purpose Runtime hook
agents/get-chat-run Return status for a known chat run. wp_agent_chat_run_status_handler
agents/cancel-chat-run Request best-effort cancellation for a known chat run. wp_agent_chat_run_cancel_handler
agents/list-chat-run-events Return lifecycle/event pages for a known chat run. wp_agent_chat_run_events_handler
agents/queue-chat-message Accept a next user message while a session has an active run. wp_agent_chat_message_queue_handler

Clients can expose status, Stop, and Queue controls whenever these canonical abilities are available and the caller has permission for the selected agent. The default handlers preserve safe behavior for synchronous runtimes; runtime-specific handlers are enhancements, not prerequisites.

Run status vocabulary is bounded to queued, running, cancelling, cancelled, completed, failed, runtime_tool_pending, approval_required, budget_exceeded, stalled, and interrupted. The canonical run payload is:

array(
	'run_id'     => 'run_opaque',
	'session_id' => 'session_opaque',
	'status'     => 'running',
	'started_at' => '2026-01-01T00:00:00Z',
	'updated_at' => '2026-01-01T00:00:01Z',
	'metadata'   => array(
		'orchestration' => array(
			'provider'     => 'external-provider-id',
			'run_id'       => 'provider-run-id',
			'event_cursor' => 'provider-event-cursor',
		),
	),
)

The status, events, and cancel hooks are the generic external durable-run adapter contract. A host can back them with any durable run provider by returning the canonical payloads above; Agents API normalizes status values, validates required identifiers, applies the existing permission callbacks, and keeps provider-specific state inside metadata. The canonical metadata object is metadata['orchestration'] with provider for the adapter/provider id, run_id for the durable provider run id, and event_cursor for the provider cursor associated with the latest status or event page. These keys are intentionally generic and do not imply a specific runner, queue, or product.

Event handlers return the same run fields plus an events list, cursor, and has_more. Each event uses id, type, created_at, optional message, and opaque metadata. The returned cursor is the client polling cursor for the next agents/list-chat-run-events call; adapters can mirror a provider-native cursor in metadata['orchestration']['event_cursor'] when the provider cursor differs from the public cursor.

External durable-run bridge

An external orchestration bridge should call its own in-process adapter or service client for its provider-native durable run status, then return the canonical Agents API run-control shape. Agents API does not shell out to an orchestrator and does not require any orchestrator package dependency; the bridge is just a handler behind wp_agent_chat_run_status_handler and wp_agent_chat_run_events_handler.

Mapping guidance:

External status field Agents API field
run.state status, mapped into the bounded Agents API vocabulary: queued, running, cancelling, cancelled, completed, failed, stalled, or interrupted.
run.id metadata.orchestration.run_id; keep the client-addressable chat run_id unchanged at the top level.
totals metadata.orchestration.totals.
latest_event_cursor cursor on event pages and metadata.orchestration.event_cursor on status/event payloads.
normalized_events events, with each item using canonical id, type, created_at, message, and metadata.
artifact_refs metadata.orchestration.artifact_refs.

Example provider-native input shape:

array(
	'schema'              => 'example-orchestrator/run-status/v1',
	'run'                 => array(
		'id'         => 'external-run-1',
		'state'      => 'succeeded',
		'started_at' => '2026-06-25T12:00:00Z',
		'updated_at' => '2026-06-25T12:03:00Z',
	),
	'totals'              => array(
		'events'    => 2,
		'artifacts' => 1,
		'errors'    => 0,
	),
	'latest_event_cursor' => 'provider-cursor-2',
	'normalized_events'   => array(
		array(
			'id'         => 'provider-event-2',
			'cursor'     => 'provider-cursor-2',
			'type'       => 'artifact',
			'created_at' => '2026-06-25T12:03:00Z',
			'message'    => 'Recorded transcript artifact.',
		),
	),
	'artifact_refs'       => array(
		array(
			'id'    => 'artifact-transcript',
			'type'  => 'transcript',
			'label' => 'Transcript',
		),
	),
)

Canonical agents/get-chat-run output:

array(
	'run_id'     => 'run-chat-1',
	'session_id' => 'session-external-1',
	'status'     => 'completed',
	'started_at' => '2026-06-25T12:00:00Z',
	'updated_at' => '2026-06-25T12:03:00Z',
	'metadata'   => array(
		'orchestration' => array(
			'provider'      => 'example-orchestrator',
			'schema'        => 'example-orchestrator/run-status/v1',
			'run_id'        => 'external-run-1',
			'state'         => 'succeeded',
			'event_cursor'  => 'provider-cursor-2',
			'totals'        => array( 'events' => 2, 'artifacts' => 1, 'errors' => 0 ),
			'artifact_refs' => array(
				array( 'id' => 'artifact-transcript', 'type' => 'transcript', 'label' => 'Transcript' ),
			),
		),
	),
)

Canonical agents/list-chat-run-events output uses the same run fields, returns provider events mapped into the canonical event shape, sets cursor to the latest provider cursor, and repeats that cursor in metadata.orchestration.event_cursor when useful. Cancellation stays handler-owned: register wp_agent_chat_run_cancel_handler only when the bridge can request cancellation through its durable provider; otherwise Agents API keeps the default local cancellation behavior.

Cancellation is best-effort. A runtime that can abort provider work immediately may do so; a runtime that cannot should mark the run cancelling and let its conversation loop stop at the next interrupt check. WP_Agent_Chat_Run_Control::cancellation_interrupt_message() builds the message shape expected by WP_Agent_Conversation_Loop interrupt_source callbacks.

Read abilities return observer-safe run envelopes for non-operators. Stored run state may contain runtime diagnostics, package/workflow selectors, raw refs, provenance, output, or caller metadata for audit/debugging, but agents/get-chat-run, agents/list-chat-run-events, agents/get-task-run, agents/get-runtime-package-run, and agents/list-runtime-package-run-events redact high-risk keys unless the caller passes the explicit unredacted read gate. Managers keep full access by default; hosts can extend that path with agents_chat_run_unredacted_read_permission, agents_task_unredacted_read_permission, or agents_runtime_package_run_unredacted_read_permission while leaving broad read access observer-safe.

Queued messages return the same run payload plus queued_message_id and position. Async runtimes can drain queued messages through their worker, cron, or Action Scheduler integration. Synchronous runtimes can expose queued state and require polling or an explicit continue operation in the consuming product; the substrate does not force a background runner.

Claim-bound chat run writes

When a wp_agent_chat_handler routes a turn to an out-of-process runtime, that runtime writes the run and its transcript back through the canonical claim-bound write abilities instead of a parallel contract. agents/chat reserves the run before the handler executes and injects an opaque _agents_run_claim_token into the handler input; the handler hands the token (with the run_id and session_id) to its runtime, and the runtime presents the token back as claim_token.

Ability Purpose
agents/append-chat-run-messages Append canonical message envelopes (assistant text, tool calls/results, input_required, approval_required, notices) to the session transcript. Each appended message receives a stable id.
agents/update-chat-run-message Replace one previously appended message by its stable id, so streamed text is edited in place.
agents/append-chat-run-events Append lifecycle events to the same event stream served by agents/list-chat-run-events.
agents/set-chat-run-status Transition run status (running → completed/failed/cancelled, or a suspended status such as approval_required). The response always carries cancelled, so a status heartbeat doubles as cancellation observation.
agents/claim-chat-run-input Drain queued user answers (for example to an input_required message) submitted through agents/queue-chat-message.

Authorization is bound to the run claim: the presented claim_token must match the token stored when the run was reserved, so a runtime can only write runs it claimed, for sessions it was handed. A client-supplied principal or session_owner never grants write access, and the permission callbacks never consult the current user's capabilities. Hosts can veto (never widen) the decision with the agents_chat_run_write_claim_permission filter.

To return immediately with an accepted-pending run, an external handler returns the canonical output with status => 'pending' and completed => false. The dispatcher then leaves the stored run open (running) instead of terminalizing it, and the runtime finalizes status later through agents/set-chat-run-status. A committed cancellation request still wins over a late success claim, exactly like the in-process finish path.

Session, webhook, and idempotency helpers

Shared channel primitives include:

  • WP_Agent_External_Message: normalized external message value object for text, connector, provider, conversation id, message id, sender, room kind, attachments, and raw payload.
  • WP_Agent_Channel_Session_Map: maps (connector_id, external_conversation_id, agent) to a session id.
  • WP_Agent_Channel_Session_Store and WP_Agent_Option_Channel_Session_Store: replaceable/default session storage.
  • WP_Agent_Webhook_Signature: HMAC SHA-256 signature verification helper.
  • WP_Agent_Message_Idempotency, WP_Agent_Message_Idempotency_Store, and WP_Agent_Transient_Message_Idempotency_Store: TTL-backed duplicate suppression.

Remote bridge protocol

Remote bridge clients are out-of-process processes that relay messages between an external surface and WordPress. WP_Agent_Bridge is the facade for generic queue-first delivery.

Flow:

register client
-> execute inbound chat through agents/chat
-> enqueue outbound assistant replies
-> remote client polls pending items or receives webhook delivery
-> remote client acknowledges accepted queue ids

Public facade methods:

  • set_store( ?WP_Agent_Bridge_Store $store )
  • store(): WP_Agent_Bridge_Store
  • register_client( string $client_id, ?string $callback_url = null, array $context = array(), ?string $connector_id = null ): WP_Agent_Bridge_Client
  • get_client( string $client_id ): ?WP_Agent_Bridge_Client
  • enqueue( array $args ): WP_Agent_Bridge_Queue_Item
  • pending( string $client_id, int $limit = 25, array $session_ids = array() ): array
  • ack( string $client_id, array $queue_ids ): int

Queued items remain pending until acknowledged. The default store is option-backed; hosts that need custom tables, queues, leases, retention, or multi-worker behavior replace it with set_store().

Bridge authorization is intentionally separate from queue state. Connectors can own service metadata; Agents API owns bridge runtime state and expects consumers to map credentials to client, connector, agent, workspace, and operation scopes.

Workflow specs and validation

Workflows are deterministic recipes composed of inputs, triggers, and steps. Agents API provides the spec value object, structural validator, step-type registry, in-memory workflow registry, runner, store/recorder interfaces, optional Action Scheduler bridge and branch executor, and canonical abilities. It does not ship a durable workflow runtime, editor UI, product-specific step types, or run-history database.

WP_Agent_Workflow_Spec_Validator::validate( array $spec ): array performs structural validation only:

  • id is required and non-empty;
  • inputs, when present, must be a map;
  • steps must be a non-empty list;
  • each step needs an id and type;
  • known step types and their field validation come from WP_Agent_Workflow_Step_Type_Registry (see below);
  • wp_action and cron trigger shapes are checked;
  • forward or unknown ${steps.<id>.output.*} binding references are reported.

The validator does not check whether referenced agents or abilities exist; the runner handles that at execution time.

The step-type registry

WP_Agent_Workflow_Step_Type_Registry pairs one step type name with its runner handler and its field validation contract, so a type can't validate and then fail at run time with no handler, or vice versa. Built-in types (ability, agent, foreach, parallel) are registered lazily on first access — no init hook dependency, so the registry behaves identically inside and outside a full WordPress bootstrap.

Register a step type with register_workflow_step_type( string $type, array $args ): bool:

register_workflow_step_type(
    'branch',
    array(
        'handler'  => 'my_plugin_branch_step_handler', // callable( array $resolved_step, array $context ): array|WP_Error
        'required' => array( 'condition' ),            // simple non-empty-string required-field check
        'validate' => 'my_plugin_validate_branch_step', // callable( array $step, string $path ): list<{path,code,message}>
    )
);

required covers the common case (a field must be a non-empty string); write a validate callback for anything more specific — shape checks, mutually-exclusive fields, recursion into nested steps, etc. Registering an already-registered type keeps the first registration and triggers _doing_it_wrong (or the non-WordPress equivalent) — no silent overwrite.

The runner and the reconcile branch resolver (agents_workflow_resolve_step_handlers()) both read their handler map from WP_Agent_Workflow_Step_Type_Registry::handlers(), so one registration reaches structural validation, execution, and async branch reconciliation — a consumer no longer registers two unrelated filters that can drift apart.

WP_Agent_Workflow_Step_Type_Registry::types() is the sole source of truth for known step types. The one-release back-compat filters and constant that used to sit on top of the registry's output were removed in #572; register a step type through WP_Agent_Workflow_Step_Type_Registry::register() or register_workflow_step_type().

Workflow runner

WP_Agent_Workflow_Runner executes one WP_Agent_Workflow_Spec in order:

start run record
-> validate required inputs
-> resolve bindings against inputs and prior step outputs
-> dispatch step handler
-> record per-step status/output/error/timing
-> short-circuit on failure unless continue_on_error is enabled
-> return WP_Agent_Workflow_Run_Result

Default step handlers:

  • ability: calls a registered Abilities API ability with resolved args.
  • agent: calls the canonical agents/chat ability with agent, message, and optional session_id.
  • foreach: runs nested steps once for each resolved item in process.
  • parallel: fans out either role-scoped branches or mapped items, collects branch outputs, and optionally runs one aggregator branch.

The default parallel handler uses wp_agent_workflow_step_executor as its concurrency seam. When an executor is available, it dispatches branches out of band and returns a _suspend directive so the run can resume after reconciliation. Agents API ships an Action Scheduler branch executor that is selected when as_enqueue_async_action() exists; it enqueues one async action per branch, raises branch-specific Action Scheduler concurrency while branches are in flight, and triggers loopback runners for faster drain. Without Action Scheduler or a caller-supplied executor, parallel falls back to synchronous in-process branch execution.

The Action Scheduler executor requires a store that supports concurrent writes for parallel branch execution. MySQL and MariaDB can provide that concurrency. SQLite uses a single database-wide writer lock, so Action Scheduler claims and branch execution serialize even when multiple workers are requested. SQLite remains correct for async branch completion, but it provides no parallel speedup. Consumers that require parallel execution should use MySQL or MariaDB; Agents API does not detect or warn about the active database engine at runtime.

Consumers extend the runner by registering a step type through register_workflow_step_type() (see The step-type registry), or, for a one-off handler override on a single runner instance, through the constructor's $step_handlers argument. Product-specific steps such as branch or nested workflow belong in consumers.

Recorder behavior is conservative: start() runs before input validation, per-step update() calls follow state changes, and recorder-start failure returns a failed run result without executing steps.

Awaiting suspended workflow runs

WP_Agent_Workflow_Run_Awaiter is the storage-neutral foreground await service for persisted workflow runs. Call await( string $run_id, WP_Agent_Workflow_Run_Recorder $recorder, array $options = array() ) from a foreground/orchestrator context such as a CLI command, wp-cron.php request, or explicit status poll. The recorder is always caller-owned: Agents API does not resolve an ambient workflow recorder and the awaiter does not implement storage. Pass the same recorder implementation that owns the run so the awaiter and drained branch actions can re-read live state.

The await contract is:

  • If find( $run_id ) returns null before or after a drain, the call returns WP_Error with code agents_workflow_run_not_found and does not fabricate a response.
  • If the current run is not suspended, no Action Scheduler work is claimed. The response still includes a zero-work drain shape with stop_reason: terminal_status, the current status in terminal_state, normalized hooks/group, and the scoped-drain availability flag.
  • If the run is suspended, the awaiter delegates to WP_Agent_Workflow_Scoped_Drain::drain_suspended_run(). That helper drains only the configured hook + group scope until the run leaves suspended, the scope empties, a safety budget stops the loop, Action Scheduler is unavailable, or a re-entrant/claimed-action guard refuses the drain.
  • After draining, the awaiter reloads the run from the caller recorder and returns the latest persisted status.

The await response uses schema agents-api/workflow-run-await/v1 and includes:

Field Contract
schema Versioned await response schema.
run_id The persisted run id from the reloaded result.
status Latest workflow-run status.
terminal true only for succeeded, failed, skipped, or cancelled.
reconnectable false for terminal runs; true for any nonterminal response, including a budget-exhausted or drain-refused suspended run.
result Canonical agents-api/run-result/v1 run-result envelope when terminal is true; null while nonterminal. Failed terminal results preserve the canonical error payload.
drain Scoped-drain stats: batches, actions_processed, completions, failures, remaining_pending, total_pending, warnings, stop_reason, terminal_state, comma-separated hooks, group, and available.

Supported await/drain options are forwarded to the scoped drain after validation: hooks (string list), group, batch_size, limit, time_limit_ms, time_limit, stop_before_timeout_ms, stop_before_timeout, and execution_context. Omitted or empty hooks fall back to the Action Scheduler branch executor's branch and resume hooks; an omitted group falls back to the workflow Action Scheduler group. Budget stop reasons such as limit, time_limit, timeout_margin, memory_limit, no_progress, as_unavailable, refused_reentrant, and refused_in_claimed_action are reported in drain.stop_reason rather than converted into errors, so callers can return a reconnectable polling response.

Do not call the drain from inside one of the scoped branch or resume actions. WP_Agent_Workflow_Scoped_Drain checks the live action stack and a process-level guard and refuses those contexts to avoid self-deadlocking a worker that is holding an Action Scheduler claim while trying to claim sibling actions.

The await primitive

await is the generic "park a step on an external durable job" wait (agents-api#577). It reuses the same suspend/resume model parallel already uses — a step handler returns _suspend, the runner persists a table-free frame under metadata._suspension, and the run stays suspended until something resumes it — but for a SINGLE opaque external wait instead of a dispatched branch set.

A step handler suspends with:

return array(
    '_suspend' => array(
        'kind'       => 'await',
        'wait_id'    => $opaque_external_job_id, // caller-defined, opaque to the substrate
        'timeout_at' => $unix_timestamp,          // optional
    ),
);

The runner stamps the frame with kind, wait_id, timeout_at (when given), and a freshly minted generation — an unguessable identity for this exact suspension INSTANCE, distinct from the caller-opaque wait_id. The owning runtime key (#567) rides the frame exactly like it does for parallel.

Something outside the run — a webhook handler, a scheduled poll, another workflow's terminal hook — completes the wait:

$result = AgentsAPI\AI\Workflows\agents_workflow_complete_wait(
    $runtime,   // owning runtime key; '' for legacy/unattributed runs
    $run_id,
    $wait_id,
    array(
        'status' => 'succeeded', // or 'failed'
        'output' => array( /* becomes the step's output */ ),
        'error'  => array( 'code' => '...', 'message' => '...' ), // when status is 'failed'
    )
);

or through the ability agents/complete-workflow-wait (show_in_rest false by default; permission current_user_can( 'manage_options' ), filterable with agents_workflow_complete_wait_permission, mirroring the other workflow control abilities).

Contract:

  • Exactly once. The completion is delivered under the same per-run reconcile lock parallel reconcile uses. The FIRST caller to observe the wait still open atomically claims it and splices the output/error into the suspended step's record; a second caller — a duplicate delivery, or a real completion racing its own timeout — sees the claim already consumed and returns the current run state untouched, before resume ever runs.
  • Bindings. After a successful completion, downstream steps see the output through the normal ${steps.<id>.output} bindings, exactly like any other step.
  • Wrong or unknown wait_id. A completion whose wait_id does not match the run's currently suspended wait (or whose run is not suspended on await at all) returns WP_Error, not a silent no-op.
  • failed completions. Mark the waited step failed and follow the runner's normal failure semantics — the run terminalizes failed unless continue_on_error keeps it going, exactly like a synchronous step failure.
  • Timeout. When the directive names a timeout_at and Action Scheduler is present (as_schedule_single_action()), one action is scheduled under the agents_workflow_await_timeout hook, group agents-api-run-{md5(run_id)}, carrying { run_id, wait_id, generation, runtime }. It fires the wait as failed with error code workflow_wait_timeout through the SAME exactly-once path, fenced to the exact suspension generation so a stale timeout (the run resumed and suspended again on the same wait_id) cannot touch a newer, unrelated suspension. No Action Scheduler present → no timeout is scheduled; the wait can still be completed manually or never times out.
  • Cancellation. No await-specific cancellation code exists. A cancelled run suspended on await uses the runner's existing fence unmodified: the intent stages via WP_Agent_Run_Control::request_cancel()/stage_cancellation(), and the next thing that advances the run — a real completion, or the timeout — resumes through WP_Agent_Workflow_Runner::resume(), whose step loop checks is_cancel_requested() before running the next step and terminalizes cancelled (discarding the spliced output) instead of the completion's own outcome. A later completion after that is a no-op like any other post-terminal completion.

Workflow abilities and permissions

src/Workflows/register-agents-workflow-abilities.php registers three abilities:

Ability Purpose Default permission
agents/run-workflow Dispatch a registered or inline workflow to a consumer runtime. current_user_can( 'manage_options' ), filterable with agents_run_workflow_permission.
agents/validate-workflow Structurally validate a supplied workflow spec. is_user_logged_in(), filterable with agents_validate_workflow_permission.
agents/describe-workflow Return an in-memory registered workflow spec and inputs. Same permission callback as run workflow.

agents/run-workflow routes to the runtime that owns the target workflow (#567). When the owning runtime key is identifiable — an explicit runtime input, or the registered spec's meta['runtime'] — the dispatcher looks it up in the runtime-scoped handler map registered on the wp_agent_workflow_runtime_handlers filter and calls only that entry; a matching runtime with no registered handler is a no_handler error, with no fallback to any other registered handler. register_workflow_runtime_handler( string $runtime, callable $handler, int $priority = 10 ) is the convenience helper. When no runtime is identifiable (no explicit runtime input and no registered spec meta['runtime']), the unattributed-dispatch rule applies: exactly one registered handler dispatches to it; zero registered handlers is no_handler; more than one registered handler is agents_run_workflow_ambiguous_runtime, listing the registered keys, since the dispatcher can't guess which runtime should own the run. If no handler is present or a handler returns the wrong type, the dispatcher returns WP_Error and fires agents_run_workflow_dispatch_failed for observability.

Workflow runs carry an owning runtime key end to end: the runner's runtime option is stamped onto the run (metadata._runtime) and the execution context, and every Action Scheduler continuation payload (branch, aggregate, reconcile retry, resume) carries it alongside run_id. Recorder resolution via the wp_agent_workflow_run_recorder filter receives ( $recorder, $runtime, $run_id ): a consumer returns its recorder only for runs whose $runtime it owns and null otherwise, so multiple consumers on one site can each reconcile their own suspended runs. Runs started before runtime attribution existed resolve with $runtime === ''.

Routines and scheduling

AgentsAPI\AI\Routines\WP_Agent_Routine models a persistent scheduled invocation. A routine differs from a workflow because a chat-targeted routine reuses the same conversation session across every wake, letting context accumulate.

Required shape:

  • routine id slug;
  • exactly one wake target: an agent slug or an ability slug (see Wake targets);
  • exactly one trigger: positive interval seconds or non-empty cron expression.

Optional fields include label, prompt, session_id, and meta (for chat targets), input (for ability targets). When session_id is omitted, the default is routine:<id>.

Action Scheduler bridges and listeners are optional operational adapters. The substrate detects Action Scheduler at runtime and no-ops cleanly when absent; composer.json suggests woocommerce/action-scheduler for scheduled workflow/routine execution.

Wake targets

A routine's wake target is what the scheduled listener dispatches when the schedule fires. WP_Agent_Routine::get_target_type() reports WP_Agent_Routine::TARGET_CHAT (the default) or WP_Agent_Routine::TARGET_ABILITY:

  • agent (chat target) — sends the routine's prompt to the named agent through the canonical agents/chat ability, reusing the routine's persistent session_id. This is today's behavior, unchanged.
  • ability (ability target) — executes the named ability directly, passing the routine's input array. No agent, prompt, or session is involved; get_agent_slug() returns an empty string and session_id/prompt stay unused.

Exactly one of agent / ability is required — providing both or neither throws. WP_Agent_Routine::to_array() always includes a target key and adds ability + input for ability targets, so operators can see what a routine does.

Permission differs by target. The scheduled invocation runs as the cron/loopback principal: the chat path lifts the agents/chat permission gate for that one invocation (existing behavior), while the ability path denies by default — there is no implicit elevation and no per-ability bypass. The listener fires apply_filters( 'wp_agent_routine_ability_permission', false, $routine, $ability ) and only executes when a consumer's filter returns true. Consumers that register ability-targeted routines opt in by filtering, typically scoped to the abilities they own.

Failure reporting is uniform: a missing ability (ability_missing), a denied permission filter (permission_denied), or a WP_Error returned by the ability (its error code) all fire agents_run_routine_dispatch_failed. Successful ability results arrive through the same wp_agent_routine_run_completed action as chat results, with the ability output as the second argument.

Routine backends

Durable scheduling sits behind a contract: WP_Agent_Routine_Backend (in src/Routines/) is the only scheduling surface the registry consumes — availability, register/repair/unregister, pause/resume, run-now, pause-state and generation reads, and pending_by_routine() / cancel() for reconcile (handles are opaque ints). register() is the cheap declaration entry point; repair() is the authoritative check-and-repair that never trusts recorded state. The registry resolves one backend per request through the wp_agent_routine_backend filter; the default is the Action Scheduler bridge (WP_Agent_Routine_Action_Scheduler_Bridge) when Action Scheduler is present, and null otherwise. With no backend, routines are still registered and their lifecycle hooks still fire, but nothing is scheduled and reconcile() reports a _scheduler error. Consumers replace the backend by filtering in their own WP_Agent_Routine_Backend implementation.

register() is idempotent and cheap to call on every boot, even for hundreds of persisted routines — and the cheap path touches Action Scheduler not at all. The bridge keeps one persisted record of what it last scheduled: a single non-autoloaded option (agents_routine_schedules) mapping routine id → ['signature' => string, 'action_id' => int], where the signature is exactly what the matcher compares — trigger type plus interval seconds or cron expression. When a declaration's signature matches its record, register() returns before issuing a single as_get_scheduled_actions() query or fetch_action() call; reading the record costs one get_option() per request, independent of routine count. Only a missing or changed signature reaches the store, serialized through a short single-flight per-routine lock (agents_routine_schedule_lock_<id>, the same shared atomic $wpdb compare-and-set as the reconcile lock — see the shared lock helper below — with a 30-second stale takeover) so two concurrent processes can never both check-then-schedule — the race that used to mint duplicate chains; a losing caller no-ops. A consumer that must re-declare all of its routines on every request (the in-memory registry does not persist across requests) no longer pays any per-routine scheduler I/O once every record matches.

Whenever a register() or reconcile call does reach Action Scheduler, it enforces one chain per routine: among the routine's pending recurring actions whose recurrence matches, exactly the lowest-id chain that is live (at or above the routine's current watermark) is kept and every other pending recurring action for that routine is cancelled by id — healing duplicate chains left by earlier versions or crashed requests. An action already claimed by an in-flight worker is not part of the pending set (Action Scheduler marks claimed actions in-progress), so a collapse never breaks a running worker. When no live matching chain exists (missing schedule, changed interval/expression, or a chain that is entirely below the current watermark), a fresh chain is scheduled, the generation/watermark fence is advanced, and the record is rewritten.

The trade-off is deliberate: plain declarations trust the record, so an out-of-band deletion (pruned store, restored backup, manually deleted row) is invisible to them. A lost chain is restored within one hour by the WP-Cron routine repair (below), and on demand by WP_Agent_Routine_Registry::reconcile() — via the backend contract's repair( $routine ) method, which never trusts recorded state.

Routine generation + watermark fencing

Every WP_Agent_Routine_Action_Scheduler_Bridge register call that actually schedules something (i.e. skips the no-op path above) mints a fresh generation (wp_generate_uuid4()) and records it, alongside the new chain's first action id as a watermark, in ONE non-autoloaded option per routine: agents_routine_generation_<routine_id> holds [ 'generation' => string, 'watermark' => int ]. Scheduled action args stay purely logical — array( 'routine_id' => ... ) — so Action Scheduler's exact-match queries keep working.

Fencing needs no per-action bookkeeping at all. Action Scheduler action ids are a global, strictly increasing sequence: every action in a chain (the initial insert and every recurrence successor AS spawns) has an id at or above the chain's own watermark, and every action from a superseded chain — including one already claimed by an in-flight worker when the supersede happened — has an id below it. action_scheduler_before_execute fires before the queue runner re-checks that the action is still pending; when the fetched action's id is below its routine's current watermark, the bridge cancels it there (firing agents_routine_action_fenced( $routine_id, $generation, $action_id ) for observability) and the runner's own status check then ignores it, so no recurrence successor is spawned. A routine with no recorded watermark yet (never re-registered this process, or a pre-fix legacy action) is never fenced.

Earlier versions stamped a generation onto every stored action individually, in a per-action option (agents_routine_action_generation_<action_id>) written on the action_scheduler_stored_action hook. That hook no longer exists: the option was only ever deleted by cancel(), and register()'s prior unconditional unschedule-and-reschedule never routed through cancel(), so the option accumulated without bound — one production site reached 302,623 orphaned rows (10 MB of wp_options) against 596 live pending actions. WP_Agent_Routine_Action_Scheduler_Bridge::purge_legacy_action_generation_options() is a one-shot cleanup (single DELETE ... LIKE query) for rows a pre-fix version already left behind; it runs at most once per site, gated by a persisted marker option checked on init rather than a plugin-activation hook (agents-api ships far more often as a Composer-embedded library inside a consumer plugin than as its own activatable plugin).

WP_Agent_Routine_Registry::current_generation( $id ) exposes the persisted routine generation; unregister() deletes the whole per-routine option (generation and watermark together).

Routine stagger

Routines registered with the same interval would all fire in the same second. WP_Agent_Routine accepts stagger => bool|int (default true for interval routines, false for cron expressions, where the expression already is the slot; an int is an explicit max window in seconds). WP_Agent_Routine::stagger_offset() computes crc32( 'agents_routine_stagger_' . $id ) % min( interval, max_window ), capped by WP_Agent_Routine::MAX_STAGGER_SECONDS (one hour). The bridge adds the offset to the first-run timestamp. The offset depends only on the routine id, so re-registration always lands the routine back in the same slot — and, since the offset only affects the first-run timestamp and never the recurrence itself, it never causes an unchanged re-register to be mistaken for a schedule mismatch.

Hourly WP-Cron routine repair

Agents API declares ONE WordPress cron event on init: hook wp_agent_routines_reconcile, recurrence hourly (in src/Routines/register-routine-bridge-sync.php, alongside the declaration listeners). The declaration is guarded by wp_next_scheduled(), so any number of init fires schedule exactly one event, and the guard itself reads the autoloaded cron option — it costs no queries per request. Firing the event runs WP_Agent_Routine_Registry::reconcile() in apply mode (the lock-protected path) and fires wp_agent_routines_reconcile_completed with the reconcile report (enqueued/removed/unchanged/errors) so consumers can log or alert on it.

This is the scheduled counterpart to the record fast path above: since declarations trust the persisted schedule record, a chain lost out of band (cancelled in the Action Scheduler admin UI, a pruned or restored table) stays lost until something authoritative runs. The repair lives in WP-Cron rather than Action Scheduler on purpose — losing Action Scheduler chains cannot also take out the mechanism that restores them — and it runs through $wpdb, not the options API, so a stale or cached-away lock row can never wedge it. There is deliberately no unschedule-on-deactivation hook and no self-repair machinery for the event itself: agents-api ships far more often as a Composer-embedded library inside a consumer plugin than as its own activatable plugin, so there is no deactivation path that would fire for most real installs, and a leftover event after removal is inert.

Routine reconcile

WP_Agent_Routine_Registry::reconcile( array $opts = [] ) repairs drift between the registry and the Action Scheduler store. For every registered, non-paused routine it calls the backend's authoritative repair(): pending-action coverage is verified against the real store by logical identity (never against the schedule record), duplicate matching chains are collapsed to one live chain, a missing or entirely-stale schedule is re-enqueued, and the schedule record is (re)written so later declarations fast-path again; pending routine actions whose logical routine_id is not registered (or is durably paused) are unscheduled as orphans. It returns array( 'enqueued' => [ids], 'removed' => [ids], 'unchanged' => [ids], 'errors' => [id => message] ); $opts['dry_run'] reports the same shape without writing. pending_by_routine() (the one bulk read reconcile uses) always forces a fresh fetch of the Action Scheduler store — reconcile's entire purpose is authoritative drift detection, so trusting recorded state would defeat the point; that fresh read also refreshes the request-scoped map repair() walks, so a reconcile run pays exactly one bulk read.

The run is serialized through a $wpdb-level compare-and-set lock (agents_routine_reconcile_lock; a single INSERT ... ON DUPLICATE KEY UPDATE against wp_options) that never trusts get_option()/the options cache for the decision — only the real DB row. A lock older than five minutes is treated as stale and taken over atomically in the same query, and the lock is always released (a direct DELETE, again bypassing the cache) in a finally. This closes a production failure mode where a killed request left the lock option cached (e.g. in Redis) with no backing DB row: add_option() always failed because the cache said the option existed, the stale check read that same cached value, and delete_option() found nothing in the DB to delete — the lock stayed stuck "held" until the cache entry was manually cleared. Reading and writing exclusively through $wpdb, with an explicit wp_cache_delete() after every write, means a stale cache entry with no backing row can never block acquisition.

Both routine locks — this one and the bridge's per-routine schedule lock — share one internal helper, AgentsAPI\AI\Routines\WP_Agent_Routine_Option_Lock (acquire( $option_name, $ttl ) / release( $option_name )): one atomic query decides insert, stale takeover, or "someone fresh holds it", so two processes that both observe the same stale lock cannot both take it over — only the first write changes the row and the second reports zero affected rows.

Pause state is durable: the bridge maintains the agents_routine_paused option so reconcile can distinguish "unscheduled on purpose" from "missing by drift" across requests, even though the registry itself stays in-memory. unregister() and pause() also drop the routine's entry from the agents_routine_schedules record, so a later register()/resume() cannot fast-path on a signature whose action no longer exists.

The agents/reconcile-routines ability (show_in_rest: true, annotations destructive: true, idempotent: true) exposes the same operation to ability consumers: input { dry_run?: bool }, output the reconcile report, permission current_user_can( 'manage_options' ) filterable via agents_reconcile_routines_permission.

Transcripts and approvals

Transcript contracts live in src/Transcripts/ and runtime persister contracts live in src/Runtime/:

  • WP_Agent_Conversation_Store: session and transcript persistence surface, including workspace-stamped sessions and provider continuity metadata.
  • WP_Agent_Conversation_Lock and WP_Agent_Null_Conversation_Lock: lock contracts used by the conversation loop to avoid concurrent session mutation.
  • WP_Agent_Transcript_Persister and WP_Agent_Null_Transcript_Persister: loop-level persistence adapters.

Canonical conversation sessions

Agents API owns the canonical conversation-session ability surface for agent chat channels:

Ability Purpose
agents/list-conversation-sessions List sessions for the resolved (workspace, owner) tuple.
agents/get-conversation-session Read one session owned by the current principal.
agents/create-conversation-session Create an empty session for the current principal.
agents/update-conversation-session-title Update the stored display title.
agents/delete-conversation-session Idempotently delete the session.

The backing store is resolved through WP_Agent_Conversation_Sessions::get_store(), which accepts a direct conversation_store context value or the wp_agent_conversation_store filter. This keeps the contract storage-neutral: runtimes can use posts, custom tables, remote stores, files, or in-memory fakes as long as they implement the interface.

Canonical session rows should expose stable generic fields when available: session_id, workspace_type, workspace_id, owner_type, owner_key, user_id, agent_slug, title, messages, metadata, provider, model, provider_response_id, context, created_at, updated_at, last_read_at, and expires_at. list_sessions() should omit messages when include_messages is false. update_session() replaces the complete transcript and metadata, and the provider fields link the transcript to provider-side continuation state without making the session store own run-status storage.

Principal-aware stores should implement WP_Agent_Principal_Conversation_Store for non-user owners such as browser sessions, external channels, tokens, or system principals. Stores that hash or hide owner keys should also implement WP_Agent_Principal_Conversation_Session_Reader so get, update-title, and delete can verify ownership without exposing raw owner keys in generic rows.

Product-specific state belongs in metadata under namespaced keys. For example, a consumer plugin can keep read/progress/reporting fields under metadata['example_vendor'] while exposing the canonical Agents API fields to channel clients. Consumer-specific abilities, REST routes, and CLI commands can then become compatibility/product aliases over its adapter instead of a parallel generic contract.

Approval primitives live in src/Approvals/:

  • WP_Agent_Pending_Action: JSON-friendly proposal and audit value object.
  • WP_Agent_Pending_Action_Status: pending/accepted/rejected/expired/deleted vocabulary.
  • WP_Agent_Approval_Decision: resolver decision value object.
  • WP_Agent_Pending_Action_Store: durable queue/audit interface.
  • WP_Agent_Pending_Action_Observer: lifecycle observer contract for stores that emit stored/resolved/expired events.
  • WP_Agent_Pending_Action_Resolver: accept/reject resolution contract.
  • WP_Agent_Pending_Action_Handler: product handler contract for permission checks and applying/rejecting proposals.

Store implementations own observer registration and invocation. Observers should tolerate duplicate lifecycle calls and avoid throwing; stores should defensively catch observer failures so notification, logging, or metrics observers cannot break durable approval state transitions.

Consumers own the database tables, REST routes, admin/chat UI, queues, product-specific apply/reject handlers, event adapters, and authorization ceilings for transcript and approval materialization.

Operational failure behavior

  • Channel validation can silent-skip or return user-facing errors.
  • Missing Abilities API or missing ability registrations return WP_Error from channel and workflow handlers.
  • Bridge pending items are not deleted by best-effort delivery; ack() is the removal boundary.
  • Workflow recorder-start failure prevents step execution; per-step failures short-circuit unless continue_on_error is enabled.
  • Absent Action Scheduler means scheduled bridges no-op rather than hard-failing the substrate.

Future coverage

Future pages should add full method-level references for bridge queue item fields, workflow spec/run-result fields, Action Scheduler bridge hooks, transcript store methods, and pending-action resolver semantics. This bootstrap documents the integration contracts and operational lifecycle required to implement consumers safely.