agents/openai.yaml
interface:
display_name: "Pexo Video Agent"
short_description: "Create finished multi-shot AI videos"
default_prompt: "Use $pexo-agent only when I explicitly ask to produce a video; confirm before sending my brief or selected assets to Pexo."
references/SETUP-CHECKLIST.md
# Setup Checklist
This guide covers first-time setup and environment diagnostics for the Pexo agent skill.
Run bundled scripts through Bash from the skill directory, for example
`bash scripts/pexo-doctor.sh`; installed files may not retain executable bits.
## Quick Start
### 1. Create config file
```bash
umask 077
mkdir -p ~/.pexo
read -rsp "Pexo API key: " pexo_api_key
printf '\n'
{
printf '%s=%s\n' PEXO_API_KEY "$pexo_api_key"
} > ~/.pexo/config
unset pexo_api_key
chmod 600 ~/.pexo/config
```
Get your API key at: https://pexo.ai
- If you do not have an account:
Go to https://pexo.ai and sign up. During registration, you will be asked for an invite code.
Use invite code: **BV5N38**
New users receive bonus credits upon registration — enough to try out video generation right away.
- If you are already logged in:
click the top-right avatar → `API Keys` → `Create Key`, then copy the new key.
### 2. Run diagnostics
The next command makes outbound HTTPS requests only to `https://pexo.ai`. It performs
an unauthenticated connectivity check and, when an API key is configured, an authenticated
project-list request to validate access. Pexo may log these requests; they do not start a
generation or consume generation credits. Run it only after the user approves this check.
```bash
bash scripts/pexo-doctor.sh
```
This checks:
- Config file exists and is readable
- `PEXO_BASE_URL` and `PEXO_API_KEY` are set
- `curl`, `jq`, and `file` are installed
- Network connectivity to Pexo servers
- API key is valid (attempts to list projects)
Fix any issues reported before using other scripts.
### 3. Verify
The next command sends an authenticated project-list request to `https://pexo.ai` and may
appear in Pexo service logs. It does not create a project or consume generation credits.
```bash
bash scripts/pexo-project-list.sh
```
If this returns a JSON list (even if empty), setup is complete.
## Troubleshooting Setup Issues
### "PEXO_BASE_URL must be exactly https://pexo.ai"
Authenticated requests are restricted to the production Pexo origin. Remove any custom base
URL override, or set it to exactly `https://pexo.ai`.
### "Set PEXO_API_KEY in ~/.pexo/config or env"
Same as above — the API key line is missing from the config file.
### API key invalid (401 Unauthenticated)
Your API key may be expired or incorrect. Log in at https://pexo.ai to generate a new one. Replace the value in `~/.pexo/config`.
### curl, jq, or file not found
Install the missing dependency:
```bash
# macOS (file is usually preinstalled)
brew install curl jq
# Ubuntu/Debian
apt-get install -y curl jq file
# CentOS/RHEL
yum install -y curl jq file
```
### Network connectivity failure
If `pexo-doctor.sh` reports a connectivity issue:
- Check if your server can reach `pexo.ai` (e.g. `curl -I https://pexo.ai`)
- Check firewall rules for outbound HTTPS (port 443)
- If behind a proxy, configure `http_proxy`/`https_proxy` environment variables
## Environment Variables
All scripts read `~/.pexo/config` automatically. You can also override via environment variables:
Only `PEXO_*` assignments are accepted in the config file; it is parsed as data
and is never executed as shell code. Explicit environment variables take
precedence over values in the config file.
| Variable | Description | Required |
|---|---|---|
| `PEXO_BASE_URL` | Optional compatibility override; if set, must be exactly `https://pexo.ai` | No |
| `PEXO_API_KEY` | Your Pexo API key (starts with `sk-`) | Yes |
| `PEXO_CONFIG` | Custom path to config file (default: `~/.pexo/config`) | No |
| `PEXO_BILLING_CONFIRMATION_MODE` | Credit confirmation mode: `always` or `threshold` (default: `always`; use `threshold` only after explicit user opt-in) | No |
references/TROUBLESHOOTING.md
# Troubleshooting
## Script Exit Behavior
- Exit `0`: success
- Exit `1`: request/transport/backend failure
- Exit `2`: local usage error (missing args, invalid flags, invalid local input)
On request failure, scripts print compact JSON to `stderr`, for example:
```json
{"ok":false,"httpCode":429,"message":"Daily creation limit reached. Contact support email for more access."}
```
Fields you may see:
- `httpCode`: the real HTTP status code returned to the script
- `error`: auth/proxy error code such as `INVALID_API_KEY` or `INTERNAL_ERROR`
- `message`: the most useful user-facing message extracted from the response
- `details`: extra backend detail when available
## Auth And Proxy Errors
These can happen on every script that makes API calls:
| HTTP | `error` | Meaning | What to do |
|---|---|---|---|
| 401 | `INVALID_API_KEY` | API key is invalid or revoked | Update `PEXO_API_KEY` in `~/.pexo/config`. Get a new key at pexo.ai. |
| 401 | `MISSING_TOKEN` | The request was sent without an API key | Run `pexo-doctor.sh` to verify config. Make sure `~/.pexo/config` is sourced correctly. |
| 401 | `INTERNAL_ERROR` | The service failed to process the request before authentication completed | This is a temporary service issue, not a problem with the API key. Wait a moment and retry; if it persists, contact support. |
| 409 | `SESSION_REPLACED` | This API key's session was invalidated by a new login elsewhere | Unusual for API-key usage. Retry the command. If it keeps happening, regenerate the API key at pexo.ai. |
If the message says `Invalid API key`, it is an auth problem.
If the body says `error=INTERNAL_ERROR`, do not tell the user to rotate the key first; the service may simply be temporarily down.
## Script-Specific Errors
### `pexo-project-create.sh`
Real statuses:
- `400`: project name is too long. Ask the user to use a shorter name and retry.
- `401`: auth failure — see Auth and Proxy Errors above.
- `429`: creation limit reached — could be any of:
- User already has an active project running (must wait for it to finish)
- Insufficient credits to start a new project
Read the error `message` to distinguish these cases. The script does not query the balance automatically.
- `500`: an unexpected server error occurred. Retry in a moment; if the problem persists, contact support at pexo.ai.
Notes:
- If no project name is provided, the script defaults to `"Untitled"`.
### `pexo-project-list.sh`
Real statuses:
- `401`: auth failure — see Auth and Proxy Errors above.
- `500`: an unexpected server error occurred. Retry in a moment; if the problem persists, contact support at pexo.ai.
Notes:
- Invalid `page` / `page_size` values are handled locally by the script before request time.
- Backend page size is effectively capped at `100`.
### `pexo-project-get.sh`
Real statuses from the first project fetch:
- `401`: auth failure — see Auth and Proxy Errors above.
- `404`: the project does not exist or has been deleted. Verify the project_id; if correct, start a new project.
- `500`: an unexpected server error occurred. Retry in a moment; if the problem persists, contact support at pexo.ai.
Subsequent status fetches can also fail with:
- `401`: auth failure — see Auth and Proxy Errors above.
- `404`: project not found. Same action as above.
- `500`: an unexpected server error occurred. Retry in a moment; if the problem persists, contact support at pexo.ai.
`nextAction=CONFIRM` is a successful status response. The output includes a `confirmation` object for the current pending batch. Use its `confirmation_id` only after obtaining explicit user approval.
`nextAction=FAILED` can include `failureReason=INSUFFICIENT_CREDITS`. In that case, `recentMessages` retains the terminal error with `errorCode=credits.insufficient_credits_err`. This polling result is the authoritative way to detect an insufficient-credit failure that occurs after `pexo-chat.sh` has acknowledged an asynchronous submission.
### `pexo-upload.sh`
This script has three phases, and the failure source matters.
#### Phase 1: upload credential
Real statuses:
- `400`: the file name or file size is invalid. Check that the file exists and is not empty; rename it if it contains special characters.
- `401`: auth failure — see Auth and Proxy Errors above.
- `500`: an unexpected server error occurred. Retry in a moment; if the problem persists, contact support at pexo.ai.
Notes:
- The script rejects unsupported extensions locally. Supported formats:
- Images: `jpg`, `jpeg`, `png`, `webp`, `bmp`, `tiff`, `heic`, `heif`
- Videos: `mp4`, `mov`, `avi`
- Audio: `mp3`, `wav`, `aac`, `m4a`, `ogg`, `flac`
#### Phase 2: file transfer
Possible failures:
- `4xx/5xx`: the file storage service rejected the upload. Check network connectivity and retry. If the problem persists, contact support at pexo.ai.
The script surfaces this directly as:
```text
Error: upload failed with HTTP <code>
```
#### Phase 3: finalize
Real statuses:
- `400`: the file was rejected — possible reasons: file exceeds the size limit, file format is not supported, or the file content does not match its extension. Convert or compress the file and re-upload from scratch using `pexo-upload.sh`.
- `401`: auth failure — see Auth and Proxy Errors above.
- `404`: the file record was not found. The upload session may have been cleaned up. Re-upload from scratch using `pexo-upload.sh`.
- `412`: the upload session has already expired or been completed. Re-upload from scratch using `pexo-upload.sh`.
- `500`: an unexpected server error occurred. Retry in a moment; if the problem persists, contact support at pexo.ai.
### `pexo-chat.sh`
Real statuses:
- `400`: the message could not be sent due to invalid content. Check the message text; if the issue persists, start a new project.
- `401`: auth failure — see Auth and Proxy Errors above.
- `404`: the project does not exist or has been deleted. Start a new project.
- `412`: two possible causes:
- **Project no longer supported**: this project was created with an older version of Pexo's production system and cannot be continued. Start a new project.
- **Account billing issue**: the account's credits are frozen or suspended. Read the response `message`, then direct the user to top up or contact support at pexo.ai.
- `429`: limit reached — could be insufficient credits or the project's video output limit. Read the response `message` to distinguish the cause.
- `500`: an unexpected server error occurred. Retry in a moment; if the problem persists, contact support at pexo.ai.
Notes:
- `pexo-chat.sh` is asynchronous. Success means the request was accepted, not that the video is done.
- The script stops reading the SSE stream after `: stream opened`. A business error emitted later in that stream is not returned by `pexo-chat.sh`.
- Synchronous HTTP failures are printed as compact JSON to `stderr`. Use the HTTP status and response `message` to classify them.
- A successful `pexo-chat.sh` call should be followed by `pexo-project-get.sh` polling, typically every `60` seconds.
- If the asynchronous run later fails for insufficient credits, `pexo-project-get.sh` returns `nextAction=FAILED`, `failureReason=INSUFFICIENT_CREDITS`, and the matching error in `recentMessages`.
- When a project is waiting for credit approval, sending a new message through `pexo-chat.sh` cancels that pending confirmation and submits the replacement message.
### `pexo-billing-confirm.sh`
This command approves a pending billable batch. It must only be called after explicit user approval
and requires the `--user-approved` flag. Without that flag it exits before making a network request.
Local validation failures:
- The project is not in `CONFIRM_REQUIRED`: fetch the project again and follow its current `nextAction`.
- The supplied `confirmation_id` does not match the latest confirmation: use the current `confirmation.confirmation_id` returned by `pexo-project-get.sh`.
- The confirmation event is temporarily unavailable in history: poll again shortly; the event may still be persisting.
- `sufficient` is `false`: the available balance cannot cover the batch. Direct the user to purchase credits and do not submit approval.
- The confirmation mode is missing or invalid: fetch the current confirmation again; do not construct an approval request manually.
### `pexo-asset-get.sh`
Real statuses:
- `401`: auth failure — see Auth and Proxy Errors above.
- `403`: the account is not subscribed or watermark-whitelisted, or object storage denied access.
- `404`: the file does not exist, or it belongs to a different project. Verify the asset_id and project_id.
- `412`: the requested asset derivative is still processing. Retry after a short delay.
- `500`: an unexpected server error occurred. Retry in a moment; if the problem persists, contact support at pexo.ai.
Secondary download failures after metadata fetch:
- `403`: the download link has expired. Re-run `pexo-asset-get.sh` to get a fresh link.
- `000`: network request failed before receiving a response. Check network connectivity and retry.
- local filesystem write failure: the temp directory (`~/.pexo/tmp/`) is not writable or the disk is full. Free up space or set `PEXO_TMP_DIR` to a writable path.
Notes:
- The script downloads without a watermark by default. Pass `--with-watermark` only when the user explicitly requests it.
- The script downloads the file into `~/.pexo/tmp/` (or `$PEXO_TMP_DIR`) and returns `url`, `localPath`, and `withWatermark`.
- If the asset is still uploading or has no ready download URL, the script returns `localPath: null`.
### `pexo-doctor.sh`
- `200`: config and API key look healthy
- `401` + `INVALID_API_KEY`: API key is invalid or revoked. Update `PEXO_API_KEY` in `~/.pexo/config`.
- `401` + `INTERNAL_ERROR`: the service failed temporarily — not a key problem. Wait and retry.
- `409`: session conflict, unusual for API-key usage. Retry the command.
- `000`: no response received — network is unreachable or DNS failed. Check connectivity.
## Common Scenarios
### Synchronous `429` or `412` from project creation or chat submission
These scripts print the HTTP failure as compact JSON to `stderr`. They do not query or append the current credit balance. Read both `httpCode` and `message` before choosing an action because these statuses also represent non-credit limits and compatibility failures.
If the response identifies insufficient or suspended credits:
1. Explain the credit restriction to the user.
2. Direct them to `https://pexo.ai/home?billing=credits` and have them complete the purchase flow.
3. Do not retry until the user confirms that credits have been added or the suspension has been resolved.
For a concurrent-project limit, video output limit, or incompatible project, follow the response `message` instead of using the credit remediation.
### `pexo-chat.sh` returns success immediately
This is expected.
The script only confirms that the request was accepted by the server, then exits.
It does not stream progress or final results to the terminal.
It also does not return business errors emitted after the SSE acknowledgement.
Next step:
1. Wait `60` seconds.
2. Run `pexo-project-get.sh <project_id>`.
3. Follow `nextAction`.
### `nextAction=FAILED` with `failureReason=INSUFFICIENT_CREDITS`
Meaning:
- Production started but stopped when a billable operation found that the account did not have enough credits.
- The matching error details are retained in `recentMessages` with `event: "error"`.
Action:
1. Tell the user prominently that production stopped because the account has insufficient credits.
2. Direct them to top up credits at `https://pexo.ai/home?billing=credits`.
3. Do not retry until the user confirms that credits have been added.
4. Use `recentMessages[].errorMessage` for additional detail when needed; use `failureReason`, not free-form hint text, to select this remediation.
### `nextAction=CONFIRM`
The project is waiting for a decision on a billable generation batch.
1. Read `confirmation.estimated_credits`, `confirmation.available_credits`, and `confirmation.sufficient`.
2. If `sufficient` is `true`, explain the estimate and ask the user for explicit approval.
3. After approval, run `pexo-billing-confirm.sh <project_id> <confirmation_id> --user-approved`, then resume polling.
4. If `sufficient` is `false`, direct the user to purchase credits. Do not submit approval.
5. If the user changes the request, send the revised message with `pexo-chat.sh`; this cancels the pending confirmation.
### `WAIT` lasts a long time
This is normal for video generation.
Practical guideline:
1. Keep polling every `60` seconds.
2. Do not send another `pexo-chat.sh` message while `nextAction=WAIT`.
3. If the project later becomes `RECONNECT`, send a short continuation message and resume polling.
### `RECONNECT` keeps appearing
Meaning:
- The connection to the video generation service was interrupted.
Action:
1. Send a short message with `pexo-chat.sh`, for example `continue`.
2. Resume polling with `pexo-project-get.sh`.
3. If this repeats multiple times, start a new project instead of looping forever.
### Download URL expired or returns `403`
Signed URLs are temporary.
Action:
1. Re-run `pexo-asset-get.sh <project_id> <asset_id>`.
2. The script will fetch a fresh download URL for the default clean variant and re-download the file into `~/.pexo/tmp/`.
3. Deliver the fresh `downloadUrl` and report the `withWatermark` value.
### Upload fails locally with “unsupported file type”
This is a local pre-check, not a backend outage.
Action:
1. Convert the file into one of the supported formats listed above.
2. Retry `pexo-upload.sh`.
### A script says `401`, but the API key may still be fine
Inspect the error payload:
- `error=INVALID_API_KEY`: fix the key
- `error=INTERNAL_ERROR`: treat it as a temporary service issue, not a key problem
scripts/_common.sh
#!/usr/bin/env bash
# Shared configuration for Pexo scripts.
# Parses ~/.pexo/config as data; explicit environment variables override it.
# Agent scripts source this file -- no need to handle auth manually.
set -euo pipefail
umask 077
_PEXO_CONFIG="${PEXO_CONFIG:-$HOME/.pexo/config}"
_PEXO_PRODUCTION_BASE_URL="https://pexo.ai"
_PEXO_REQUESTED_BASE_URL="${PEXO_BASE_URL:-}"
_pexo_trim() {
local value="${1:-}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
_pexo_set_config_default() {
local key="$1"
local value="$2"
case "$key" in
PEXO_BASE_URL)
[[ -n "$_PEXO_REQUESTED_BASE_URL" ]] || _PEXO_REQUESTED_BASE_URL="$value"
;;
PEXO_API_KEY)
[[ -n "${PEXO_API_KEY+x}" ]] || export PEXO_API_KEY="$value"
;;
PEXO_BILLING_CONFIRMATION_MODE)
[[ -n "${PEXO_BILLING_CONFIRMATION_MODE+x}" ]] || export PEXO_BILLING_CONFIRMATION_MODE="$value"
;;
PEXO_CONNECT_TIMEOUT)
[[ -n "${PEXO_CONNECT_TIMEOUT+x}" ]] || export PEXO_CONNECT_TIMEOUT="$value"
;;
PEXO_REQUEST_TIMEOUT)
[[ -n "${PEXO_REQUEST_TIMEOUT+x}" ]] || export PEXO_REQUEST_TIMEOUT="$value"
;;
PEXO_CHAT_ACK_TIMEOUT)
[[ -n "${PEXO_CHAT_ACK_TIMEOUT+x}" ]] || export PEXO_CHAT_ACK_TIMEOUT="$value"
;;
PEXO_TMP_DIR)
[[ -n "${PEXO_TMP_DIR+x}" ]] || export PEXO_TMP_DIR="$value"
;;
PEXO_BILLING_CONFIRMATION_HISTORY_MAX_ATTEMPTS)
[[ -n "${PEXO_BILLING_CONFIRMATION_HISTORY_MAX_ATTEMPTS+x}" ]] || export PEXO_BILLING_CONFIRMATION_HISTORY_MAX_ATTEMPTS="$value"
;;
PEXO_BILLING_CONFIRMATION_HISTORY_RETRY_DELAY)
[[ -n "${PEXO_BILLING_CONFIRMATION_HISTORY_RETRY_DELAY+x}" ]] || export PEXO_BILLING_CONFIRMATION_HISTORY_RETRY_DELAY="$value"
;;
*)
printf 'Unsupported config key in %s: %s\n' "$_PEXO_CONFIG" "$key" >&2
return 2
;;
esac
}
pexo_load_config() {
local config_file="$1"
local line line_number=0 key value first last
[[ -r "$config_file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
line_number=$((line_number + 1))
line=$(_pexo_trim "$line")
[[ -z "$line" || "$line" == \#* ]] && continue
[[ "$line" == export[[:space:]]* ]] && line=$(_pexo_trim "${line#export}")
if [[ ! "$line" =~ ^(PEXO_[A-Z0-9_]+)[[:space:]]*=(.*)$ ]]; then
printf 'Invalid config syntax in %s at line %d\n' "$config_file" "$line_number" >&2
return 2
fi
key="${BASH_REMATCH[1]}"
value=$(_pexo_trim "${BASH_REMATCH[2]}")
if [[ -n "$value" ]]; then
first="${value:0:1}"
last="${value: -1}"
if [[ "$first" == '"' || "$first" == "'" ]]; then
if [[ "$last" != "$first" || ${#value} -lt 2 ]]; then
printf 'Unterminated quoted value in %s at line %d\n' "$config_file" "$line_number" >&2
return 2
fi
value="${value:1:${#value}-2}"
elif [[ "$value" == *'`'* || "$value" == *'$('* || "$value" == *';'* ]]; then
printf 'Unsafe unquoted value in %s at line %d\n' "$config_file" "$line_number" >&2
return 2
fi
fi
_pexo_set_config_default "$key" "$value"
done < "$config_file"
}
pexo_load_config "$_PEXO_CONFIG"
pexo_lock_production_base_url() {
local requested="${_PEXO_REQUESTED_BASE_URL%/}"
if [[ -n "$requested" && "$requested" != "$_PEXO_PRODUCTION_BASE_URL" ]]; then
printf 'PEXO_BASE_URL must be exactly %s; refusing to send credentials to %s\n' \
"$_PEXO_PRODUCTION_BASE_URL" "$requested" >&2
return 2
fi
export PEXO_BASE_URL="$_PEXO_PRODUCTION_BASE_URL"
readonly PEXO_BASE_URL
}
pexo_lock_production_base_url
PEXO_LAST_HTTP_CODE=0
_PEXO_CONNECT_TIMEOUT="${PEXO_CONNECT_TIMEOUT:-10}"
_PEXO_REQUEST_TIMEOUT="${PEXO_REQUEST_TIMEOUT:-60}"
pexo_resolve_billing_confirmation_mode() {
local override="${1:-}"
local mode="${override:-${PEXO_BILLING_CONFIRMATION_MODE:-always}}"
case "$mode" in
always|threshold)
printf '%s\n' "$mode"
;;
*)
printf 'Invalid billing confirmation mode: %s (expected always or threshold)\n' "$mode" >&2
return 2
;;
esac
}
pexo_extract_latest_billing_confirmation() {
local history="$1"
jq -cer '
(if type == "array" then . else (.messages // []) end) as $messages |
($messages[0] // {}) as $latest |
select(($latest.content.event // $latest.eventType // "") == "billing_confirmation") |
($latest.content.data // $latest.content // empty) |
select(type == "object" and (.confirmation_id // "") != "")
' <<<"$history"
}
pexo_get_pending_billing_confirmation() {
local project_id="$1"
local max_attempts="${PEXO_BILLING_CONFIRMATION_HISTORY_MAX_ATTEMPTS:-4}"
local retry_delay="${PEXO_BILLING_CONFIRMATION_HISTORY_RETRY_DELAY:-1}"
local attempt history confirmation
for ((attempt = 1; attempt <= max_attempts; attempt++)); do
history=$(pexo_get "/api/biz/projects/${project_id}/history?page=1&page_size=1&sort_order=DESC")
if confirmation=$(pexo_extract_latest_billing_confirmation "$history" 2>/dev/null); then
printf '%s\n' "$confirmation"
return 0
fi
if ((attempt < max_attempts)); then
sleep $((retry_delay * attempt))
fi
done
return 1
}
pexo_require_config() {
local missing=()
if [[ -z "${PEXO_BASE_URL:-}" ]]; then
missing+=("PEXO_BASE_URL")
fi
if [[ -z "${PEXO_API_KEY:-}" ]]; then
missing+=("PEXO_API_KEY")
fi
if [[ ${#missing[@]} -gt 0 ]]; then
printf 'Missing required config: %s\n' "${missing[*]}" >&2
printf 'Set them in %s or in the environment.\n' "$_PEXO_CONFIG" >&2
return 1
fi
}
_pexo_auth_header() {
printf 'Authorization: Bearer %s' "$PEXO_API_KEY"
}
pexo_tmp_dir() {
local tmp_dir="${PEXO_TMP_DIR:-$HOME/.pexo/tmp}"
mkdir -p "$tmp_dir"
chmod 700 "$tmp_dir"
printf '%s\n' "$tmp_dir"
}
_pexo_is_json() {
local payload="${1:-}"
[[ -n "$payload" ]] && jq -e . >/dev/null 2>&1 <<<"$payload"
}
_pexo_extract_http_code() {
local header_file="$1"
awk '/^HTTP\// { code = $2 } END { print code + 0 }' "$header_file"
}
_pexo_extract_content_type() {
local header_file="$1"
awk '
tolower($1) == "content-type:" {
value = $0
}
END {
sub(/\r$/, "", value)
sub(/^[^:]*:[[:space:]]*/, "", value)
print tolower(value)
}
' "$header_file"
}
_pexo_emit_success() {
local body="${1:-}"
if [[ -z "$body" ]]; then
return 0
fi
if _pexo_is_json "$body"; then
if jq -e 'type == "object" and has("code") and has("data")' >/dev/null 2>&1 <<<"$body"; then
jq '.data' <<<"$body"
return 0
fi
jq '.' <<<"$body"
return 0
fi
printf '%s\n' "$body"
}
_pexo_emit_error() {
local http_code="${1:-0}"
local body="${2:-}"
local transport_error="${3:-}"
export PEXO_LAST_HTTP_CODE="$http_code"
if [[ "$http_code" == "0" && -n "$transport_error" ]]; then
jq -nc \
--argjson httpCode 0 \
--arg message "Network request failed" \
--arg details "$transport_error" \
'{ok:false, httpCode:$httpCode, message:$message, details:$details}' >&2
return 1
fi
if _pexo_is_json "$body"; then
jq -c --argjson httpCode "${http_code:-0}" '
def maybe(field; value):
if value == null or value == "" then {} else { (field): value } end;
{
ok: false,
httpCode: $httpCode,
message: (
if (.data | type) == "object" and (.data.message? // "") != "" then .data.message
elif (.message? // "") != "" then .message
elif (.error? // "") != "" then .error
else "request failed"
end
)
}
+ (
if (.data | type) == "object" and (.data.code? != null) then
{businessCode: .data.code}
else
{}
end
)
+ (
if (.data | type) == "object" and (.data.error? // "") != "" then
{error: .data.error}
elif (.error? // "") != "" then
{error: .error}
else
{}
end
)
+ (
if (.data | type) == "object" and (.data.details? // "") != "" then
{details: .data.details}
elif (.details? // "") != "" then
{details: .details}
else
{}
end
)
' <<<"$body" >&2
return 1
fi
jq -nc \
--argjson httpCode "${http_code:-0}" \
--arg message "request failed" \
--arg details "${transport_error:-$body}" \
'{ok:false, httpCode:$httpCode, message:$message} + (if $details != "" then {details:$details} else {} end)' >&2
return 1
}
_pexo_request_json() {
local method="$1"
local path="$2"
local body="${3:-}"
shift 3 || true
pexo_require_config
local body_file header_file err_file
local response http_code curl_status=0
body_file=$(mktemp)
header_file=$(mktemp)
err_file=$(mktemp)
if [[ -n "$body" ]]; then
http_code=$(curl -sS \
--connect-timeout "$_PEXO_CONNECT_TIMEOUT" \
--max-time "$_PEXO_REQUEST_TIMEOUT" \
-X "$method" \
-H "$(_pexo_auth_header)" \
-H "Content-Type: application/json" \
-D "$header_file" \
-o "$body_file" \
-w '%{http_code}' \
-d "$body" \
"$@" \
"${PEXO_BASE_URL}${path}" 2>"$err_file") || curl_status=$?
else
http_code=$(curl -sS \
--connect-timeout "$_PEXO_CONNECT_TIMEOUT" \
--max-time "$_PEXO_REQUEST_TIMEOUT" \
-X "$method" \
-H "$(_pexo_auth_header)" \
-H "Content-Type: application/json" \
-D "$header_file" \
-o "$body_file" \
-w '%{http_code}' \
"$@" \
"${PEXO_BASE_URL}${path}" 2>"$err_file") || curl_status=$?
fi
response=$(cat "$body_file")
export PEXO_LAST_HTTP_CODE="${http_code:-0}"
if [[ $curl_status -ne 0 && "${http_code:-0}" == "000" ]]; then
_pexo_emit_error 0 "" "$(cat "$err_file")"
rm -f "$body_file" "$header_file" "$err_file"
return 1
fi
if [[ "${http_code:-0}" -ge 400 ]] 2>/dev/null; then
_pexo_emit_error "$http_code" "$response" "$(cat "$err_file")"
rm -f "$body_file" "$header_file" "$err_file"
return 1
fi
_pexo_emit_success "$response"
rm -f "$body_file" "$header_file" "$err_file"
}
# GET -> extracts .data from BFF wrapper when present
pexo_get() {
local path="$1"
shift || true
_pexo_request_json GET "$path" "" "$@"
}
# POST with optional JSON body -> extracts .data
pexo_post() {
local path="$1"
local body="${2:-}"
shift 2 || true
_pexo_request_json POST "$path" "$body" "$@"
}
pexo_post_sse_ack() {
local path="$1"
local body="${2:-}"
local timeout="${3:-20}"
pexo_require_config
local body_file header_file err_file
local response http_code content_type
body_file=$(mktemp)
header_file=$(mktemp)
err_file=$(mktemp)
set +o pipefail
if [[ -n "$body" ]]; then
curl -sS -N \
--connect-timeout "$_PEXO_CONNECT_TIMEOUT" \
--max-time "$timeout" \
-X POST \
-H "$(_pexo_auth_header)" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-D "$header_file" \
-d "$body" \
"${PEXO_BASE_URL}${path}" 2>"$err_file" | tee "$body_file" | sed '/^: stream opened$/q' >/dev/null
else
curl -sS -N \
--connect-timeout "$_PEXO_CONNECT_TIMEOUT" \
--max-time "$timeout" \
-X POST \
-H "$(_pexo_auth_header)" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-D "$header_file" \
"${PEXO_BASE_URL}${path}" 2>"$err_file" | tee "$body_file" | sed '/^: stream opened$/q' >/dev/null
fi
set -o pipefail
response=$(cat "$body_file")
http_code=$(_pexo_extract_http_code "$header_file")
content_type=$(_pexo_extract_content_type "$header_file")
export PEXO_LAST_HTTP_CODE="${http_code:-0}"
if [[ "${http_code:-0}" -ge 400 ]] 2>/dev/null; then
_pexo_emit_error "$http_code" "$response" "$(cat "$err_file")"
rm -f "$body_file" "$header_file" "$err_file"
return 1
fi
if [[ "$http_code" == "200" && "$content_type" == text/event-stream* && "$response" == *": stream opened"* ]]; then
rm -f "$body_file" "$header_file" "$err_file"
return 0
fi
if [[ "$http_code" == "0" ]]; then
_pexo_emit_error 0 "" "$(cat "$err_file")"
rm -f "$body_file" "$header_file" "$err_file"
return 1
fi
_pexo_emit_error 0 "" "Timed out waiting for SSE acknowledgement from ${path}"
rm -f "$body_file" "$header_file" "$err_file"
return 1
}
# Detect asset type from file extension
detect_asset_type() {
local ext="${1##*.}"
ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
case "$ext" in
jpg|jpeg|png|webp|bmp|tiff|heic|heif) echo "IMAGE" ;;
mp4|mov|avi) echo "VIDEO" ;;
mp3|wav|aac|m4a|ogg|flac) echo "AUDIO" ;;
*) echo "UNKNOWN" ;;
esac
}
# Detect MIME type
detect_mime() {
file --brief --mime-type "$1" 2>/dev/null || echo "application/octet-stream"
}
mime_supported_for_asset_type() {
local mime_type
local asset_type="$2"
mime_type=$(echo "$1" | tr '[:upper:]' '[:lower:]')
case "${asset_type}:${mime_type}" in
IMAGE:image/jpeg|IMAGE:image/jpg|IMAGE:image/png|IMAGE:image/webp|IMAGE:image/tiff|IMAGE:image/bmp|IMAGE:image/heic|IMAGE:image/heif)
return 0
;;
VIDEO:video/mp4|VIDEO:video/x-msvideo|VIDEO:video/avi|VIDEO:video/quicktime)
return 0
;;
AUDIO:audio/mpeg|AUDIO:audio/wav|AUDIO:audio/wave|AUDIO:audio/aac|AUDIO:audio/mp4|AUDIO:audio/x-m4a|AUDIO:audio/ogg|AUDIO:audio/flac)
return 0
;;
*)
return 1
;;
esac
}
scripts/pexo-asset-get.sh
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
usage() {
cat <<'EOF'
Usage:
pexo-asset-get.sh <project_id> <asset_id> [--with-watermark]
pexo-asset-get.sh -h | --help
Description:
Fetch asset details and download the asset into ~/.pexo/tmp/ (or
$PEXO_TMP_DIR when set). Downloads are watermark-free by default. Pass
--with-watermark when the user explicitly requests a watermarked copy.
Returns:
Asset JSON from /api/biz/projects/:project_id/assets/:asset_id
plus:
- url: signed download URL selected by the watermark option
- localPath: downloaded local cache path, or null when the asset is not ready
- withWatermark: whether the selected URL contains a watermark
Common errors:
401 Invalid API key or auth failure
403 User is not entitled to download, or object storage denied download
404 Asset not found, or asset does not belong to the project/user
412 Asset derivative is still processing
500 Backend/internal failure
EOF
}
source "$(dirname "$0")/_common.sh"
with_watermark=false
positionals=()
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--with-watermark)
with_watermark=true
;;
--)
shift
positionals+=("$@")
break
;;
-* )
printf 'unknown option: %s\n' "$1" >&2
usage >&2
exit 2
;;
*)
positionals+=("$1")
;;
esac
shift
done
if [[ ${#positionals[@]} -ne 2 ]]; then
usage >&2
exit 2
fi
pid="${positionals[0]}"
aid="${positionals[1]}"
asset=$(pexo_get "/api/biz/projects/${pid}/assets/${aid}")
# Uploading assets have no stable download URL yet. Avoid turning the existing
# metadata-only behavior into a download error while still using the explicit
# download endpoint for all ready assets, including final videos.
if [[ "$(echo "$asset" | jq -r '.assetStatus // empty')" == "UPLOADING" ]]; then
echo "$asset" | jq '. + {url:null, localPath:null, withWatermark:null}'
exit 0
fi
remove_watermark=true
if [[ "$with_watermark" == true ]]; then
remove_watermark=false
fi
download=$(pexo_get "/api/biz/projects/${pid}/assets/${aid}/download-url?remove_watermark=${remove_watermark}")
download_url=$(echo "$download" | jq -r '.url // empty')
with_watermark_result=$(echo "$download" | jq -c 'if has("withWatermark") then .withWatermark else null end')
if [[ -z "$download_url" ]]; then
echo "$asset" | jq --argjson withWatermark "$with_watermark_result" '. + {url:null, localPath:null, withWatermark:$withWatermark}'
exit 0
fi
tmp_dir=$(pexo_tmp_dir)
file_name=$(echo "$asset" | jq -r '.fileName // .assetName // empty')
[[ -n "$file_name" && "$file_name" != "null" ]] || file_name="${aid}.bin"
safe_name=$(printf '%s' "$file_name" | sed 's#[/[:space:]]#_#g')
variant="clean"
if [[ "$with_watermark" == true ]]; then
variant="watermarked"
fi
local_path="${tmp_dir}/${aid}-${variant}-${safe_name}"
part_path="${local_path}.part.$$"
err_file=$(mktemp)
http_code=""
curl_status=0
http_code=$(curl -sS -L \
--connect-timeout "$_PEXO_CONNECT_TIMEOUT" \
--max-time "$_PEXO_REQUEST_TIMEOUT" \
-o "$part_path" \
-w '%{http_code}' \
"$download_url" 2>"$err_file") || curl_status=$?
if [[ $curl_status -ne 0 && "${http_code:-0}" == "000" ]]; then
err_text=$(cat "$err_file")
rm -f "$part_path" "$err_file"
_pexo_emit_error 0 "" "${err_text:-Failed to download asset from signed URL}"
fi
if [[ ! "${http_code:-}" =~ ^2 ]]; then
err_text=$(cat "$err_file")
rm -f "$part_path"
rm -f "$err_file"
_pexo_emit_error "${http_code:-0}" "" "${err_text:-Failed to download asset from signed URL}"
fi
mv -f "$part_path" "$local_path"
rm -f "$err_file"
echo "$asset" | jq \
--arg url "$download_url" \
--arg localPath "$local_path" \
--argjson withWatermark "$with_watermark_result" \
'. + {url:$url, localPath:$localPath, withWatermark:$withWatermark}'
scripts/pexo-billing-confirm.sh
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
usage() {
cat <<'EOF'
Usage:
pexo-billing-confirm.sh <project_id> <confirmation_id> --user-approved [--timeout <seconds>]
pexo-billing-confirm.sh -h | --help
Description:
Continue the current credit-gated tool batch after explicit user approval.
The confirmation ID must match the project's latest billing confirmation.
--user-approved records that the displayed estimate was approved by the user.
Options:
--user-approved Required assertion of prior explicit user approval
--timeout <sec> Wait time for SSE acknowledgement (default: 20)
EOF
}
source "$(dirname "$0")/_common.sh"
case "${1:-}" in
-h|--help)
usage
exit 0
;;
esac
if [[ $# -lt 2 ]]; then
usage >&2
exit 2
fi
pid="$1"
confirmation_id="$2"
shift 2
timeout="${PEXO_CHAT_ACK_TIMEOUT:-20}"
user_approved=false
while [[ $# -gt 0 ]]; do
case "$1" in
--user-approved)
user_approved=true
shift
;;
--timeout)
[[ $# -ge 2 ]] || { echo 'Error: --timeout requires a value' >&2; exit 2; }
timeout="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ "$user_approved" != "true" ]]; then
echo 'Error: --user-approved is required after the user approves the displayed credit estimate.' >&2
exit 2
fi
project=$(pexo_get "/api/biz/projects/${pid}")
execution_status=$(jq -r '.executionStatus // ""' <<<"$project")
if [[ "$execution_status" != "CONFIRM_REQUIRED" ]]; then
echo "Error: project is not waiting for billing confirmation (executionStatus=${execution_status:-unknown})." >&2
exit 1
fi
pending=$(pexo_get_pending_billing_confirmation "$pid") || {
echo 'Error: current billing confirmation event is not available yet. Retry shortly.' >&2
exit 1
}
current_id=$(jq -r '.confirmation_id // ""' <<<"$pending")
if [[ "$current_id" != "$confirmation_id" ]]; then
echo 'Error: confirmation_id does not match the current pending confirmation.' >&2
exit 1
fi
sufficient=$(jq -r '.sufficient // false' <<<"$pending")
if [[ "$sufficient" != "true" ]]; then
echo 'Error: available credits are insufficient; this confirmation cannot continue.' >&2
exit 1
fi
confirmation_mode=$(jq -r '.confirmation_mode // ""' <<<"$pending")
if [[ -z "$confirmation_mode" ]]; then
echo 'Error: current confirmation is missing its confirmation mode.' >&2
exit 1
fi
confirmation_mode=$(pexo_resolve_billing_confirmation_mode "$confirmation_mode") || exit $?
ts=$(date +%s000)
body=$(jq -nc \
--arg pid "$pid" \
--arg ts "$ts" \
--arg confirmation_id "$confirmation_id" \
--arg mode "$confirmation_mode" '
{
action: "billing_confirm",
project_id: $pid,
timestamp: $ts,
user_visible: true,
billing_confirmation_response: {
decision: "approve",
confirmation_id: $confirmation_id
},
billing_confirmation_policy: {mode: $mode}
}
')
printf 'Submitting user-approved billing confirmation for project %s and confirmation %s.\n' \
"$pid" "$confirmation_id" >&2
pexo_post_sse_ack "/api/chat" "$body" "$timeout"
jq -nc --arg pid "$pid" --arg confirmation_id "$confirmation_id" '{
projectId: $pid,
confirmationId: $confirmation_id,
status: "submitted",
submissionMode: "async",
pollAfterSeconds: 60,
nextActionHint: "Use pexo-project-get.sh to poll for progress."
}'
scripts/pexo-chat.sh
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
usage() {
cat <<'EOF'
Usage:
pexo-chat.sh <project_id> <message> [--choice <preview_asset_id>] [--billing-confirmation-mode <mode>] [--timeout <seconds>]
pexo-chat.sh -h | --help
Description:
Submit a message to an existing Pexo project.
This script does not keep the SSE stream open. It only waits until /api/chat
acknowledges the request by opening the stream, then it disconnects.
If the message references uploaded assets, wrap each asset ID with one of:
<original-image>asset_id</original-image>
<original-video>asset_id</original-video>
<original-audio>asset_id</original-audio>
Bare asset IDs inside the message are ignored by Pexo and rejected locally.
Options:
--choice <id> Send the selected preview asset ID as choices.preview_id
--billing-confirmation-mode <mode>
Override this message's confirmation mode: always or threshold
--timeout <sec> Wait time for SSE acknowledgement (default: 20)
Returns:
JSON acknowledgement:
{
"projectId": "...",
"status": "submitted",
"submissionMode": "async",
"submittedAt": "...",
"pollAfterSeconds": 60,
"nextActionHint": "Use pexo-project-get.sh to poll for progress."
}
Common errors:
Local validation error: asset IDs in <message> are not wrapped in valid tags
400 Invalid request body
401 Invalid API key or auth failure
404 Project not found
412 Project agent version incompatible
429 Project video limit reached
500 Backend/internal failure
EOF
}
source "$(dirname "$0")/_common.sh"
strip_valid_asset_tags() {
local text="$1"
printf '%s' "$text" \
| sed -E 's#<original-image>((a_[1-9A-HJ-NP-Za-km-z]{7,24})|([0-9A-Z]{26}))</original-image># #g' \
| sed -E 's#<original-video>((a_[1-9A-HJ-NP-Za-km-z]{7,24})|([0-9A-Z]{26}))</original-video># #g' \
| sed -E 's#<original-audio>((a_[1-9A-HJ-NP-Za-km-z]{7,24})|([0-9A-Z]{26}))</original-audio># #g'
}
find_unwrapped_asset_ids() {
local text="$1"
printf '%s' "$text" \
| tr -cs 'A-Za-z0-9_' '\n' \
| awk '/^([0-9A-Z]{26}|a_[1-9A-HJ-NP-Za-km-z]{7,24})$/ && !seen[$0]++'
}
validate_message_asset_references() {
local text="$1"
local stripped invalid_refs joined
stripped=$(strip_valid_asset_tags "$text")
invalid_refs=$(find_unwrapped_asset_ids "$stripped")
if [[ -z "$invalid_refs" ]]; then
return 0
fi
joined=$(printf '%s\n' "$invalid_refs" | awk 'BEGIN { first = 1 } { printf("%s%s", first ? "" : ", ", $0); first = 0 }')
echo 'Error: asset IDs in <message> must be wrapped with <original-image>...</original-image>, <original-video>...</original-video>, or <original-audio>...</original-audio>.' >&2
printf 'Invalid asset reference(s): %s\n' "$joined" >&2
echo 'Example: pexo-chat.sh <project_id> "Use <original-image>a_xxx</original-image> as the reference image."' >&2
return 1
}
case "${1:-}" in
-h|--help)
usage
exit 0
;;
esac
if [[ $# -lt 2 ]]; then
usage >&2
exit 2
fi
pid="$1"
msg="$2"
shift 2
choice=""
confirmation_mode_override=""
timeout="${PEXO_CHAT_ACK_TIMEOUT:-20}"
while [[ $# -gt 0 ]]; do
case "$1" in
--choice)
[[ $# -ge 2 ]] || { echo 'Error: --choice requires a value' >&2; exit 2; }
choice="$2"
shift 2
;;
--timeout)
[[ $# -ge 2 ]] || { echo 'Error: --timeout requires a value' >&2; exit 2; }
timeout="$2"
shift 2
;;
--billing-confirmation-mode)
[[ $# -ge 2 ]] || { echo 'Error: --billing-confirmation-mode requires a value' >&2; exit 2; }
confirmation_mode_override="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
validate_message_asset_references "$msg" || exit 2
confirmation_mode=$(pexo_resolve_billing_confirmation_mode "$confirmation_mode_override") || exit $?
ts=$(date +%s000)
project=$(pexo_get "/api/biz/projects/${pid}")
execution_status=$(jq -r '.executionStatus // ""' <<<"$project")
replacement_confirmation='null'
if [[ "$execution_status" == "CONFIRM_REQUIRED" ]]; then
pending=$(pexo_get_pending_billing_confirmation "$pid") || {
echo 'Error: project is waiting for billing confirmation, but the current confirmation event is not available yet. Retry shortly.' >&2
exit 1
}
replacement_confirmation=$(jq -c '{
decision: "cancel",
confirmation_id: .confirmation_id,
tool_call_ids: (.tool_call_ids // []),
items: [(.items // [])[] | {tool_name, tool_call_id}]
}' <<<"$pending")
fi
body=$(jq -nc \
--arg pid "$pid" \
--arg msg "$msg" \
--arg ts "$ts" \
--arg ch "$choice" \
--arg mode "$confirmation_mode" \
--argjson replacement "$replacement_confirmation" '
{
project_id: $pid,
timestamp: $ts,
user_visible: true,
native_inputs: {text: $msg},
billing_confirmation_policy: {mode: $mode}
}
+ (if $ch != "" then {choices: {preview_id: $ch}} else {} end)
+ (if $replacement != null then {
action: "message",
billing_confirmation_response: $replacement
} else {} end)
')
pexo_post_sse_ack "/api/chat" "$body" "$timeout"
jq -nc \
--arg pid "$pid" \
--arg submitted_at "$ts" \
'{
projectId: $pid,
status: "submitted",
submissionMode: "async",
submittedAt: $submitted_at,
pollAfterSeconds: 60,
nextActionHint: "Use pexo-project-get.sh to poll for progress."
}'
scripts/pexo-doctor.sh
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
# Pexo environment diagnostic tool.
# Checks config, dependencies, connectivity, and API key validity.
# Run this when first setting up or when scripts fail unexpectedly.
#
# Usage: pexo-doctor.sh
set -uo pipefail
usage() {
cat <<'EOF'
Usage:
pexo-doctor.sh
pexo-doctor.sh -h | --help
Description:
Run environment checks for the Pexo shell scripts:
- config file presence
- required variables
- local dependencies
- network reachability
- API key/auth check against /api/biz/projects?page_size=1
Notes:
API keys are expected to use the sk- prefix.
EOF
}
extract_message() {
local payload="${1:-}"
echo "$payload" | jq -r '.data.message // .message // .error // "unknown"' 2>/dev/null || echo "unknown"
}
extract_error_code() {
local payload="${1:-}"
echo "$payload" | jq -r '.data.error // .error // empty' 2>/dev/null || true
}
mask_secret() {
local value="${1:-}"
if [[ -z "$value" ]]; then
printf '%s\n' ""
return 0
fi
if [[ ${#value} -le 12 ]]; then
printf '%s\n' "$value"
return 0
fi
printf '%s...%s\n' "${value:0:8}" "${value: -4}"
}
case "${1:-}" in
-h|--help)
usage
exit 0
;;
esac
source "$(dirname "$0")/_common.sh"
PASS="✓"
FAIL="✗"
WARN="!"
errors=0
echo "=== Pexo Environment Diagnostic ==="
echo ""
config_path="${PEXO_CONFIG:-$HOME/.pexo/config}"
# 1. Config file
if [[ -f "$config_path" ]]; then
echo "$PASS Config file found: $config_path"
config_mode=$(stat -f '%Lp' "$config_path" 2>/dev/null || stat -c '%a' "$config_path" 2>/dev/null || echo unknown)
if [[ "$config_mode" == "unknown" ]]; then
echo "$WARN Could not determine config file permissions"
elif [[ "${config_mode: -2}" != "00" ]]; then
echo "$FAIL Config file is accessible by group or other users (mode $config_mode)"
echo " Fix with: chmod 600 $config_path"
errors=$((errors + 1))
else
echo "$PASS Config file permissions are owner-only (mode $config_mode)"
fi
else
echo "$FAIL Config file not found: $config_path"
echo " Create an owner-only config with the API key copied from https://pexo.ai."
echo " Follow references/SETUP-CHECKLIST.md; it reads the key without echoing it."
errors=$((errors + 1))
fi
# 2. Required variables
echo "$PASS Authenticated API origin is locked to: $PEXO_BASE_URL"
confirmation_mode="${PEXO_BILLING_CONFIRMATION_MODE:-always}"
case "$confirmation_mode" in
always|threshold)
echo "$PASS Billing confirmation mode: $confirmation_mode"
;;
*)
echo "$FAIL PEXO_BILLING_CONFIRMATION_MODE must be always or threshold"
errors=$((errors + 1))
;;
esac
if [[ -n "${PEXO_API_KEY:-}" ]]; then
masked=$(mask_secret "$PEXO_API_KEY")
echo "$PASS PEXO_API_KEY is set: $masked"
if [[ "$PEXO_API_KEY" != sk-* ]]; then
echo "$WARN PEXO_API_KEY does not start with sk-"
echo " The current frontend API key validator recognizes keys with the sk- prefix."
fi
else
echo "$FAIL PEXO_API_KEY is not set"
echo " Get your API key at: https://pexo.ai"
errors=$((errors + 1))
fi
# 3. Dependencies
echo ""
for cmd in curl jq file; do
if command -v "$cmd" &>/dev/null; then
ver=$("$cmd" --version 2>&1 | head -1)
echo "$PASS $cmd is installed: $ver"
else
echo "$FAIL $cmd is not installed"
if [[ "$cmd" == "file" ]]; then
echo " Install the package that provides file(1) for your OS. It is usually preinstalled on macOS."
else
echo " Install: brew install $cmd (macOS) or apt-get install $cmd (Linux)"
fi
errors=$((errors + 1))
fi
done
# 4. Network connectivity
echo ""
if [[ -n "${PEXO_BASE_URL:-}" ]]; then
http_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 "${PEXO_BASE_URL}" 2>/dev/null || echo "000")
if [[ "$http_code" != "000" ]]; then
echo "$PASS Network: can reach $PEXO_BASE_URL (HTTP $http_code)"
else
echo "$FAIL Network: cannot reach $PEXO_BASE_URL"
echo " Check your network connection, firewall, and DNS settings."
errors=$((errors + 1))
fi
else
echo "$WARN Network: skipped (PEXO_BASE_URL not set)"
fi
# 5. API key validation
echo ""
if [[ -n "${PEXO_BASE_URL:-}" && -n "${PEXO_API_KEY:-}" ]]; then
tmp_body=$(mktemp)
tmp_err=$(mktemp)
http_code=$(curl -sS \
--connect-timeout 10 \
-H "Authorization: Bearer $PEXO_API_KEY" \
-H "Content-Type: application/json" \
-o "$tmp_body" \
-w '%{http_code}' \
"${PEXO_BASE_URL}/api/biz/projects?page_size=1" 2>"$tmp_err" || echo "000")
resp=$(cat "$tmp_body")
curl_err=$(cat "$tmp_err")
rm -f "$tmp_body" "$tmp_err"
if [[ "$http_code" == "200" ]]; then
echo "$PASS API key is valid (projects endpoint responded OK)"
elif [[ "$http_code" == "401" ]]; then
auth_error=$(extract_error_code "$resp")
message=$(extract_message "$resp")
if [[ "$auth_error" == "INVALID_API_KEY" ]]; then
echo "$FAIL API key is invalid or expired (HTTP 401)"
echo " Message: $message"
echo " Get a new key at: https://pexo.ai"
errors=$((errors + 1))
elif [[ "$auth_error" == "INTERNAL_ERROR" ]]; then
echo "$WARN API check returned HTTP 401 with INTERNAL_ERROR"
echo " This usually means the BFF/proxy failed before auth completed."
echo " Message: $message"
else
echo "$FAIL API check returned HTTP 401"
echo " Message: $message"
errors=$((errors + 1))
fi
elif [[ "$http_code" == "409" ]]; then
echo "$WARN API check returned HTTP 409"
echo " Message: $(extract_message "$resp")"
echo " This is normal for JWT session replacement, but unusual for API-key auth."
elif [[ "$http_code" == "000" ]]; then
echo "$FAIL API validation request failed before receiving a response"
echo " Curl error: ${curl_err:-unknown}"
errors=$((errors + 1))
else
echo "$WARN API check returned HTTP $http_code"
echo " Message: $(extract_message "$resp")"
fi
else
echo "$WARN API key validation: skipped (missing config)"
fi
# Summary
echo ""
echo "=== Summary ==="
if [[ $errors -eq 0 ]]; then
echo "$PASS All checks passed. Pexo is ready to use."
else
echo "$FAIL $errors issue(s) found. Fix the items marked with $FAIL above."
fi
exit $errors
scripts/pexo-project-create.sh
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
usage() {
cat <<'EOF'
Usage:
pexo-project-create.sh [project_name]
pexo-project-create.sh --name <project_name>
pexo-project-create.sh -h | --help
Description:
Create a new Pexo project.
If no project name is provided, the script uses "Untitled" because the backend
requires project_name.
Returns:
project_id string on stdout
Common errors:
400 Invalid project name
401 Invalid API key or auth failure
429 Daily creation limit or concurrent project limit reached
500 Backend/internal failure
EOF
}
source "$(dirname "$0")/_common.sh"
project_name=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--name)
[[ $# -ge 2 ]] || { echo 'Error: --name requires a value' >&2; exit 2; }
project_name="$2"
shift 2
;;
--)
shift
break
;;
-*)
echo "Error: unknown option: $1" >&2
usage >&2
exit 2
;;
*)
if [[ -n "$project_name" ]]; then
echo "Error: unexpected argument: $1" >&2
usage >&2
exit 2
fi
project_name="$1"
shift
;;
esac
done
if [[ $# -gt 0 ]]; then
echo "Error: unexpected argument: $1" >&2
usage >&2
exit 2
fi
[[ -n "$project_name" ]] || project_name="Untitled"
body=$(jq -nc --arg n "$project_name" '{project_name: $n}')
result=$(pexo_post "/api/biz/projects" "$body")
project_id=$(echo "$result" | jq -r '.projectId // empty')
if [[ -z "$project_id" ]]; then
echo 'Error: create project response missing projectId' >&2
echo "$result" >&2
exit 1
fi
printf '%s\n' "$project_id"
scripts/pexo-project-get.sh
#!/usr/bin/env bash
# If invoked with sh, re-exec with bash (this script uses bash-only syntax).
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
usage() {
cat <<'EOF'
Usage:
pexo-project-get.sh <project_id> [--full-history]
pexo-project-get.sh -h | --help
Description:
Fetch project state and derive nextAction for agent-side orchestration.
Options:
--full-history Return simplified full message history instead of nextAction view
Returns:
Default mode:
Project JSON with nextAction, nextActionHint, and recentMessages when action is needed.
FAILED may also include a machine-readable failureReason.
--full-history:
Project JSON with recentMessages for the full simplified history
Common errors:
401 Invalid API key or auth failure
404 Project not found
500 Backend/internal failure
EOF
}
# Get project details with next-action recommendation.
# Returns a clean project JSON with:
#
# nextAction — WAIT | RESPOND | CONFIRM | DELIVER | FAILED | RECONNECT
# nextActionHint — plain-language instruction for what to do next
# recentMessages — simplified last conversation round (when nextAction is RESPOND / DELIVER / FAILED / RECONNECT)
# failureReason — machine-readable remediation reason for recognized FAILED states
#
# Raw status fields (status / executionStatus / serviceStatus) and meaningless
# progress values (executionProgress / stepProgress) are stripped from output.
# Use nextAction for workflow branching. For FAILED, use failureReason to select
# the remediation when it is present.
#
# ── All (executionStatus × serviceStatus) combinations in practice ───────────
# executionStatus: IDLE (DB default / no progress yet), RUNNING, FAILED, INTERRUPTED, CONFIRM_REQUIRED.
# COMPLETED is not used in production (Agent does not send "finished").
# serviceStatus: IDLE (default or after ProcessExecution exits), PROCESSING (during ProcessExecution).
#
# | executionStatus | serviceStatus | Scenario | nextAction |
# |-----------------|---------------|----------|------------|
# | IDLE | IDLE | New project, or no active run; no message sent yet or previous run ended. | WAIT |
# | IDLE | PROCESSING | ProcessExecution just started, no progress event from Agent yet (brief). | WAIT |
# | RUNNING | IDLE | Run was reported RUNNING but worklet already exited (e.g. stream closed). Reconnect by sending a new message. | RECONNECT |
# | RUNNING | PROCESSING | Normal: Agent is producing, worklet is handling the stream. | WAIT |
# | INTERRUPTED | IDLE | Pexo waiting for input; no active ProcessExecution (user must send message or reconnect). | RESPOND |
# | INTERRUPTED | PROCESSING | Pexo waiting for input; ProcessExecution still open (stream waiting for reply). | RESPOND |
# | FAILED | IDLE | Run failed, worklet has exited. | FAILED |
# | FAILED | PROCESSING | Run failed, worklet defer not run yet (brief). | FAILED |
#
# nextAction mapping:
# FAILED — executionStatus=FAILED; recognized failures may include failureReason
# DELIVER — executionStatus=COMPLETED AND serviceStatus≠PROCESSING (COMPLETED not used in practice)
# RESPOND — executionStatus=INTERRUPTED
# CONFIRM — executionStatus=CONFIRM_REQUIRED and its latest history event is billing_confirmation
# RECONNECT — executionStatus=RUNNING AND serviceStatus=IDLE (should re-initiate conversation via pexo-chat.sh)
# WAIT — all other combinations
#
# recentMessages format (simplified, actionable-only):
# USER → {role, text}
# message → {role, event:"message", text}
# final_video → {role, event:"final_video", assetId}
# preview_video → {role, event:"preview_video", assetIds:[...]}
# document → {role, event:"document", documentType, documentName}
# attachment → {role, event:"attachment", assetIds:[...]}
# error → {role, event:"error", errorCode, errorMessage, toolCallId, terminal}
# (planning / progress / thinking / meta / voice etc. are omitted)
#
# Usage: pexo-project-get.sh <project_id> [--full-history]
source "$(dirname "$0")/_common.sh"
case "${1:-}" in
-h|--help)
usage
exit 0
;;
esac
if [[ $# -lt 1 ]]; then
usage >&2
exit 2
fi
pid="$1"
shift
full_history=false
while [[ $# -gt 0 ]]; do
case "$1" in
--full-history) full_history=true; shift ;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
# jq filter: simplify a raw messages array into actionable-only entries.
# ASSISTANT events not listed here (planning, progress, thinking, meta, voice…) are dropped.
_SIMPLIFY_MSGS='[.[] |
if (.role | ascii_downcase) == "user" then
{role: "USER", text: (.content.native_inputs.text // null)}
else
(.content.event // "") as $evt |
(.content.data // {}) as $d |
if $evt == "message" then {role: "ASSISTANT", event: "message", text: ($d.message // null)}
elif $evt == "final_video" then {role: "ASSISTANT", event: "final_video", assetId: ($d.final_video_id // null)}
elif $evt == "preview_video" then {role: "ASSISTANT", event: "preview_video", assetIds: ($d.preview_video_ids // [])}
elif $evt == "document" then {role: "ASSISTANT", event: "document", documentType: ($d.type // null), documentName: ($d.name // null)}
elif $evt == "attachment" then {role: "ASSISTANT", event: "attachment", assetIds: ($d.attachment_ids // [])}
elif $evt == "billing_confirmation" then {role: "ASSISTANT", event: "billing_confirmation", confirmation: $d}
elif $evt == "error" then {role: "ASSISTANT", event: "error", errorCode: ($d.error_code // null), errorMessage: ($d.error_message // $d.error // null), toolCallId: ($d.tool_call_id // null), terminal: (if $d.terminal == null then true else $d.terminal end)}
else empty
end
end
]'
_raw=$(pexo_get "/api/biz/projects/${pid}")
# Read status fields needed for nextAction logic before stripping them
exec_status=$(echo "$_raw" | jq -r '.executionStatus // ""')
svc_status=$(echo "$_raw" | jq -r '.serviceStatus // ""')
# Strip raw status fields and meaningless progress values from the output project object
project=$(echo "$_raw" | jq 'del(.status, .executionStatus, .serviceStatus, .executionProgress, .stepProgress)')
# ── Full history mode (bypass nextAction logic) ───────────────────────────────
if [[ "$full_history" == "true" ]]; then
history=$(pexo_get "/api/biz/projects/${pid}/history?page=1&page_size=200&sort_order=ASC")
raw_msgs=$(echo "$history" | jq 'if type == "array" then . else (.messages // []) end' 2>/dev/null || echo '[]')
messages=$(echo "$raw_msgs" | jq "$_SIMPLIFY_MSGS" 2>/dev/null || echo '[]')
echo "$project" | jq --argjson msgs "$messages" '. + {recentMessages: $msgs}'
exit 0
fi
# ── Determine nextAction from status fields ──────────────────────────────────
if [[ "$exec_status" == "FAILED" ]]; then
next_action="FAILED"
hint="Production failed. Read recentMessages for error details. Send a new message via pexo-chat.sh to retry with a modified brief."
failure_reason=""
elif [[ "$exec_status" == "COMPLETED" && "$svc_status" != "PROCESSING" ]]; then
next_action="DELIVER"
hint="Production complete. Find assetId in recentMessages[event=final_video], fetch it with pexo-asset-get.sh."
elif [[ "$exec_status" == "INTERRUPTED" ]]; then
next_action="RESPOND"
hint="Pexo is waiting for your input. Read recentMessages to understand what is needed, then call pexo-chat.sh to respond."
elif [[ "$exec_status" == "CONFIRM_REQUIRED" ]]; then
if confirmation=$(pexo_get_pending_billing_confirmation "$pid"); then
next_action="CONFIRM"
hint="Pexo is waiting for credit approval. Ask the user and call pexo-billing-confirm.sh with --user-approved only after explicit approval."
else
next_action="WAIT"
hint="Credit confirmation is being persisted. Poll again shortly."
fi
elif [[ "$exec_status" == "RUNNING" && "$svc_status" == "IDLE" ]]; then
next_action="RECONNECT"
hint="Connection may have been lost. Re-initiate the conversation by sending a new message via pexo-chat.sh."
else
# IDLE+IDLE, IDLE+PROCESSING, RUNNING+PROCESSING
next_action="WAIT"
hint="Production is in progress. Poll again in 60 seconds."
fi
# ── Fetch and simplify recentMessages when caller must act ───────────────────
if [[ "$next_action" == "CONFIRM" ]]; then
echo "$project" | jq \
--arg na "$next_action" \
--arg hint "$hint" \
--argjson confirmation "$confirmation" \
'. + {nextAction: $na, nextActionHint: $hint, confirmation: $confirmation}'
elif [[ "$next_action" == "RESPOND" || "$next_action" == "DELIVER" || "$next_action" == "FAILED" || "$next_action" == "RECONNECT" ]]; then
# Paginate DESC (newest first) until we find a page with a user message,
# then take from that user message to the top and reverse to chronological order.
page=1
page_size=50
accumulated='[]'
recent_raw='[]'
while true; do
resp=$(pexo_get "/api/biz/projects/${pid}/history?page=${page}&page_size=${page_size}&sort_order=DESC")
new_msgs=$(echo "$resp" | jq 'if type == "array" then . else (.messages // []) end' 2>/dev/null || echo '[]')
has_more=$(echo "$resp" | jq '.hasMore // false' 2>/dev/null)
accumulated=$(jq -n --argjson a "$accumulated" --argjson b "$new_msgs" '$a + $b' 2>/dev/null || echo '[]')
user_count=$(echo "$accumulated" | jq '[.[] | select((.role | ascii_downcase) == "user")] | length' 2>/dev/null || echo 0)
if [[ "${user_count:-0}" -gt 0 ]]; then
recent_raw=$(echo "$accumulated" | jq '
. as $all |
[range(length)] | map(select(($all[.].role | ascii_downcase) == "user")) |
if length > 0 then (first as $idx | $all[0:($idx+1)] | reverse)
else []
end
' 2>/dev/null || echo '[]')
break
fi
if [[ "$has_more" != "true" ]]; then
break
fi
page=$((page + 1))
done
recent=$(echo "$recent_raw" | jq "$_SIMPLIFY_MSGS" 2>/dev/null || echo '[]')
if [[ "$next_action" == "RESPOND" ]] && jq -e 'any(.[]; .event == "final_video" and (.assetId // "") != "")' >/dev/null 2>&1 <<<"$recent"; then
next_action="DELIVER"
hint="Production complete. Find assetId in recentMessages[event=final_video], fetch it with pexo-asset-get.sh."
fi
failure_reason="${failure_reason:-}"
if [[ "$next_action" == "FAILED" ]]; then
terminal_error_code=$(echo "$recent" | jq -r '[.[] | select(.event == "error" and .terminal != false)] | last | .errorCode // ""' 2>/dev/null || echo '')
if [[ "$terminal_error_code" == "credits.insufficient_credits_err" ]]; then
failure_reason="INSUFFICIENT_CREDITS"
hint="Production stopped because the account has insufficient credits. Tell the user that more credits are required. Do not retry until credits have been added."
fi
fi
echo "$project" | jq \
--arg na "$next_action" \
--arg hint "$hint" \
--arg failureReason "$failure_reason" \
--argjson msgs "$recent" \
'. + {nextAction: $na, nextActionHint: $hint, recentMessages: $msgs}
+ (if $failureReason == "" then {} else {failureReason: $failureReason} end)'
else
echo "$project" | jq \
--arg na "$next_action" \
--arg hint "$hint" \
'. + {nextAction: $na, nextActionHint: $hint}'
fi
scripts/pexo-project-list.sh
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
usage() {
cat <<'EOF'
Usage:
pexo-project-list.sh [page_size]
pexo-project-list.sh [--page <n>] [--page-size <n>]
pexo-project-list.sh -h | --help
Description:
List projects for the authenticated user.
Options:
--page <n> Page number (default: 1)
--page-size <n> Page size (default: 20, effective max: 100)
Returns:
Projects JSON from /api/biz/projects
Common errors:
401 Invalid API key or auth failure
500 Backend/internal failure
EOF
}
source "$(dirname "$0")/_common.sh"
page=1
page_size=20
legacy_page_size=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--page)
[[ $# -ge 2 ]] || { echo 'Error: --page requires a value' >&2; exit 2; }
page="$2"
shift 2
;;
--page-size)
[[ $# -ge 2 ]] || { echo 'Error: --page-size requires a value' >&2; exit 2; }
page_size="$2"
shift 2
;;
-*)
echo "Error: unknown option: $1" >&2
usage >&2
exit 2
;;
*)
if [[ -n "$legacy_page_size" ]]; then
echo "Error: unexpected argument: $1" >&2
usage >&2
exit 2
fi
legacy_page_size="$1"
shift
;;
esac
done
if [[ -n "$legacy_page_size" ]]; then
page_size="$legacy_page_size"
fi
if [[ ! "$page" =~ ^[0-9]+$ || "$page" == "0" ]]; then
echo "Error: page must be a positive integer: $page" >&2
exit 2
fi
if [[ ! "$page_size" =~ ^[0-9]+$ || "$page_size" == "0" ]]; then
echo "Error: page_size must be a positive integer: $page_size" >&2
exit 2
fi
pexo_get "/api/biz/projects?page=${page}&page_size=${page_size}"
scripts/pexo-upload.sh
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
usage() {
cat <<'EOF'
Usage:
pexo-upload.sh <project_id> <file_path>
pexo-upload.sh -h | --help
Description:
Upload a local media file to a project in three steps:
1. Request upload credential
2. PUT bytes to the presigned URL
3. Finalize the asset
Supported file types:
Images: jpg, jpeg, png, webp, bmp, tiff, heic, heif
Videos: mp4, mov, avi
Audio: mp3, wav, aac, m4a, ogg, flac
Returns:
asset_id string on stdout
Common errors:
400 Invalid file metadata or unsupported media type
401 Invalid API key or auth failure
404 Asset not found during finalize
412 Asset is no longer in UPLOADING state during finalize
500 Upload credential/finalize backend failure
EOF
}
source "$(dirname "$0")/_common.sh"
case "${1:-}" in
-h|--help)
usage
exit 0
;;
esac
if [[ $# -ne 2 ]]; then
usage >&2
exit 2
fi
pid="$1"
filepath="$2"
[[ -f "$filepath" ]] || { echo "Error: file not found: $filepath" >&2; exit 1; }
filename=$(basename "$filepath")
filesize=$(stat -f%z "$filepath" 2>/dev/null || stat -c%s "$filepath" 2>/dev/null)
asset_type=$(detect_asset_type "$filename")
mime_type=$(detect_mime "$filepath")
finalize_mime_type="$mime_type"
[[ -n "${filesize:-}" ]] || { echo "Error: failed to determine file size: $filepath" >&2; exit 1; }
[[ "$asset_type" != "UNKNOWN" ]] || {
echo "Error: unsupported file type: $filename" >&2
echo "Allowed: jpg jpeg png webp bmp tiff heic heif mp4 mov avi mp3 wav aac m4a ogg flac" >&2
exit 1
}
if ! mime_supported_for_asset_type "$mime_type" "$asset_type"; then
finalize_mime_type=""
fi
# Phase 1: get upload credential
cred=$(pexo_post "/api/biz/projects/${pid}/assets/upload-credential" \
"{\"file_name\":\"$filename\",\"file_size\":$filesize}")
upload_url=$(echo "$cred" | jq -r '.uploadUrl')
asset_id=$(echo "$cred" | jq -r '.assetId')
storage_path=$(echo "$cred" | jq -r '.storagePath')
[[ -n "$upload_url" && "$upload_url" != "null" ]] || { echo "Error: failed to get upload credential" >&2; echo "$cred" >&2; exit 1; }
[[ -n "$asset_id" && "$asset_id" != "null" ]] || { echo "Error: upload credential missing assetId" >&2; echo "$cred" >&2; exit 1; }
[[ -n "$storage_path" && "$storage_path" != "null" ]] || { echo "Error: upload credential missing storagePath" >&2; echo "$cred" >&2; exit 1; }
# Phase 2: PUT raw bytes to presigned URL
http_code=$(curl -sS -X PUT -H "Content-Type: $mime_type" \
--data-binary "@$filepath" -o /dev/null -w '%{http_code}' "$upload_url" 2>/dev/null || echo "000")
[[ "$http_code" =~ ^2 ]] || { echo "Error: upload failed with HTTP $http_code" >&2; exit 1; }
# Phase 3: finalize
finalize_body=$(jq -nc \
--arg name "$filename" \
--arg type "$asset_type" \
--arg fname "$filename" \
--argjson size "$filesize" \
--arg mime "$finalize_mime_type" \
--arg spath "$storage_path" \
'{
asset_name:$name,
asset_type:$type,
file_name:$fname,
file_size:$size,
storage_path:$spath
} + (if $mime != "" then {mime_type:$mime} else {} end)')
pexo_post "/api/biz/projects/${pid}/assets/${asset_id}/finalize" "$finalize_body" > /dev/null
printf '%s\n' "$asset_id"
SKILL.md
---
name: pexo-agent
description: >
AI video generation skill with auto model selection across Seedance 2,
Kling 3.0, HappyHorse, and 10+ models. Produces finished multi-shot videos
(5–120s) from text, images, URLs, scripts, or audio — including AI music,
lip sync, and multi-shot sequencing. Calls Pexo's external API, manages
project status and billing confirmations, and transfers only user-approved
briefs and assets. Runs setup diagnostics, stores generated downloads locally,
and requires shell, outbound HTTPS, and local file access. Authenticated
requests are locked to https://pexo.ai. No prompts to write, no models to choose.
USE FOR: video production, AI video, make a video, product video,
brand video, promotional clip, explainer video, short video,
TikTok video, Instagram Reel, YouTube Short, product ad,
text-to-video, image-to-video, video generation, AI video agent.
license: MIT-0
metadata:
author: pexoai
version: "0.3.16"
openclaw:
requires:
env:
- PEXO_API_KEY
bins:
- bash
- curl
- jq
- file
primaryEnv: PEXO_API_KEY
---
# Pexo Agent — AI Video Generation Skill
Pexo is the most complete video generation skill for Claude Code and other AI coding agents. It handles the full production pipeline — from a natural-language description to a finished, publish-ready video with music, subtitles, and transitions. Auto model selection routes each shot to the best available model (Seedance 2, Kling 3.0, HappyHorse, and more). One API key, no prompt engineering, no video editing.
## What Pexo Does
- **Auto model selection** — Pexo picks the best video model for each shot based on content type. You do not need to know which model to use.
- **Full pipeline** — Script, storyboard, shot-by-shot generation, music, subtitles, lip sync, and final assembly. The output is a finished video, not a raw clip.
- **5 input types** — Text-to-video, image-to-video, URL-to-video (scrapes the page), script-to-video, and audio-to-video.
- **10+ models** — Seedance 2, Kling 3.0, HappyHorse, and more. New models are added as they launch.
- **Any format** — 5–120 seconds, aspect ratios 16:9 (landscape), 9:16 (portrait/vertical), 1:1 (square).
## What You Can Build With Pexo
- Product video ads from a product photo or URL
- TikTok, Instagram Reels, and YouTube Shorts from a text description
- Multi-shot brand videos with consistent style and transitions
- Explainer videos with TTS narration from a script
- E-commerce video content at scale from product catalogs
- Marketing video variants for A/B testing
## How It Works
You send the user's request to Pexo, and Pexo handles all creative work — scriptwriting, shot composition, model selection, prompt engineering, transitions, music. Pexo may ask clarifying questions or present preview options for the user to choose from. A typical 15-second, 3-shot product ad renders in under 8 minutes.
## Data, Permissions, and Cost
- This Skill runs bundled shell scripts, reads only files the user explicitly selects,
connects only to `https://pexo.ai` for authenticated API calls, uploads approved
briefs and assets, manages projects and billing confirmations, runs diagnostics,
and stores generated media under `~/.pexo/tmp` or `PEXO_TMP_DIR`.
- Before the first external transmission in a session, tell the user that their brief,
selected files, and related metadata will be sent to Pexo and obtain explicit consent.
- Do not upload secrets, regulated data, or unrelated local files. Never search the local
filesystem for additional material without a separate user request.
- Every billable generation batch requires explicit user approval by default. Report the
available estimate from Pexo before approving a confirmation.
## Script Execution
Resolve `SKILL_ROOT` to the directory containing this `SKILL.md`. Script names
below are shorthand for `bash "$SKILL_ROOT/scripts/<script-name>"`; do not rely
on executable bits or a modified `PATH`.
## Prerequisites
Config file `~/.pexo/config`:
```bash
umask 077
mkdir -p ~/.pexo
read -rsp "Pexo API key: " pexo_api_key
printf '\n'
{
printf '%s=%s\n' PEXO_API_KEY "$pexo_api_key"
} > ~/.pexo/config
unset pexo_api_key
chmod 600 ~/.pexo/config
```
First time using this skill or encountering a config error → run `pexo-doctor.sh` and follow its output. See `references/SETUP-CHECKLIST.md` for details.
### Credit Confirmation Preference
`PEXO_BILLING_CONFIRMATION_MODE` controls the confirmation behavior for each message sent by this Skill. It is optional; the default is `always`.
- `always`: ask for approval before every billable generation batch.
- `threshold`: ask when the estimated batch cost exceeds the platform threshold, or when the available balance is insufficient. Use only after the user explicitly opts in for the current session.
Use `pexo-chat.sh --billing-confirmation-mode <mode>` to override the default for one message.
---
## ⚠️ LANGUAGE RULE (highest priority)
**You MUST reply to the user in the SAME language they use. This is non-negotiable.**
- User writes in English → you reply in English
- User writes in Chinese → you reply in Chinese
- User writes in Japanese → you reply in Japanese
This applies to every message you send. If the user switches language mid-conversation, you switch too.
---
## Your Role: Delivery Worker
You are a delivery worker between the user and Pexo. You do three things:
1. **Upload**: user gives a file → `pexo-upload.sh` → get asset ID
2. **Relay**: copy the user's words into `pexo-chat.sh`
3. **Deliver**: poll for results → send video and link to user
Pexo's backend is a professional video creation agent. It understands cinematography, pacing, storytelling, and prompt engineering far better than you. When you add your own creative ideas, the video quality goes down.
### How to relay messages — copy-paste template
When calling pexo-chat.sh, copy the user's message exactly:
```
pexo-chat.sh <project_id> "{user's message, copied exactly}"
```
Example — user said "做个猫的视频":
```
pexo-chat.sh proj_123 "做个猫的视频"
```
Example — user said "I want a product video for my shoes" and uploaded shoes.jpg:
```
asset_id=$(pexo-upload.sh proj_123 shoes.jpg)
pexo-chat.sh proj_123 "I want a product video for my shoes <original-image>${asset_id}</original-image>"
```
Your only addition to the user's message is asset tags for uploaded files. Everything else stays exactly as the user wrote it.
### When the user's request is vague
Pass it to Pexo exactly as-is. Pexo will ask the user for any missing details. Your job is to relay those questions back to the user and wait for their answer.
### Why this matters
Pexo's backend agent specializes in video production. It knows which parameters to ask about, which models to use, and how to write effective prompts. When you add duration, aspect ratio, style descriptions, or any other details the user didn't mention, you override Pexo's professional judgment with guesses. This produces worse videos.
---
## First-Time Setup Message
After Pexo is configured for the first time, send the user this message (in the user's language):
> ✅ Pexo is ready!
> 📖 Guide: https://pexo.ai/connect/openclaw
> Tell me what video you'd like to make.
---
## Step-by-Step Workflow
Follow these steps in order.
### Making a New Video
```
Step 1. Create project.
Run: pexo-project-create.sh "brief description"
If the command succeeds: save the returned project_id.
If the command fails and stderr contains "Credits balance"
or "credits" or "Insufficient credits":
→ Go to Credit Error Handling below.
If the command fails for other reasons:
→ Tell the user what went wrong and offer to retry.
Step 2. Upload files (if user provided any images/videos/audio).
Run: pexo-upload.sh <project_id> <file_path>
Save the returned asset_id.
Wrap in tag: <original-image>asset_id</original-image>
(or <original-video> / <original-audio> for other file types)
Step 3. Send user's message to Pexo.
Run: pexo-chat.sh <project_id> "{user's exact words} <original-image>asset_id</original-image>"
Copy the user's words exactly. Only add asset tags for uploaded files.
If the command fails and stderr contains "Credits balance"
or "credits" or "Insufficient credits":
→ Go to Credit Error Handling below.
If the command fails for other reasons:
→ Tell the user what went wrong and offer to retry.
Step 4. Notify the user (in the user's language).
Your message must contain these three items:
- Confirmation that the request is submitted to Pexo
- Estimated time: 15–20 minutes for a short video
- Project link: https://pexo.ai/project/{project_id}
Step 5. Poll for status.
Run: sleep 60
Run: pexo-project-get.sh <project_id>
Read the nextAction field from the returned JSON.
Continue to Step 6.
Step 6. Act on nextAction:
"WAIT" →
Go back to Step 5. Keep repeating.
Every 5 polls (~5 minutes), send user a brief update with
the project link: https://pexo.ai/project/{project_id}
"CONFIRM" →
Read the confirmation object. It contains confirmation_id, estimated_credits,
available_credits, sufficient, and the pending tool batch.
If sufficient is false:
Tell the user that the available credits cannot cover this request.
Go to Credit Error Handling below. Do not run pexo-billing-confirm.sh.
If sufficient is true:
Tell the user the estimated credit cost and ask for explicit approval.
Do not approve on the user's behalf.
After explicit approval:
Run: pexo-billing-confirm.sh <project_id> <confirmation_id> --user-approved
Go back to Step 5.
If the user changes the request instead:
Run: pexo-chat.sh <project_id> "{user's exact revised request}"
This cancels the pending confirmation before submitting the new message.
Go back to Step 5.
"RESPOND" →
Read the recentMessages array. Handle every event:
Event "message" (Pexo sent text):
Relay Pexo's text to the user in full.
If Pexo asked a question, wait for the user's answer.
Then run: pexo-chat.sh <project_id> "{user's exact answer}"
Go back to Step 5.
Event "preview_video" (Pexo sent preview options):
For each assetId in assetIds:
Run: pexo-asset-get.sh <project_id> <assetId>
Copy the "url" field from the returned JSON.
Show all preview URLs to the user with labels (A, B, C...).
Ask the user to pick one.
After user picks:
Run: pexo-chat.sh <project_id> "{user's choice}" --choice <selected_asset_id>
Go back to Step 5.
Event "document":
Mention the document to the user.
Event "attachment":
Fetch each assetId with pexo-asset-get.sh and deliver the resulting file or URL.
"DELIVER" →
Go to Step 7.
"FAILED" →
Go to Step 8.
"RECONNECT" →
Run: pexo-chat.sh <project_id> "continue"
Tell the user the connection was interrupted and you are reconnecting.
Go back to Step 5.
Step 7. Deliver the final video.
7a. Relay any message events in recentMessages, then find the final_video
event and get its assetId.
7b. Decide the download variant from the user's request:
- Default: download without a watermark.
- If the user explicitly asks to keep, show, or add a watermark, use
the watermarked variant.
- Both variants require an active subscription or watermark whitelist.
7c. Run one of:
- pexo-asset-get.sh <project_id> <assetId>
(default, no watermark)
- pexo-asset-get.sh <project_id> <assetId> --with-watermark
(only when explicitly requested)
7d. Show the downloaded video file to the user.
7e. Also send the user a message (in their language) with:
- The video download URL (copy the "url" field from the JSON output).
Send the FULL URL as plain text, including all query parameters.
Example:
https://pexo-assets.oss-us-east-1.aliyuncs.com/projects%2F123%2Fassets%2Fvideo.mp4?OSSAccessKeyId=xxx&Expires=xxx&Signature=xxx
- Project page: https://pexo.ai/project/{project_id}
- Ask if satisfied or want revisions.
Common delivery mistakes to avoid:
✗ Truncated URL (missing ?OSSAccessKeyId=...&Signature=...) → 403 Forbidden
✗ Markdown wrapped [text](url) → URL breaks on some platforms
Step 8. Handle failure.
8a. Read failureReason, nextActionHint, and recentMessages from the JSON.
8b. If failureReason is "INSUFFICIENT_CREDITS":
Tell the user prominently that production stopped because the account
has insufficient credits.
Go to Credit Error Handling below. Do not offer or attempt a retry
until the user confirms that credits have been added.
Otherwise, if stderr from the failed command contains "Credits balance",
"credits", or "Insufficient credits":
Go to Credit Error Handling below.
Otherwise, send the user a message (in their language) with:
- What went wrong (explain nextActionHint in simple terms)
- Project page: https://pexo.ai/project/{project_id}
- Offer to retry.
Step 9. Timeout.
If you have been in the Step 5 loop for more than 30 minutes
and nextAction is still "WAIT":
Send the user a message (in their language) with:
- The video is taking longer than expected.
- Project page: https://pexo.ai/project/{project_id}
- Help guide: https://pexo.ai/connect/openclaw
- Ask whether to keep waiting or start over.
Stop polling. Wait for user instructions.
```
### Credit Error Handling
Use this flow when `pexo-project-get.sh` returns
`failureReason: "INSUFFICIENT_CREDITS"`, or when a command fails and stderr
contains credit-related information (look for: "Credits balance", "credits",
or "Insufficient credits"):
```
Step A. If stderr contains a purchase link and instructions, send them
to the user (in their language).
Step B. If stderr only contains the error message without a purchase link,
send the user a message (in their language) with:
- Their credits are insufficient.
- To add credits: visit https://pexo.ai/home?billing=credits
and complete the purchase flow.
Step C. After the user confirms they have added credits, retry the failed step.
```
### Revising an Existing Video
```
Step 1. Use the same project_id.
Step 2. Run: pexo-chat.sh <project_id> "{user's exact feedback}"
Step 3. Go to Step 5 of the main workflow (start polling).
```
---
## Asset Upload
Pexo can process a public `https://` webpage URL when it is included verbatim in the user's
brief. Pass that webpage URL to Pexo; do not scrape or download the page locally.
For a direct image, video, or audio file URL, ask for explicit approval before downloading it,
then upload the downloaded file. Only fetch public `https://` URLs. Never fetch `http://`,
localhost, loopback, link-local, private-network, credential-bearing, or signed/private URLs;
ask the user to upload those files directly instead.
Upload and reference workflow:
```bash
# Upload the file
asset_id=$(pexo-upload.sh <project_id> photo.jpg)
# Reference the asset in your message to Pexo
pexo-chat.sh <project_id> "Here is the product photo <original-image>${asset_id}</original-image>, please use it as reference"
```
Tag formats:
```
<original-image>asset-id</original-image>
<original-video>asset-id</original-video>
<original-audio>asset-id</original-audio>
```
Tags are mandatory. Bare asset IDs in pexo-chat.sh messages are ignored by Pexo.
---
## Important Rules
### Polling
- During WAIT: only call pexo-project-get.sh. Calling pexo-chat.sh during WAIT triggers duplicate video production.
- Wait at least 60 seconds between each pexo-project-get.sh call.
- Process every event in recentMessages, not just the first one.
### Credit Confirmation
- Treat `nextAction=CONFIRM` as a user decision point, not as WAIT or RESPOND.
- Only run `pexo-billing-confirm.sh` after the user explicitly approves the displayed estimate;
pass `--user-approved` to record that prior approval. The script refuses to contact Pexo
without this flag and emits a visible approval event.
- Use the `confirmation_id` returned by `pexo-project-get.sh`; confirmation IDs apply only to the current pending batch.
- A revised message sent with `pexo-chat.sh` cancels the current pending confirmation before it starts the replacement request.
### Delivery
- Copy the "url" field from pexo-asset-get.sh output. Send it as plain text with all query parameters.
- Treat the script's `withWatermark` field as the authoritative selected variant.
- Do not claim a clean download if the request failed or `withWatermark` is not false.
- Show the downloaded video file to the user when possible.
### Projects
- New video → pexo-project-create.sh to create a new project.
- Revisions → reuse the existing project_id.
### Cost
- Each message to Pexo costs tokens. Consolidate information into one message when possible.
- For `nextAction=FAILED`, use `failureReason` for remediation. Do not infer a failure category from `nextActionHint` text.
---
## Script Reference
| Script | Usage | Returns |
|---|---|---|
| `pexo-project-create.sh` | `[project_name]` or `--name <n>` | `project_id` string. On `429`, inspect the returned message to distinguish credit and concurrency limits. |
| `pexo-project-list.sh` | `[page_size]` or `--page <n> --page-size <n>` | Projects JSON |
| `pexo-project-get.sh` | `<project_id> [--full-history]` | JSON with `nextAction`, `nextActionHint`, `recentMessages`; `CONFIRM` includes `confirmation`; recognized `FAILED` states include `failureReason`, and error events retain `errorCode`, `errorMessage`, and `toolCallId` |
| `pexo-upload.sh` | `<project_id> <file_path>` | `asset_id` string |
| `pexo-chat.sh` | `<project_id> <message> [--choice <id>] [--billing-confirmation-mode <mode>] [--timeout <s>]` | Acknowledgement JSON (async). A new message cancels a pending confirmation. On `429`/`412` or credit errors, error info printed to stderr. |
| `pexo-billing-confirm.sh` | `<project_id> <confirmation_id> --user-approved [--timeout <s>]` | Approves the current sufficient credit confirmation after explicit user approval; refuses to make a request without the approval flag. |
| `pexo-asset-get.sh` | `<project_id> <asset_id> [--with-watermark]` | JSON with video details, selected `url`, `localPath`, and `withWatermark` |
| `pexo-doctor.sh` | (no args) | Diagnostic report |
---
## Pexo Capabilities
- Output: 5–120 second finished videos with music, subtitles, and transitions
- Aspect ratios: 16:9 (landscape), 9:16 (portrait/vertical for TikTok, Reels, Shorts), 1:1 (square)
- Auto model selection: Seedance 2, Kling 3.0, HappyHorse, and more — Pexo picks the best model per shot
- Input types: text, images, URLs, scripts, audio
- Production time: ~8 minutes for a 15-second 3-shot video, ~20 minutes for a 60-second brand video
- Supported uploads: Images (jpg, png, webp, bmp, tiff, heic), Videos (mp4, mov, avi), Audio (mp3, wav, aac, m4a, ogg, flac)
- Post-production: AI music, TTS narration, voice cloning, lip sync, subtitles, transitions
---
## References
Load these when needed:
- **First time or config error** → read `references/SETUP-CHECKLIST.md`
- **Error codes or failures** → read `references/TROUBLESHOOTING.md`