---
title: "Start a batch"
method: POST
path: "/api/agent/analysis-batches"
tags: ["Batches"]
---

# Start a batch

`POST /api/agent/analysis-batches`

## Request

- Base URL: `https://preuve.ai`
- URL: `https://preuve.ai/api/agent/analysis-batches`
- Auth: API key in header `x-preuve-key`

Create up to 5 analysis runs in one batch. Idempotent on the batch clientRunId. Each item needs its own unique clientRunId. Item scanType is optional and falls back to the batch's, so set it per item only to mix starter and deep in one batch.

## Request body

- union
  - object
    - `clientRunId` string, required: Idempotency id for the whole batch.
    - `name` string
    - `scanType` 'starter' | 'deep', required: Default scanType applied to every item; each item may override.
    - `enrichmentMode` 'core' | 'none'
    - `items` BatchAnalysisItem[], required
      - `clientRunId` string: Caller-chosen idempotency id. Reusing it replays the existing run.
      - `scanType` 'starter' | 'deep': "starter" costs nothing; "deep" runs the full paid analysis and consumes account quota. "free" is accepted as a legacy alias of "starter".
      - `idea` string: The startup idea, in plain language.
      - `targetMarket` string
      - `targetCountry` string: ISO country code, e.g. "US" or "FR".
      - `title` string
      - `stage` string: Where the founder is today. Canonical values: idea, validation, mvp, launched, growth, scaling (free text tolerated). Optional here, but the MCP tools require it for deep scans; it feeds the feasibility analysis.
      - `budget` string: Budget available to pursue the idea. Canonical values: bootstrap (no outside money), 10k, 50k, 100k, 100kPlus (over $100k), 1mPlus (over $1M); free text tolerated. Optional here, but the MCP tools require it for deep scans; it feeds the feasibility analysis.
      - `language` string
      - `enrichmentMode` 'core' | 'none': "core" also generates the export-required sections; "none" returns the raw completion only.
      - `publish` boolean: When true, creates a public share URL for the report.
  - object
    - `items` AnalysisRequestFields[], required
      - `clientRunId` string: Caller-chosen idempotency id. Reusing it replays the existing run.
      - `scanType` 'starter' | 'deep': "starter" costs nothing; "deep" runs the full paid analysis and consumes account quota. "free" is accepted as a legacy alias of "starter".
      - `idea` string: The startup idea, in plain language.
      - `targetMarket` string
      - `targetCountry` string: ISO country code, e.g. "US" or "FR".
      - `title` string
      - `stage` string: Where the founder is today. Canonical values: idea, validation, mvp, launched, growth, scaling (free text tolerated). Optional here, but the MCP tools require it for deep scans; it feeds the feasibility analysis.
      - `budget` string: Budget available to pursue the idea. Canonical values: bootstrap (no outside money), 10k, 50k, 100k, 100kPlus (over $100k), 1mPlus (over $1M); free text tolerated. Optional here, but the MCP tools require it for deep scans; it feeds the feasibility analysis.
      - `language` string
      - `enrichmentMode` 'core' | 'none': "core" also generates the export-required sections; "none" returns the raw completion only.
      - `publish` boolean: When true, creates a public share URL for the report.
    - `clientRunId` string, required: Idempotency id for the whole batch.
    - `name` string
    - `scanType` 'starter' | 'deep': Default scanType applied to every item; each item may override.
    - `enrichmentMode` 'core' | 'none'

## Response `200`

Existing batch replayed via clientRunId. Nothing new was created and nothing new was consumed; the body is the batch's current state.

- Batch
  - `id` string, uuid
  - `clientRunId` string
  - `name` string, nullable
  - `status` string
  - `counts` object
  - `items` BatchItem[]
    - union: One entry in a batch. Either a real run or a late-collision stub; the two are told apart by id, which is null only on the stub.
      - AnalysisRun
        - `id` string, uuid
        - `reportId` string, uuid, nullable: Null until the report row is attached, which is why a run that failed before dispatch is retryable under the same clientRunId.
        - `clientRunId` string, nullable: The caller-chosen id from create. Null when the analysis is a dashboard-created report resolved by its report id: no run row exists, so there is no clientRunId to replay.
        - `batchId` string, uuid, nullable: The batch this run belongs to, or null for a standalone run.
        - `scanType` 'starter' | 'deep': Your request type. Reliable from creation, even while PROCESSING.
        - `enrichmentMode` 'core' | 'none'
        - `publish` boolean
        - `reportType` 'quick' | 'deep_dive' | 'null', nullable
        - `analysisTier` 'basic' | 'advanced' | 'null', nullable: Derived from the report. Deep runs read "basic" until deep sections land.
        - `status` 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'
        - `progressStep` string, nullable: Free-text pipeline stage while PROCESSING.
        - `errorCode` string, nullable: Set on a FAILED run. Two internal markers are deliberately rewritten before they leave: an ambiguous dispatch reports TRIGGER_DISPATCH_FAILED and an ambiguous claim refund reports REPORT_CREATE_FAILED.
        - `errorMessage` string, nullable
        - `resetAt` string, date-time: Present only on a batch item refused by the rolling daily cap; absent, not null, on every other run.
        - `pollAfterSeconds` integer: Present only while polling is still useful. Pace by this rather than by a fixed interval.
        - `pollHint` string: Present alongside pollAfterSeconds.
        - `createdAt` string, date-time
        - `updatedAt` string, date-time
        - `readyForExport` boolean: Whether GET /export will serve this analysis now. Always false for a report created on the preuve.ai dashboard, whatever state the report is in, because that export answers 403 REPORT_NOT_FROM_AGENT_RUN. Branch on exportable first.
        - `exportable` boolean: Present, and false, only on a report created on the preuve.ai dashboard. Absent means the ordinary contract applies and readyForExport is the gate. When it is false, no amount of polling or enriching changes it.
        - `exportBlockedCode` 'REPORT_NOT_FROM_AGENT_RUN': Present alongside exportable, naming the refusal.
        - `exportBlockedHint` string: Present alongside exportable. Relayable prose.
        - `enrichment` EnrichmentState: Progress of the core export sections. The four name arrays partition `required`, so a poller decides what to do next by reading them rather than by parsing `status`: anything in processing means keep polling, anything in failed means call enrich again, and missing with nothing processing means enrichment was never started for it.
          - `mode` 'core' | 'none'
          - `status` 'not_required' | 'pending' | 'processing' | 'partial' | 'completed' | 'failed': Rolled up from the arrays below. "partial" means some sections landed and others did not; "not_required" is mode "none", or a report with no required sections.
          - `required` string[]: Every section this report and mode need. The other four arrays partition it.
          - `completed` string[]
          - `missing` string[]: Required, not generated, and not currently being generated.
          - `processing` string[]: Generation is in flight. Keep polling rather than calling enrich again.
          - `failed` string[]: Generation was attempted and failed. Retryable by calling enrich again.
          - `sections` object: Per-section detail, keyed by section name, for every entry in required. Each value carries at least a status matching the array the section is in, plus completedAt on a completed section and whatever the last attempt recorded on a failed one.
        - `modules` object, nullable: Per-module statuses. Deep reports only; null on free runs.
          - `proofOfDemand` ModuleStatus
            - `status` 'not_generated' | 'generating' | 'failed' | 'completed'
          - `founderFit` ModuleStatus
            - `status` 'not_generated' | 'generating' | 'failed' | 'completed'
          - `playbook` ModuleStatus
            - `status` 'not_generated' | 'generating' | 'failed' | 'completed'
          - `trends` ModuleStatus
            - `status` 'not_generated' | 'generating' | 'failed' | 'completed'
          - `community` ModuleStatus
            - `status` 'not_generated' | 'generating' | 'failed' | 'completed'
        - `reportUrl` string, uri, nullable: Null until the report row exists.
        - `shareUrl` string, uri, nullable
      - BatchItemConflict: Not a run. Another request claimed this item's clientRunId after the batch row was reserved but before the item started, so nothing was created and id is null. Appears in Batch.items on create, on replay and on poll, and its null id carries through to BatchExport.omittedItems.
        - `id` unknown
        - `reportId` unknown
        - `clientRunId` string
        - `batchId` string, uuid
        - `status` 'FAILED'
        - `errorCode` 'CLIENT_RUN_ID_CONFLICT'
        - `errorMessage` string

## Other responses

- `202`: Batch accepted. Every item has been attempted, so a 202 can still carry items that failed at start - read status and counts, and each item's own status, rather than assuming five runs began.
- `400`: The request was rejected before any work started: malformed JSON (INVALID_JSON), an id that is not one of ours (INVALID_ANALYSIS_ID, INVALID_BATCH_ID), an export format we do not serve (UNSUPPORTED_FORMAT), or a field the schema cannot express - an idea too short to analyse, a batch outside 1..5 items (INVALID_BATCH_SIZE), two batch items sharing a clientRunId (DUPLICATE_CLIENT_RUN_ID), an unknown scanType. No paid quota, tokens, or scans were consumed. On the CREATE routes a rate-limit window can still be: those per-account limiters are claimed ahead of schema validation on purpose, so that malformed spam is metered, and retrying unchanged spends another slot. Enrich no longer works that way - since 2026-09-15 its 12/min window is claimed after the body is validated, so a malformed enrich costs nothing. Fix the request either way; retrying it unchanged returns the same answer.
- `401`: Missing x-preuve-key header (MISSING_API_KEY), or a malformed, unknown or revoked key (INVALID_AGENT_KEY - the same envelope for all three, on purpose). Repeated failures are throttled per IP and answer 429.
- `403`: The key is valid but was not granted the scope this route requires (INSUFFICIENT_SCOPE). Scopes are per route and fixed when the key is created: analysis:write to start a run, analysis:read to poll it, export:read to export, batch:write and batch:read for batches. They are never widened afterwards, so this is not retryable - the user reconnects the client and approves the missing permission, or creates a new key with it. Distinct from 401, which means the key itself was not accepted.
- `409`: One of the batch's item clientRunIds is already owned by an existing run (CLIENT_RUN_ID_CONFLICT), so the batch was not created. Not retryable unchanged: read the run that owns the id, or give that item a new one. A per-item failure inside an accepted batch is not this - it comes back as a FAILED item in the 200 body.
- `413`: The request body is over the 1 MB limit (PAYLOAD_TOO_LARGE, with the ceiling in `limit`). Counted while the body streams in and refused the moment it is exceeded, before any quota, token, or scan is consumed. Not retryable unchanged: send less, or split the work across requests.
- `429`: Rate limited (RATE_LIMITED or DAILY_LIMIT_REACHED). RATE_LIMITED carries a Retry-After header with the seconds until the window resets; the quota-shaped codes clear on a different clock and put resetAt in the body instead.
- `500`: Unexpected server error (INTERNAL_ERROR, or TRIGGER_DISPATCH_FAILED when a deep scan could not be handed to the job runner). Never tell the user their quota is safe: on a deep scan the spend can already have happened when the failure hit, and an ambiguous dispatch deliberately keeps the report and its funding because the job may have been accepted before its response was lost. Treat consumption as unknown and retry with backoff. On an operation that takes a clientRunId, retry with the SAME one - that reconciles the original run, whatever state it reached, instead of starting and charging for a second one. On every other operation there is nothing to reconcile: the read or export simply failed, so a plain backed-off retry is the whole recovery.
- `503`: A dependency was unavailable or deliberately paused (SERVICE_DISABLED, LLM_PAUSED, PROFILE_LOOKUP_FAILED, and the fail-closed answer when the rate-limit backend is down). Any authenticated route can answer this one: authentication meters itself through the same fail-closed limiter, so an outage there refuses the request before the handler runs. Retryable, and no paid quota, tokens, or scans were consumed - a pause that lands mid-run refunds the claim it had already made. Back off and try again rather than falling back to a different endpoint.

---

[API](https://skmtc.dev/preuve/apis/preuve-agent-api.md) · [All operations](https://skmtc.dev/preuve/apis/preuve-agent-api/llms.txt) · [OpenAPI document](https://skmtc.dev/preuve/apis/preuve-agent-api/revisions/d58b3d396fa7?raw)
