Exec (WP-CLI)
capability-gated
The exec resource runs a WP-CLI command inside an environment and returns a
streaming job — output arrives live over the job’s SSE
channel. It’s the same WP-CLI you already run over the
SSH gateway, reshaped into a first-class, scoped, idempotent API
primitive that automation and agents can call safely.
the scope split — this is the important part
Section titled “the scope split — this is the important part”There are two levels of exec, and they are deliberately not the same scope:
| Scope | What it grants | Isolation |
|---|---|---|
| wp.cli:exec | WP-CLI with a denylist — arbitrary-code and catastrophic data commands are refused. | The safe default for WP automation. |
| exec:raw | Lifts the denylist — the key runs any WP-CLI command unfiltered. | Excluded from every wildcard. Granted one key at a time, explicitly. |
A key holding only wp.cli:exec cannot run these commands (each returns
403 with a message naming the required scope):
- arbitrary code:
wp eval,wp eval-file,wp shell,wp package …,wp config edit,wp config set - catastrophic data:
wp db drop,wp db reset,wp db clean,wp db query,wp db import,wp site empty
A key that also holds exec:raw, and any human dashboard session, runs unfiltered.
Even a * wildcard key stays denylisted — exec:raw never rides along with
anything else. See the security model for why dangerous
scopes are isolated.
run a command
Section titled “run a command”POST /v1/sites/{siteID}/environments/{envID}/exec
Returns 202 Accepted with a job and a Location header. Send an
Idempotency-Key so a retried command never runs twice.
| Parameter | Type | Required | Description |
|---|---|---|---|
args |
string[] |
yes | The WP-CLI arguments without the leading wp, e.g. ["plugin", "list", "--status=active"]. 1–64 items. |
Command output streams over the returned job’s SSE channel
(GET /v1/jobs/{id}/stream) as it’s produced — you don’t wait for the command to
finish to see its output.
worked example — stream WP-CLI output
Section titled “worked example — stream WP-CLI output”Update all plugins on staging and watch the output stream live.
-
Run the command. A
wp.cli:exec-scoped key, an idempotency key, a202back.wp plugin update --all on staging curl -X POST https://api.managed.dev/v1/sites/site_01J7.../environments/env_01J8.../exec \-H "Authorization: Bearer mfk_live_9aF2..." \-H "Forge-Version: 2026-06-23" \-H "Idempotency-Key: a91f-22b7-01de" \-H "Content-Type: application/json" \-d '{ "args": ["plugin", "update", "--all"] }'wp plugin update --all client := forge.New(os.Getenv("FORGE_TOKEN"))job, err := client.Environments.ExecWPCLI(ctx, "site_01J7...", "env_01J8...",[]string{"plugin", "update", "--all"})if err != nil {return err}// stream the command's output to stdout as it runs_, err = client.Jobs.Follow(ctx, job.ID, os.Stdout)wp plugin update --all mf envs exec env_01J8... -- wp plugin update --allThe CLI streams the output for you and exits non-zero if the job fails.
202 response HTTP/1.1 202 AcceptedLocation: /v1/jobs/job_01J9...{"data": {"id": "job_01J9...", "type": "job.exec", "status": "queued","progress": 0,"created_at": "2026-07-04T18:10:02.004Z","resource": { "type": "job", "id": "env_01J8...","site_id": "site_01J7...", "env_id": "env_01J8..." },"links": { "self": "/v1/jobs/job_01J9...", "stream": "/v1/jobs/job_01J9.../stream" }},"request_id": "req_01J9..."} -
Tail the output. The job’s SSE stream carries the command output as it runs.
Live-tail the exec job curl -N https://api.managed.dev/v1/jobs/job_01J9.../stream \-H "Authorization: Bearer mfk_live_9aF2..."Plugin ‘woocommerce’ updated to 9.1.2 Plugin ‘wordpress-seo’ updated to 23.5 ✓ succeeded · 2 updated -
Check the terminal status. The job ends
succeededorfailed— the exact job status set — and afailedjob carries a typederror. Gate the next step of your automation on it.