# Full client script example

Use the Python script on this page to start a generation job, poll until it finishes, and print the PowerPoint download URLs. It works for single-slide generation, deck generation, or both in one run.

The script handles the details that production callers should care about:

* It sends `POST /api/v1/generate` with an `Idempotency-Key`.
* It polls `GET /api/v1/status/{job_id}` with backoff.
* It honors `Retry-After` when the API asks the client to slow down.
* It prints timestamps, job IDs, status changes, and final download URLs.
* It leaves web search and knowledge base use off unless you enable them.

## Environment

Set the API origin and your API key:

```bash
export PERCEPTIS_API_BASE_URL="https://app.perceptis.ai"
export PERCEPTIS_API_KEY="sk-live-per-..."
```

`PERCEPTIS_API_BASE_URL` should be the origin only. Do not include `/api/v1`; the script adds the API paths.

## Run The Script

Generate both a single slide and a deck:

```bash
python3 generate_and_poll.py --mode both
```

Generate only a single slide:

```bash
python3 generate_and_poll.py \
  --mode single_slide \
  --single-slide-prompt "Generate a revenue stream comparison slide for SpaceX focusing on low earth orbit satellites versus deep space exploration."
```

Generate only a deck:

```bash
python3 generate_and_poll.py \
  --mode deck \
  --deck-prompt "Generate a consulting proposal deck for reshaping SpaceX from low earth orbit satellite launches toward deep space exploration."
```

Generate a single slide from a local reference image:

```bash
python3 generate_and_poll.py \
  --mode single_slide \
  --reference-image "/path/to/reference.png" \
  --single-slide-prompt "Recreate the provided reference image as a polished consulting slide. Match the layout, visual hierarchy, colors, labels, and composition as closely as possible."
```

Enable web search or knowledge base context only when you want those sources included:

```bash
python3 generate_and_poll.py \
  --mode deck \
  --use-web-search \
  --use-knowledge-base
```

## Safe retries

`--idempotency-key` and `job_id` have different roles:

* `--idempotency-key` is sent when creating a job. Reuse it with the same request body if you need to retry after a timeout or network error; Perceptis returns the original job instead of creating a duplicate.
* `job_id` is returned by Perceptis after the job is accepted. Use it to poll status and refresh download links.

If you omit `--idempotency-key`, the script generates one for the run. When `--mode both` is used, it derives separate keys for the single-slide and deck jobs.

Completed jobs can be polled again for fresh download links while the generated files remain available. Use the same API key that created the job.

## Full reference script

Use this as `generate_and_poll.py`:

```python
#!/usr/bin/env python3
"""Run Perceptis API single-slide and deck examples with safe status polling."""

from __future__ import annotations

import argparse
import base64
import json
import mimetypes
import os
import sys
import time
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen


TERMINAL_STATUSES = {"completed", "failed"}


@dataclass
class ApiResponse:
    status_code: int
    headers: Any
    body: dict[str, Any]


class PerceptisApiError(RuntimeError):
    def __init__(self, message: str, *, status_code: int | None = None) -> None:
        super().__init__(message)
        self.status_code = status_code


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Generate Perceptis API single-slide and deck jobs, poll status, and print downloads.",
    )
    parser.add_argument(
        "--base-url",
        default=os.environ.get("PERCEPTIS_API_BASE_URL"),
        help=(
            "API origin only (scheme://host[:port]); script appends /api/v1/.... "
            "Trailing /api is stripped if present."
        ),
    )
    parser.add_argument(
        "--api-key",
        default=os.environ.get("PERCEPTIS_API_KEY"),
        help="Perceptis API key. Defaults to PERCEPTIS_API_KEY.",
    )
    parser.add_argument(
        "--mode",
        choices=("single_slide", "deck", "both"),
        default="both",
        help="Which example request to run.",
    )
    parser.add_argument(
        "--single-slide-prompt",
        default="One slide summarizing Q3 revenue drivers.",
        help="Prompt used for the single-slide example.",
    )
    parser.add_argument(
        "--deck-prompt",
        default="A concise 4-slide board deck on market entry for EU expansion.",
        help="Prompt used for the deck example.",
    )
    parser.add_argument(
        "--template-name",
        help="Optional template name to include in generate requests.",
    )
    parser.add_argument(
        "--use-web-search",
        action="store_true",
        help="Enable web search for generation. Defaults to false.",
    )
    parser.add_argument(
        "--use-knowledge-base",
        action="store_true",
        help="Enable your organization's knowledge base for generation. Defaults to false.",
    )
    parser.add_argument(
        "--reference-image",
        action="append",
        default=[],
        help=(
            "Path to a reference image for single-slide generation. "
            "Repeat up to 3 times. Ignored unless --mode includes single_slide."
        ),
    )
    parser.add_argument(
        "--idempotency-key",
        help="Optional idempotency key. Defaults to a generated UUID per run.",
    )
    parser.add_argument(
        "--timeout-seconds",
        type=float,
        default=900.0,
        help="Maximum time to poll each job.",
    )
    parser.add_argument(
        "--initial-delay-seconds",
        type=float,
        default=3.0,
        help="Initial poll delay. Keep this in the 2-5 second range for normal use.",
    )
    return parser.parse_args()


def timestamp() -> str:
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def log(message: str) -> None:
    print(f"[{timestamp()}] {message}")


def reference_image_payload(path: str) -> dict[str, str]:
    mime_type, _ = mimetypes.guess_type(path)
    if mime_type not in {"image/png", "image/jpeg", "image/webp"}:
        raise PerceptisApiError(
            f"Unsupported reference image type for {path!r}. Use PNG, JPEG, or WebP."
        )

    with open(path, "rb") as image_file:
        data = base64.b64encode(image_file.read()).decode("ascii")

    return {"data": data, "mime_type": mime_type}


def build_reference_images(paths: list[str]) -> list[dict[str, str]]:
    if len(paths) > 3:
        raise PerceptisApiError("At most 3 --reference-image values are supported.")
    return [reference_image_payload(path) for path in paths]


def request_json(
    method: str,
    url: str,
    api_key: str,
    *,
    body: dict[str, Any] | None = None,
    idempotency_key: str | None = None,
    allow_statuses: set[int] | None = None,
) -> ApiResponse:
    payload = None
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    }
    if body is not None:
        payload = json.dumps(body).encode("utf-8")
        headers["Content-Type"] = "application/json"
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    request = Request(url, data=payload, headers=headers, method=method)
    try:
        with urlopen(request, timeout=60) as response:
            raw_body = response.read().decode("utf-8")
            return ApiResponse(
                status_code=response.status,
                headers=response.headers,
                body=json.loads(raw_body) if raw_body else {},
            )
    except HTTPError as exc:
        raw_body = exc.read().decode("utf-8")
        try:
            error_body = json.loads(raw_body) if raw_body else {}
        except json.JSONDecodeError:
            error_body = {"error": {"message": raw_body}}
        if allow_statuses and exc.code in allow_statuses:
            return ApiResponse(
                status_code=exc.code,
                headers=exc.headers,
                body=error_body,
            )
        retry_after = exc.headers.get("Retry-After")
        suffix = f" Retry-After={retry_after!r}." if retry_after else ""
        raise PerceptisApiError(
            f"{method} {url} failed with HTTP {exc.code}: {error_body}.{suffix}",
            status_code=exc.code,
        ) from exc
    except URLError as exc:
        raise PerceptisApiError(f"{method} {url} failed: {exc.reason}") from exc


def generate(
    base_url: str,
    api_key: str,
    *,
    output_type: str,
    prompt: str,
    template_name: str | None,
    idempotency_key: str,
    use_web_search: bool,
    use_knowledge_base: bool,
    reference_images: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
    body: dict[str, Any] = {
        "prompt": prompt,
        "output_type": output_type,
        "use_web_search": use_web_search,
        "use_knowledge_base": use_knowledge_base,
    }
    if output_type == "single_slide":
        body["variant_count"] = 1
        if reference_images:
            body["reference_images"] = reference_images
    if template_name:
        body["template_name"] = template_name

    response = request_json(
        "POST",
        f"{base_url}/api/v1/generate",
        api_key,
        body=body,
        idempotency_key=idempotency_key,
    )
    return response.body


def retry_after_seconds(value: str | None, default: float) -> float:
    if value is None:
        return default
    try:
        return max(float(value), 0.0)
    except ValueError:
        return default


def poll_status(
    base_url: str,
    api_key: str,
    job_id: str,
    *,
    timeout_seconds: float,
    initial_delay_seconds: float,
) -> dict[str, Any]:
    deadline = time.monotonic() + timeout_seconds
    delay = initial_delay_seconds

    while time.monotonic() < deadline:
        response = request_json(
            "GET",
            f"{base_url}/api/v1/status/{job_id}",
            api_key,
            allow_statuses={429},
        )

        if response.status_code == 429:
            delay = retry_after_seconds(response.headers.get("Retry-After"), delay)
            log(f"job {job_id}: rate limited, sleeping {delay:.1f}s")
            time.sleep(delay)
            delay = min(delay * 1.5, 30.0)
            continue

        status = response.body.get("status")
        log(f"job {job_id}: status={status}")
        if status in TERMINAL_STATUSES:
            return response.body

        time.sleep(delay)
        delay = min(delay * 1.3, 20.0)

    raise TimeoutError(f"Job {job_id} did not finish within {timeout_seconds} seconds")


def print_downloads(result: dict[str, Any]) -> None:
    if result.get("status") == "failed":
        print(f"job failed: {json.dumps(result.get('error'), sort_keys=True)}")
        return

    downloads = result.get("downloads") or []
    if not downloads:
        print("completed job did not include downloads")
        return

    print("download URLs:")
    for index, entry in enumerate(downloads, start=1):
        label = f"variant {entry['variant']}" if "variant" in entry else "deck"
        slide_count = entry.get("slide_count")
        if slide_count is not None:
            label = f"{label}, {slide_count} slides"
        print(f"- {index}. {label}: {entry['url']}")


def idempotency_key_for(base_key: str, output_type: str, run_count: int) -> str:
    if run_count == 1:
        return base_key
    return f"{base_key}-{output_type}"


def main() -> int:
    args = parse_args()
    if not args.base_url:
        print("Missing --base-url or PERCEPTIS_API_BASE_URL", file=sys.stderr)
        return 2
    if not args.api_key:
        print("Missing --api-key or PERCEPTIS_API_KEY", file=sys.stderr)
        return 2
    if args.reference_image and args.mode == "deck":
        print("--reference-image is only supported for single_slide generation", file=sys.stderr)
        return 2

    base_url = args.base_url.rstrip("/")
    if base_url.endswith("/api"):
        base_url = base_url[: -len("/api")].rstrip("/")
    modes = ["single_slide", "deck"] if args.mode == "both" else [args.mode]
    base_idempotency_key = args.idempotency_key or str(uuid.uuid4())
    reference_images = build_reference_images(args.reference_image)

    for output_type in modes:
        prompt = (
            args.single_slide_prompt
            if output_type == "single_slide"
            else args.deck_prompt
        )
        idempotency_key = idempotency_key_for(
            base_idempotency_key,
            output_type,
            len(modes),
        )
        print(f"starting {output_type} job with Idempotency-Key={idempotency_key}")
        job = generate(
            base_url,
            args.api_key,
            output_type=output_type,
            prompt=prompt,
            template_name=args.template_name,
            idempotency_key=idempotency_key,
            use_web_search=args.use_web_search,
            use_knowledge_base=args.use_knowledge_base,
            reference_images=reference_images if output_type == "single_slide" else None,
        )
        job_id = job["job_id"]
        log(f"accepted {output_type} job_id={job_id}")
        final = poll_status(
            base_url,
            args.api_key,
            job_id,
            timeout_seconds=args.timeout_seconds,
            initial_delay_seconds=args.initial_delay_seconds,
        )
        print_downloads(final)

    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except PerceptisApiError as exc:
        code = f" (HTTP {exc.status_code})" if exc.status_code is not None else ""
        print(f"Error: {exc}{code}", file=sys.stderr)
        raise SystemExit(1)
    except TimeoutError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        raise SystemExit(1)
```


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.perceptis.ai/perceptis-api-v1/examples.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
