# Quickstart

> Create your first job, poll it safely, and print a useful sales result.

Canonical guide: <https://api.blackpearl.com/docs/getting-started>

Raw Markdown: <https://api.blackpearl.com/docs/raw/getting-started.md>

OpenAPI: <https://api.blackpearl.com/v1/openapi.json>

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](https://api.blackpearl.com/docs/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](https://api.blackpearl.com/docs/playbooks-api-primer) — setup commands are in [AI and machine-readable documentation](https://api.blackpearl.com/docs/agent-resources).

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

```bash
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:

```bash
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:

```json
{
  "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.

```bash
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:

```text
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](#python-and-typescript-versions) when moving this flow into an application.
- Learn how to store and resume jobs in [Jobs and polling](https://api.blackpearl.com/docs/jobs).
- Add seller context and choose sections in [Playbooks](https://api.blackpearl.com/docs/playbooks).
- Handle API failures by `error.code` using the [error and retry matrix](https://api.blackpearl.com/docs/errors).

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

<!-- tabs:start -->
<!-- tab: Python -->

Run the standard-library-only script:

```bash
python3 docs/examples/python/playbooks_quickstart.py
```

```python
#!/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())
```

<!-- tab: TypeScript -->

Install and run with npm:

```bash
npm --prefix docs/examples/typescript install
npm --prefix docs/examples/typescript start
```

```typescript
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";

type JobStatus = "queued" | "running" | "succeeded" | "failed" | "canceled";

interface ErrorResponse {
  error?: { code?: string; message?: string };
}

interface PlaybookJob {
  id: string;
  type: "playbooks";
  status: JobStatus;
  progress: number;
  error: string | null;
  result: null | { business_summary: string | null; sales_angles: string[] };
}

const terminal = new Set<JobStatus>(["succeeded", "failed", "canceled"]);
const transientReadCodes = new Set([
  "rate_limit_exceeded",
  "internal_error",
  "audiences_backend_error",
  "audiences_backend_invalid_response",
  "audiences_backend_unavailable",
]);

class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    message: string,
    readonly retryAfter: string | null,
  ) {
    super(`API error ${status} ${code}: ${message}`);
  }
}

function requiredEnv(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) throw new Error(`Set ${name} before running this example`);
  return value;
}

async function requestJson(
  method: "GET" | "POST",
  url: string,
  apiKey: string,
  body?: object,
  extraHeaders: Record<string, string> = {},
): Promise<{ body: unknown; headers: Headers }> {
  const response = await fetch(url, {
    method,
    headers: {
      Authorization: `Bearer ${apiKey}`,
      Accept: "application/json",
      ...(body ? { "Content-Type": "application/json" } : {}),
      ...extraHeaders,
    },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(30_000),
  });
  const payload: unknown = await response.json();
  if (!response.ok) {
    const error = (payload as ErrorResponse).error;
    throw new ApiError(
      response.status,
      error?.code ?? "http_error",
      error?.message ?? response.statusText,
      response.headers.get("Retry-After"),
    );
  }
  return { body: payload, headers: response.headers };
}

function isPlaybookJob(value: unknown): value is PlaybookJob {
  if (!value || typeof value !== "object") return false;
  const job = value as Partial<PlaybookJob>;
  return typeof job.id === "string" && job.type === "playbooks" &&
    ["queued", "running", "succeeded", "failed", "canceled"].includes(job.status ?? "");
}

const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));

async function saveJobId(path: string, jobId: string): Promise<void> {
  await mkdir(dirname(path), { recursive: true });
  await writeFile(path, `${jobId}\n`, "utf8");
}

async function persistentIdempotencyKey(path: string): Promise<string> {
  try {
    const key = (await readFile(path, "utf8")).trim();
    if (key) return key;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
  }
  const key = `playbook-${randomUUID()}`;
  await mkdir(dirname(path), { recursive: true });
  await writeFile(path, `${key}\n`, "utf8");
  return key;
}

async function createOrResume(baseUrl: string, apiKey: string, statePath: string): Promise<string> {
  try {
    const jobId = (await readFile(statePath, "utf8")).trim();
    if (jobId) {
      console.log(`Resuming Playbooks job ${jobId}`);
      return jobId;
    }
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
  }

  // Persist the key before sending so an ambiguous failure is safely retried
  // with the same request and key during the 24-hour replay window.
  const idempotencyKey = await persistentIdempotencyKey(`${statePath}.idempotency`);
  const { body } = await requestJson("POST", `${baseUrl}/v1/playbooks`, apiKey, {
    target_company_domain: "acme.example",
  }, { "Idempotency-Key": idempotencyKey });
  if (!isPlaybookJob(body)) throw new Error("Create response did not contain a typed Playbooks job ID");
  await saveJobId(statePath, body.id);
  console.log(`Created Playbooks job ${body.id}`);
  return body.id;
}

async function pollJob(baseUrl: string, apiKey: string, jobId: string): Promise<PlaybookJob> {
  const timeoutMs = Number(process.env.BLACKPEARL_POLL_DEADLINE_SECONDS ?? "120") * 1000;
  let delayMs = Number(process.env.BLACKPEARL_POLL_INITIAL_SECONDS ?? "2") * 1000;
  const maxDelayMs = Number(process.env.BLACKPEARL_POLL_MAX_SECONDS ?? "10") * 1000;
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    try {
      const { body } = await requestJson("GET", `${baseUrl}/v1/jobs/${jobId}`, apiKey);
      if (!isPlaybookJob(body)) throw new Error("Polling response was not a typed Playbooks job");
      console.log(`status=${body.status} progress=${body.progress}%`);
      if (terminal.has(body.status)) return body;
    } catch (error) {
      if (!(error instanceof ApiError) || !transientReadCodes.has(error.code)) throw error;
      if (error.code === "rate_limit_exceeded" && error.retryAfter) {
        delayMs = Math.max(delayMs, Number(error.retryAfter) * 1000);
      }
    }

    const jitteredMs = delayMs * (0.8 + Math.random() * 0.4);
    await sleep(Math.min(jitteredMs, Math.max(0, deadline - Date.now())));
    delayMs = Math.min(maxDelayMs, Math.max(delayMs * 2, 10));
  }
  throw new Error(`Polling deadline reached; resume job ${jobId} from the saved state file`);
}

function renderResult(job: PlaybookJob): void {
  if (job.status === "failed") throw new Error(`Playbooks job failed: ${job.error ?? "No failure message returned"}`);
  if (job.status === "canceled") throw new Error("Playbooks job was canceled");
  const summary = job.result?.business_summary;
  const firstAngle = job.result?.sales_angles[0];
  if (job.status !== "succeeded" || !summary || !firstAngle) {
    throw new Error("Playbooks result did not contain a useful business summary and sales angle");
  }
  console.log(`Business summary: ${summary}`);
  console.log(`First sales angle: ${firstAngle}`);
}

async function main(): Promise<void> {
  const baseUrl = requiredEnv("BLACKPEARL_API_BASE_URL").replace(/\/$/, "");
  const apiKey = requiredEnv("BLACKPEARL_API_KEY");
  const statePath = process.env.BLACKPEARL_JOB_STATE_FILE ?? ".blackpearl-playbooks-job";
  const jobId = await createOrResume(baseUrl, apiKey, statePath);
  const job = await pollJob(baseUrl, apiKey, jobId);
  renderResult(job);
  await rm(statePath, { force: true });
  await rm(`${statePath}.idempotency`, { force: true });
}

main().catch((error: unknown) => {
  console.error(error instanceof Error ? error.message : String(error));
  process.exitCode = 1;
});
```

<!-- tabs:end -->
