CLI ingest contract
The token-authenticated POST /api endpoint the CaptainCore CLI posts to, every command it accepts, and the key:value format the remote data collector returns.
The Manager never connects to customer servers. The CaptainCore CLI, a Go binary running on a dedicated server, does that work over SSH and posts the results back to one endpoint:
POST /wp-json/captaincore/v1/api
The route’s permission callback is __return_true, because authentication happens inside the handler with a shared token rather than a WordPress user.
Request shape
The body is a single JSON object. command and token are always present. Everything else depends on the command.
{
"command": "sync-data",
"token": "the-cli-token",
"site_id": 135,
"data": { "environment_id": 3365, "core": "6.9.1" }
}
The CLI’s client (apiclient/client.go) builds this by taking a payload map, setting payload["command"] and payload["token"], then posting it as application/json with a 30 second timeout. There are no headers beyond the content type, so the token is the whole gate.
Token validation
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 ] );
}
Three things are load-bearing here. The comparison is constant time. The token must be a string, because a JSON true would type-juggle past a loose !=. And a failure returns 404 rather than 403, so the endpoint does not confirm it exists to an unauthenticated prober.
The token comes from captaincore_get_cli_token(). If the CAPTAINCORE_CLI_TOKEN constant is defined, that wins. Otherwise the value is read from the captaincore_cli_token option, and generated as a 64 character random string on first use.
command gets the same string check, for the same reason. A JSON true would satisfy every loose comparison in the branch ladder at once and fire every handler on one request.
Site and environment resolution
After the token check:
$current_site = CaptainCore\Sites::get( $site_id );
if ( empty( $current_site ) && $site_id != "" && $command !== "default-get"
&& $command !== "configuration-get" && $command !== "providers-list-raw" ) {
return new WP_Error( 'command_invalid', "Invalid Command for $site_id", [ 'status' => 404 ] );
}
So an unknown site_id is rejected, except for the three read-only lookups that legitimately carry no site. Then:
$site_nameand$domain_namecome from the site row.$environment_idis resolved with( new Site( $site_id ) )->fetch_environment_id( $environment ), matching the environment name.
Most write commands carry data.environment_id directly and use that instead, which is why they work even when environment is absent.
Commands
Data sync
| Command | What it stores |
|---|---|
sync-data |
The main sync. Updates the environment row with plugins, themes, core, home_url, users, database credentials, checksum flag, subsite count, php_memory and token. Merges incoming details over existing details so server-side flags survive. Recomputes the audit coverage summary, refreshes screenshot_base from the latest capture, rebuilds the site’s environments cache, and updates the site name from home_url on Production. Fires checksum, default-role and open-registration alert emails once per condition, tracked by flags in details. |
update-environment |
Same merge behaviour, without the alerting or the site-name rewrite. |
update-site |
Updates a site row, merging details so flags such as removed are preserved. |
update-fathom |
Writes details.fathom on one environment. |
usage-update |
Updates visits and storage. A visits of 0 or empty keeps the existing value rather than overwriting good data with a failed API read, then recalculates site details. |
token |
Stores token_key on the site row. |
Security and monitoring
| Command | What it stores |
|---|---|
session-snapshot |
Inserts a row into captaincore_session_snapshots: user and session counts, admin IP counts, injected capability count, super admin count, plus the full collector JSON in payload. Runs delta detection against the previous snapshot for that environment and stores the anomalies and max severity. Anomalies also write an session_anomaly activity log row. Email is deliberately not sent here; wp captaincore session-alerts batches them hourly. |
malware-alert |
Sends a malware alert email from data.findings. |
capture-alert |
Sends a visual capture injection alert. |
monitor-notify |
Sends an uptime alert with data.subject and data.content. |
core-update-run |
Stores a fleet core-update run: a parent row in captaincore_core_update_runs plus per-site rows in captaincore_core_update_results. Returns run_id and inserted. |
Captures, snapshots and backups
| Command | What it stores |
|---|---|
new-capture |
Inserts a captaincore_captures row. Builds the per-page image list from capture_pages and captured_pages, keeping the previous image for pages that were not re-captured this run. Converts the epoch created_at to a MySQL datetime, then points the environment’s screenshot_base at the new set and refreshes the environments cache. |
snapshot-add |
Inserts or updates a captaincore_snapshots row keyed on snapshot_id, then emails the download link. |
backup-download-notify |
Sends the “your backup is ready” email with the file count, timestamp and download URL. |
Deploy notifications
copy, production-to-staging and staging-to-production each send a completion email when email is present. They store nothing.
Read-only lookups
| Command | Returns |
|---|---|
site-get-raw |
The raw site record for site_id |
account-get-raw |
The raw account record for account_id |
providers-list-raw |
Every provider row |
configuration-get |
The configuration object |
default-get |
Global defaults |
Destructive
site-delete deletes the site row for post.site_id.
The bulk connect endpoint
POST /wp-json/captaincore/v1/cli/connect is the CLI’s initial handshake and periodic resync. Its permission callback accepts either an administrator application password or the CLI token in the body, compared the same constant-time way.
It returns, in one response: the CLI token, the API URL, the dashboard URL, configurations, defaults, and bulk selects from captaincore_sites, captaincore_environments, captaincore_accounts, captaincore_providers, captaincore_domains, captaincore_account_site, captaincore_account_domain and captaincore_account_user. Sites, environments and accounts select only the columns the CLI needs, to keep the response from exhausting memory on a large fleet.
The fetch-site-data format
sync-data gets its content from a bash script that runs on the remote site, lib/remote-scripts/fetch-site-data. It returns key:value pairs, one per line.
plugins:[{"name":"akismet","title":"Akismet","status":"active","version":"5.3"}]
core:6.9.1
home_url:https://example.com
php_memory:256M
Split on the first colon only. Values routinely contain JSON, and JSON contains colons. The CLI does this with strings.Cut:
func parseSiteData(output string) map[string]string {
data := map[string]string{}
for _, line := range strings.Split(output, "\n") {
key, value, found := strings.Cut(line, ":")
if found {
data[key] = value
}
}
return data
}
If wp-config.php is missing the script prints WordPress not found and exits. The CLI recognises that exact string and posts a minimal sync-data with token set to basic, rather than treating it as a failure.
Keys the script emits
| Key | Destination |
|---|---|
plugins, themes |
Environment columns. Both must parse as JSON or the CLI aborts with “Response not valid” |
core, home_url, users, subsite_count, php_memory, token |
Environment columns |
database_name, database_username, database_password |
Environment columns |
core_verify_checksums |
Environment column, 0 means the checksum pass failed |
component_hashes |
Not stored directly. Merged as a hash field into each matching plugin and theme object. Keys prefixed mu: are also merged into mu_plugins and into the plugins array, because WP-CLI lists must-use plugins there |
mu_plugins |
Stored in details.mu_plugins, with hashes merged in |
default_role, registration, restic_cache, php_version, db_size |
Stored as plain strings in details |
core_checksum_details, plugin_checksum_details, security_log, error_logs, mu_plugin_files, core_file_hashes, loose_file_hashes, capture_plugin_pages |
Parsed as JSON before being stored in details. A value that fails to parse is skipped |
session_signal |
Not part of the sync-data payload. Posted separately as a session-snapshot command, best effort, so a failure there cannot disrupt the sync |
The script installs the captaincore-helper must-use plugin if it is missing, then runs wp plugin list, wp theme list, wp core version, wp user list and wp core verify-checksums with --skip-themes --skip-plugins --skip-packages so a broken site still reports.
Checksum output is parsed into three categories. Modified or extra files set the status to fail and core_verify_checksums to 0. Missing files alone are a warning.
Writing your own ingest client
If you are replacing or extending the CLI, the contract is small:
- Read the token from
GET /mecontext,POST /cli/connectas an administrator, or thecaptaincore_cli_tokenoption. - Post JSON to
/wp-json/captaincore/v1/apiwithcommand,token, and whatever the command needs. - Send
site_idfor anything site-scoped, anddata.environment_idfor anything environment-scoped. - Treat a 404 with code
token_invalidas an authentication failure, and a 404 withcommand_invalidas an unknown site.
Every command returns a JSON object with a response string describing what happened, plus command-specific fields.