Architecture
The exact contract between the Manager and the CLI, including the single callback endpoint and its command ladder.
CaptainCore splits along one line: the Manager holds state and never opens an SSH connection, the CLI opens SSH connections and holds no long-term state of its own that the Manager cannot rebuild.
Manager to CLI
Every remote operation the Manager wants is an HTTP POST to CAPTAINCORE_CLI_ADDRESS. The dispatcher is CaptainCore\Run in app/Run.php.
| Method | Path | Use |
|---|---|---|
Run::CLI( $command ) |
/run |
Run synchronously and return the body |
Run::CLI( $command, true ) |
/run/background |
Fire and forget |
Run::CLI_Stream( $command ) |
/run/stream |
Stream raw output, used for binary responses |
Run::task( $command ) |
/tasks |
Queue a task and get a tracking token back |
Every request carries a token header set to captaincore_get_cli_token(), a Content-Type: application/json; charset=utf-8 header, and a 45 second timeout by default.
The body takes one of two shapes, built by Run::build_body():
{ "command": "backup generate mysite-production" }
{ "args": ["backup", "generate", "mysite-production"], "payload": "..." }
The array form is the safer one. The CLI server execs the argv verbatim with no shell and no re-tokenizing. The string form is re-tokenized by the server, which is why Run::safe_environment() exists: it forces an environment name to one of a known list before it reaches a command string.
The server also exposes /task/{id} (GET, PUT, DELETE), /task/{id}/stream, /tasks, /progress, /progress/{pid} and a /ws WebSocket. It authenticates each request by matching the token header against the tokens array in ~/.captaincore/data/config.json. An unknown token resolves to captain id 0.
CLI to Manager
The CLI reports back through exactly one endpoint:
POST /wp-json/captaincore/v1/api
It is registered with 'permission_callback' => '__return_true' and 'show_in_index' => false. Authorization is the token check inside captaincore_api_func() itself:
if ( empty( $post ) || empty( $post->token ) || ! is_string( $post->token )
|| ! hash_equals( (string) captaincore_get_cli_token(), $post->token ) ) {
return new WP_Error( 'token_invalid', 'Invalid Token', [ 'status' => 404 ] );
}
is_string() is required because a JSON true would type-juggle past a loose comparison, and hash_equals() makes the check constant time. The command field is validated the same way, because a non-string command would satisfy every branch of the ladder at once. A site_id that does not resolve to a site is rejected, except for the three commands that are not site-scoped.
The command ladder
captaincore_api_func() dispatches on $command. These are the names it accepts:
| Command | What it does |
|---|---|
copy |
Sends the site-copy completion email |
production-to-staging |
Sends the push-to-staging completion email |
staging-to-production |
Sends the push-to-production completion email |
snapshot-add |
Records a generated snapshot |
backup-download-notify |
Notifies that a backup download is ready |
core-update-run |
Records a fleet core-update run |
monitor-notify |
Uptime monitor notification |
malware-alert |
Malware detection alert |
capture-alert |
Visual capture alert |
token |
Returns a token for the supplied token_key |
update-fathom |
Writes Fathom analytics data |
update-site |
Updates a site record |
update-environment |
Updates an environment record |
sync-data |
The main inventory sync: plugins, themes, users, core, checksums |
session-snapshot |
Stores a user-session and privilege snapshot |
new-capture |
Stores a new visual capture and refreshes the environments cache |
site-get-raw |
Returns the raw site row |
site-delete |
Deletes a site |
account-get-raw |
Returns the raw account row |
configuration-get |
Returns global configuration |
default-get |
Returns global site defaults |
providers-list-raw |
Returns all provider rows |
usage-update |
Updates visits and storage on an environment |
configuration-get, default-get and providers-list-raw are the three that run without a valid site_id. They are how captaincore configuration sync and captaincore default-sync pull fleet settings into the CLI’s SQLite database.
The bootstrap endpoint
There is a second CLI-facing route, POST /wp-json/captaincore/v1/cli/connect. Its permission callback accepts either an authenticated administrator (Basic Auth with an application password, used by the first captaincore connect) or the CLI token in the JSON body (used by captaincore connect --sync).
It returns the CLI token, api_url, gui_url, configurations, defaults, and bulk SELECT results for sites, environments, accounts, providers, domains and the three account junction tables. This is the whole fleet in one response, which is why the token gate on it is written the same constant-time way as /api.
Why the Manager never touches customer servers
There is no SSH client in the plugin. Sites store their connection details in wp_captaincore_environments, and those details are read by the CLI, not used by the Manager. Anything that has to happen on a customer server becomes a Run::CLI dispatch.
The practical consequences: the WordPress host does not need outbound SSH, does not need rclone or restic, and does not hold the long-running processes. A backup that takes twenty minutes is a task on the CLI server, polled through /task/{id}, not a PHP request holding a connection open.
What runs on cron
Renewal and billing processing runs from a real system crontab rather than WP-Cron. The captaincore_cron action fires captaincore_cron_run():
CaptainCore\Accounts::auto_switch_plans();
CaptainCore\Accounts::process_renewals();
CaptainCore\Accounts::process_pending_ach_payments();
CaptainCore\Scripts::run_scheduled();
The activator carries an explicit note not to re-add wp_schedule_event() for this, because traffic-dependent WP-Cron timing caused concurrent-run races.
Fleet operations run as WP-CLI commands, typically from cron on the fleet server. The Manager registers:
captaincore web-risk-check, scheduled-reports, top-plugins, scan-queue, component-queue, update-queue, restic-cache, security-log-sizes, error-log-sizes, mu-manifest-generate, dns, mailgun, provider-sync, remote, site-label, session-alerts and core-update-runs.
On the CLI side, scheduled work is whatever you put in the server’s crontab. captaincore cron does not schedule anything; it prints the scheduled_tasks block from the synced global configuration.
Two Mailgun actions are also scheduled through WordPress hooks: schedule_mailgun_verify and schedule_mailgun_retry.