Quickstart
Create your first job, poll it safely, and print a useful sales result.
This guide takes you from a project API key to a business summary and sales angle. You do not need another guide to complete the flow.
It uses Playbooks as the example capability. Every capability in the Build section follows the same pattern shown here — authenticate with a project key, create a job, poll it to a terminal state — so this flow transfers directly.
Working from an AI coding agent such as Claude Code, Codex, or Cursor? Connect the read-only documentation MCP server and hand your agent the Playbooks API primer — setup commands are in AI and machine-readable documentation.
Prerequisites#
- In the console, create or select a development project. A project owns its keys, usage, quotas, and budget.
- Open API keys, create a key for that project, and copy the secret when it is shown. It cannot be retrieved later.
- Install
curlandjq. - Export the configured API URL and your key:
export BLACKPEARL_API_BASE_URL="https://api.blackpearl.com"
export BLACKPEARL_API_KEY="replace-with-your-project-key"
export BLACKPEARL_IDEMPOTENCY_KEY="playbook-$(date +%s)-$$"Keep keys in a server-side secret manager. Do not commit them or embed them in browser or mobile code. A hosted development-project request performs real work and records real usage; a key prefix does not create a simulation mode.
1. Create the smallest useful Playbooks job#
The only input requirement is a non-empty target_company, a valid target_company_domain, or both. This request uses the domain:
CREATE_RESPONSE=$(curl --fail-with-body --silent --show-error \
--request POST "$BLACKPEARL_API_BASE_URL/v1/playbooks" \
--header "Authorization: Bearer $BLACKPEARL_API_KEY" \
--header "Idempotency-Key: $BLACKPEARL_IDEMPOTENCY_KEY" \
--header "Content-Type: application/json" \
--data '{"target_company_domain":"acme.example"}')
printf '%s\n' "$CREATE_RESPONSE" | jq .The API returns 202 Accepted. The response has this exact field structure; IDs and timestamps are generated for your request:
{
"id": "job_example",
"status": "queued",
"progress": 0,
"project_id": "proj_example",
"campaign_id": null,
"type": "playbooks",
"input": {
"target_company": null,
"target_company_domain": "acme.example",
"anchor_company": null,
"product_info": null,
"tone": "consultative",
"sections": null,
"model": "default",
"brand_profile_id": null,
"brand_profile_key": null
},
"result": null,
"error": null,
"usage": {
"tokens": 0,
"cost_usd": 0.0,
"backend_requests": 0,
"backend_runtime_ms": 0,
"candidates": null
},
"stages": null,
"created_at": "2026-07-13T12:00:00Z",
"expires_at": "2026-08-12T12:00:00Z",
"started_at": null,
"finished_at": null
}idis the durable job identifier. Persist it before polling.Idempotency-Keymakes an ambiguous create safe to repeat with the exact same input for 24 hours.statusisqueuedorrunningwhile active, thensucceeded,failed, orcanceled.progressis a coarse percentage from 0 through 100.project_ididentifies the project charged for the work; Playbooks has nocampaign_id.inputis the normalized request snapshot. Defaults appear even when you omit them.resultremainsnulluntil success.errorremainsnullunless the job fails.usageis the work attributed to this job.stagesisnullfor Playbooks.- The timestamps show when the job was accepted, when its 30-day retention window ends, when execution started, and when it reached a terminal state.
The response conforms to OpenAPI schema PublicPlaybookJobResponse; the request conforms to OpenAPI schema PlaybookInput. Its OpenAPI operation is create_playbook_v1_playbooks_post.
2. Persist and poll with a deadline#
This shell flow resumes an existing job from .blackpearl-playbooks-job and creates one only when no saved ID exists. It polls with capped backoff, stops at every terminal state, and leaves the ID on disk when the local deadline expires.
STATE_FILE="${BLACKPEARL_JOB_STATE_FILE:-.blackpearl-playbooks-job}"
IDEMPOTENCY_FILE="${STATE_FILE}.idempotency"
DEADLINE_SECONDS="${BLACKPEARL_POLL_DEADLINE_SECONDS:-120}"
if [ -s "$STATE_FILE" ]; then
JOB_ID=$(tr -d '\r\n' < "$STATE_FILE")
else
if [ ! -s "$IDEMPOTENCY_FILE" ]; then
printf 'playbook-%s-%s\n' "$(date +%s)" "$$" > "$IDEMPOTENCY_FILE"
fi
IDEMPOTENCY_KEY=$(tr -d '\r\n' < "$IDEMPOTENCY_FILE")
CREATE_RESPONSE=$(curl --fail-with-body --silent --show-error \
--request POST "$BLACKPEARL_API_BASE_URL/v1/playbooks" \
--header "Authorization: Bearer $BLACKPEARL_API_KEY" \
--header "Idempotency-Key: $IDEMPOTENCY_KEY" \
--header "Content-Type: application/json" \
--data '{"target_company_domain":"acme.example"}') || exit 1
JOB_ID=$(printf '%s' "$CREATE_RESPONSE" | jq -er '.id') || exit 1
printf '%s\n' "$JOB_ID" > "$STATE_FILE"
fi
deadline=$((SECONDS + DEADLINE_SECONDS))
delay=2
while [ "$SECONDS" -lt "$deadline" ]; do
JOB=$(curl --fail-with-body --silent --show-error \
"$BLACKPEARL_API_BASE_URL/v1/jobs/$JOB_ID" \
--header "Authorization: Bearer $BLACKPEARL_API_KEY") || exit 1
STATUS=$(printf '%s' "$JOB" | jq -er '.status') || exit 1
printf 'status=%s progress=%s%%\n' "$STATUS" "$(printf '%s' "$JOB" | jq -r '.progress')"
case "$STATUS" in
succeeded)
printf 'Business summary: %s\n' "$(printf '%s' "$JOB" | jq -er '.result.business_summary')"
printf 'First sales angle: %s\n' "$(printf '%s' "$JOB" | jq -er '.result.sales_angles[0]')"
rm -f "$STATE_FILE"
rm -f "$IDEMPOTENCY_FILE"
exit 0
;;
failed)
printf 'Playbooks job failed: %s\n' "$(printf '%s' "$JOB" | jq -r '.error // "No failure message returned"')" >&2
rm -f "$STATE_FILE"
rm -f "$IDEMPOTENCY_FILE"
exit 1
;;
canceled)
printf 'Playbooks job was canceled.\n' >&2
rm -f "$STATE_FILE"
rm -f "$IDEMPOTENCY_FILE"
exit 1
;;
queued|running) ;;
*) printf 'Unknown job status: %s\n' "$STATUS" >&2; exit 1 ;;
esac
sleep "$delay"
if [ "$delay" -lt 10 ]; then delay=$((delay * 2)); fi
if [ "$delay" -gt 10 ]; then delay=10; fi
done
printf 'Polling deadline reached; resume job %s using %s.\n' "$JOB_ID" "$STATE_FILE" >&2
exit 2A client timeout does not cancel server work. Keep the job ID and resume later. Job polling is rate-limited but is not billed as another Playbooks generation request.
The API retains the job and result through expires_at, 30 days after creation. Store any result your application needs beyond that timestamp. After expiry, job and candidate reads return 410 job_expired; expiry does not remove usage, cost, or audit records.
If the create connection is lost before you receive the job ID, repeat the exact POST with the key saved in IDEMPOTENCY_FILE. A matching request returns the original 202 body and does not create or bill another job. Do not change the input while reusing a key, and do not expect replay after its 24-hour window.
Expected output#
A successfully configured Playbooks backend produces output with this structure; generated content varies by request and backend:
status=running progress=55%
status=succeeded progress=100%
Business summary: <generated business summary>
First sales angle: <generated sales angle>An unconfigured backend fails the Job instead of returning synthetic content.
Next steps#
- Run the tested Python or TypeScript quickstart when moving this flow into an application.
- Learn how to store and resume jobs in Jobs and polling.
- Add seller context and choose sections in Playbooks.
- Handle API failures by
error.codeusing the error and retry matrix.
Python and TypeScript versions#
The same flow ships as tested, dependency-light programs. The complete sources are embedded below; in the repository they live under docs/examples/. Both read only BLACKPEARL_API_BASE_URL and BLACKPEARL_API_KEY for credentials and connection details.
Run the standard-library-only script:
python3 docs/examples/python/playbooks_quickstart.py#!/usr/bin/env python3
"""Minimal, dependency-free Playbooks quickstart.
Required environment:
BLACKPEARL_API_BASE_URL Configured gateway URL, for example http://localhost:8400
BLACKPEARL_API_KEY Project-scoped API key
"""
from __future__ import annotations
import json
import os
import random
import sys
import time
import uuid
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
TERMINAL = {"succeeded", "failed", "canceled"}
TRANSIENT_READ_CODES = {
"rate_limit_exceeded",
"internal_error",
"audiences_backend_error",
"audiences_backend_invalid_response",
"audiences_backend_unavailable",
}
class APIError(RuntimeError):
def __init__(self, status: int, code: str, message: str, retry_after: str | None = None):
super().__init__(f"API error {status} {code}: {message}")
self.status = status
self.code = code
self.retry_after = retry_after
def required_env(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError(f"Set {name} before running this example")
return value
def request_json(
method: str,
url: str,
api_key: str,
body: dict[str, Any] | None = None,
extra_headers: dict[str, str] | None = None,
) -> tuple[dict[str, Any], Any]:
payload = json.dumps(body).encode() if body is not None else None
request = Request(
url,
data=payload,
method=method,
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
**({"Content-Type": "application/json"} if body is not None else {}),
**(extra_headers or {}),
},
)
try:
with urlopen(request, timeout=30) as response:
return json.load(response), response.headers
except HTTPError as exc:
try:
payload = json.loads(exc.read())
error = payload.get("error", {})
except (json.JSONDecodeError, AttributeError):
error = {}
raise APIError(
exc.code,
str(error.get("code", "http_error")),
str(error.get("message", exc.reason)),
exc.headers.get("Retry-After"),
) from exc
except URLError as exc:
raise RuntimeError(f"Could not reach the configured API: {exc.reason}") from exc
def persist_job_id(path: Path, job_id: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"{job_id}\n", encoding="utf-8")
def persistent_idempotency_key(path: Path) -> str:
if path.exists() and path.read_text(encoding="utf-8").strip():
return path.read_text(encoding="utf-8").strip()
key = f"playbook-{uuid.uuid4()}"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"{key}\n", encoding="utf-8")
return key
def create_or_resume(base_url: str, api_key: str, state_path: Path) -> str:
if state_path.exists() and state_path.read_text(encoding="utf-8").strip():
job_id = state_path.read_text(encoding="utf-8").strip()
print(f"Resuming Playbooks job {job_id}")
return job_id
# Persist the key before sending. If the connection fails ambiguously, rerun
# with this same request/key during the 24-hour replay window.
idempotency_key = persistent_idempotency_key(
state_path.with_name(f"{state_path.name}.idempotency")
)
job, _ = request_json(
"POST",
f"{base_url}/v1/playbooks",
api_key,
{"target_company_domain": "acme.example"},
{"Idempotency-Key": idempotency_key},
)
job_id = job.get("id")
if job.get("type") != "playbooks" or not isinstance(job_id, str) or not job_id:
raise RuntimeError("Create response did not contain a typed Playbooks job ID")
persist_job_id(state_path, job_id)
print(f"Created Playbooks job {job_id}")
return job_id
def poll_job(base_url: str, api_key: str, job_id: str) -> dict[str, Any]:
timeout = float(os.environ.get("BLACKPEARL_POLL_DEADLINE_SECONDS", "120"))
delay = float(os.environ.get("BLACKPEARL_POLL_INITIAL_SECONDS", "2"))
max_delay = float(os.environ.get("BLACKPEARL_POLL_MAX_SECONDS", "10"))
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
job, _ = request_json("GET", f"{base_url}/v1/jobs/{job_id}", api_key)
except APIError as exc:
if exc.code not in TRANSIENT_READ_CODES:
raise
if exc.code == "rate_limit_exceeded" and exc.retry_after:
delay = max(delay, float(exc.retry_after))
time.sleep(min(delay, max(0.0, deadline - time.monotonic())))
delay = min(max_delay, max(delay * 2, 0.01))
continue
status = job.get("status")
if status not in {"queued", "running", *TERMINAL}:
raise RuntimeError(f"Unknown job status: {status!r}")
print(f"status={status} progress={job.get('progress')}%")
if status in TERMINAL:
return job
jittered = delay * random.uniform(0.8, 1.2)
time.sleep(min(jittered, max(0.0, deadline - time.monotonic())))
delay = min(max_delay, max(delay * 2, 0.01))
raise TimeoutError(f"Polling deadline reached; resume job {job_id} from the saved state file")
def render_result(job: dict[str, Any]) -> None:
status = job["status"]
if status == "failed":
raise RuntimeError(f"Playbooks job failed: {job.get('error') or 'No failure message returned'}")
if status == "canceled":
raise RuntimeError("Playbooks job was canceled")
if job.get("type") != "playbooks" or status != "succeeded":
raise RuntimeError("Expected a successful Playbooks job")
result = job.get("result")
if not isinstance(result, dict):
raise RuntimeError("Successful Playbooks job did not contain a typed result")
summary = result.get("business_summary")
angles = result.get("sales_angles")
if not isinstance(summary, str) or not summary or not isinstance(angles, list) or not angles or not isinstance(angles[0], str):
raise RuntimeError("Playbooks result did not contain a useful business summary and sales angle")
print(f"Business summary: {summary}")
print(f"First sales angle: {angles[0]}")
def main() -> int:
base_url = required_env("BLACKPEARL_API_BASE_URL").rstrip("/")
api_key = required_env("BLACKPEARL_API_KEY")
state_path = Path(os.environ.get("BLACKPEARL_JOB_STATE_FILE", ".blackpearl-playbooks-job"))
try:
job_id = create_or_resume(base_url, api_key, state_path)
job = poll_job(base_url, api_key, job_id)
render_result(job)
state_path.unlink(missing_ok=True)
state_path.with_name(f"{state_path.name}.idempotency").unlink(missing_ok=True)
return 0
except Exception as exc:
print(str(exc), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())