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.

Note

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#

  1. In the console, create or select a development project. A project owns its keys, usage, quotas, and budget.
  2. Open API keys, create a key for that project, and copy the secret when it is shown. It cannot be retrieved later.
  3. Install curl and jq.
  4. 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)-$$"
Warning

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
}
  • id is the durable job identifier. Persist it before polling.
  • Idempotency-Key makes an ambiguous create safe to repeat with the exact same input for 24 hours.
  • status is queued or running while active, then succeeded, failed, or canceled.
  • progress is a coarse percentage from 0 through 100.
  • project_id identifies the project charged for the work; Playbooks has no campaign_id.
  • input is the normalized request snapshot. Defaults appear even when you omit them.
  • result remains null until success. error remains null unless the job fails.
  • usage is the work attributed to this job. stages is null for 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 2
Important

A 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#

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())