---
title: "Extract a brand"
description: "Read one brand from its own website: the products, the offer, the promise, the proof and the voice."
url: https://docs.roasos.com/workflows/extract-a-brand
---

# Extract a brand

Extract a brand reads a website and returns what the brand says about itself. Use the result to fill your [brand kit](/brand/brand-kit), and to compile the [intelligence documents](/brand/intelligence) that the agent reads.

An extraction runs as a job with its own read path. Quote the run, confirm the run, poll the status, then read the result.

## 1. Send the website

The request has one field.

| Field         | Rule                                           |
| ------------- | ---------------------------------------------- |
| `website_url` | a public `https` URL, 2,048 characters at most |

roasOS refuses a URL that is not `https`, a URL that carries credentials, a URL with a custom port and a URL that points at an internal host. Each refusal is `400 validation_error`.

Every crawl bound is fixed by roasOS. The request cannot widen one, because a wider crawl is a higher price.

## 2. Quote

Set `quote_only` to `true`. A quote is free.

```bash
curl -X POST https://api.roasos.com/v1/workflows/extract-brand \
  -H "Authorization: Bearer roasos_sk_..." \
  -H "Idempotency-Key: extract-acme-001" \
  -H "Content-Type: application/json" \
  -d '{
    "website_url": "https://www.acme.com",
    "quote_only": true
  }'
```

```json
{
  "data": {
    "schema_version": "workflow-analysis@1",
    "kind": "workflow_analysis",
    "id": "3c1f8ab2-...",
    "workflow": "extract-brand",
    "status": "quoted",
    "analysis_kind": "website",
    "cache_disposition": "live_analysis",
    "reserved_credits": 0,
    "maximum_credits": 2,
    "pricing_version": "workflow-pricing@2026-10-01.v2",
    "quote_id": "9d40b7c5-...",
    "expires_at": "2026-08-21T10:17:44.000Z",
    "authorization": { "status": "auto_approved" },
    "created_at": "2026-08-21T10:07:44.000Z",
    "updated_at": "2026-08-21T10:07:44.000Z"
  }
}
```

An extraction is flat at **2 credits**. The price does not change with the size of the site.

## 3. Confirm

Send the same body with the `quote_id`. Use the same idempotency key.

```bash
curl -X POST https://api.roasos.com/v1/workflows/extract-brand \
  -H "Authorization: Bearer roasos_sk_..." \
  -H "Idempotency-Key: extract-acme-001" \
  -H "Content-Type: application/json" \
  -d '{
    "website_url": "https://www.acme.com",
    "quote_id": "9d40b7c5-..."
  }'
```

The answer is `202`. The credits are now reserved.

```json
{
  "data": {
    "schema_version": "workflow-analysis@1",
    "kind": "workflow_analysis",
    "id": "3c1f8ab2-...",
    "workflow": "extract-brand",
    "status": "queued",
    "analysis_kind": "website",
    "cache_disposition": "live_analysis",
    "reserved_credits": 2,
    "maximum_credits": 2,
    "pricing_version": "workflow-pricing@2026-10-01.v2",
    "created_at": "2026-08-21T10:07:44.000Z",
    "updated_at": "2026-08-21T10:08:02.000Z"
  }
}
```

<Callout type="warn">
  `Idempotency-Key` is required on both calls. Use 16 to 255 characters. roasOS answers `400 idempotency_key_required` without it.
</Callout>

## 4. Poll the status

An extraction sends no webhook. Poll the status every few seconds.

```bash
curl https://api.roasos.com/v1/workflows/analyses/3c1f8ab2-.../status \
  -H "Authorization: Bearer roasos_sk_..."
```

The status changes from `queued` to `processing`. It then becomes `succeeded` or `failed`. A `succeeded` run carries `result_ready: true`.

<Callout>
  An analysis uses its own status words. The terminal success is `succeeded`, and it is never `completed`. A generation carries the five job statuses instead, and its terminal success is `completed`. Refer to [Jobs](/concepts/jobs).
</Callout>

An extraction is one analysis run, so it uses the read path of every analysis. `GET /v1/workflows/analyses/{id}` returns the same envelope as the status path. An extraction adds no second read family, and it adds no third status vocabulary.

## 5. Read the result

```bash
curl https://api.roasos.com/v1/workflows/analyses/3c1f8ab2-.../result \
  -H "Authorization: Bearer roasos_sk_..."
```

<Callout type="warn">
  `/result` returns `409 analysis_not_completed` while the run is not terminal. This is not an error in your code. Poll `/status` first.
</Callout>

```json
{
  "data": {
    "schema_version": "workflow-analysis-result@1",
    "kind": "workflow_analysis_result",
    "analysis_id": "3c1f8ab2-...",
    "workflow": "extract-brand",
    "status": "succeeded",
    "analysis_kind": "website",
    "cache_disposition": "live_analysis",
    "model": "gemini-3-flash-preview",
    "pricing_version": "workflow-pricing@2026-10-01.v2",
    "reserved_credits": 2,
    "maximum_credits": 2,
    "settled_credits": 2,
    "completed_at": "2026-08-21T10:09:12.000Z",
    "analysis": {
      "schemaVersion": "site-analysis.v1",
      "websiteUrl": "https://www.acme.com",
      "observedAt": "2026-08-21T10:08:40.000Z",
      "corpus": {
        "sha256": "9f2c...",
        "byteLength": 48210,
        "pageCount": 9,
        "truncated": false,
        "deadlineReached": false,
        "robotsRestricted": false,
        "homepageSource": "browser-rendering",
        "reviewsRead": true,
        "verifiedDowngraded": false,
        "pages": [
          { "url": "https://www.acme.com/", "bucket": "home", "title": "Acme", "byteLength": 8102 }
        ]
      },
      "extraction": {
        "brandName": "Acme",
        "category": "skincare for sensitive skin",
        "voice": "Direct and warm. Short sentences. No hype.",
        "product": "A fragrance-free barrier cream in a 50 ml jar.",
        "mechanism": "Ceramides rebuild the skin barrier over eight weeks.",
        "differentiation": "One ingredient list, published in full.",
        "offer": "Free shipping over 40 euro. Sixty-day return.",
        "desires": "Calm skin without a ten-step routine.",
        "sophisticationStage": 3,
        "newAngle": "Name the price of the routine the customer already runs.",
        "avatars": [
          {
            "name": "Pharmacy buyer",
            "who": "Women 25 to 40 who buy skincare in a pharmacy.",
            "pains": "Redness after every new product.",
            "desires": "One product that does not sting.",
            "objections": "It is too expensive for a cream.",
            "evidence": "I stopped buying the expensive one."
          }
        ],
        "proof": "Dermatologist tested. 1,204 reviews at 4.6.",
        "competitors": [
          { "name": "Beta Skin", "url": "https://betaskin.com", "note": "Same price, louder claims." }
        ],
        "confidence": {
          "voice": "stated",
          "product": "stated",
          "mechanism": "stated",
          "differentiation": "inferred",
          "offer": "stated",
          "desires": "verified",
          "newAngle": "inferred",
          "proof": "verified"
        },
        "gaps": ["Which skin types is the cream not for?"]
      },
      "candidate": {
        "schemaVersion": "brand_analysis_candidate_v1",
        "status": "unconfirmed",
        "sourceUrl": "https://www.acme.com",
        "fields": {}
      },
      "logoMediaKey": "uploads/8f1c.../images/logo-light.png"
    }
  }
}
```

| Part           | What it holds                                                                                                     |
| -------------- | ----------------------------------------------------------------------------------------------------------------- |
| `corpus`       | which pages roasOS read, and how the crawl ended                                                                  |
| `extraction`   | the brand: the voice, the product, the mechanism, the offer, the avatars, the proof, the competitors and the gaps |
| `candidate`    | the same brand fields as an unconfirmed candidate, ready for a person to confirm                                  |
| `logoMediaKey` | the logo of the site, mirrored into your own workspace storage, or `null`                                         |

`competitors` is model-asserted. roasOS did not verify a competitor, and it did not verify the `url` of one.

## Per-field confidence

`extraction.confidence` carries one label for each of eight fields: `voice`, `product`, `mechanism`, `differentiation`, `offer`, `desires`, `newAngle` and `proof`.

| Label      | Meaning                                            |
| ---------- | -------------------------------------------------- |
| `stated`   | the brand states this about itself on its own site |
| `inferred` | the model concluded this from the pages it read    |
| `verified` | a reviews page supports this                       |

Only a reviews page earns `verified`. Read `corpus.reviewsRead` to see whether roasOS read one. `corpus.verifiedDowngraded` is `true` when roasOS read no reviews page and moved a `verified` label down to `stated`.

Treat `inferred` as a proposal. Confirm it before you build a campaign on it.

## The crawl bounds

roasOS reads the homepage first, then reads the pages that carry brand signal.

| Bound        | Value                                                              |
| ------------ | ------------------------------------------------------------------ |
| Pages        | 12 at most, the homepage included                                  |
| Whole crawl  | 60 seconds; roasOS then uses the pages it holds                    |
| One page     | 8 seconds and 750 KB at most                                       |
| Redirects    | 5 at most, and the destination must stay on the same origin        |
| `robots.txt` | roasOS obeys each `Disallow` rule that names roasOS or every agent |

A page with less than 80 characters of text carries no signal, so roasOS drops it from the corpus.

roasOS reads the homepage through a browser, so a page that draws itself with JavaScript still gives text. `corpus.homepageSource` says which reader ran: `browser-rendering` is the normal path, and `crawl` says that the browser was not available and a plain fetch read the homepage instead.

Three fields of `corpus` report how the crawl ended.

| Field              | `true` means                                                                |
| ------------------ | --------------------------------------------------------------------------- |
| `deadlineReached`  | the 60-second deadline stopped the crawl, and roasOS used the pages it held |
| `robotsRestricted` | `robots.txt` refused one or more pages                                      |
| `truncated`        | the corpus was longer than the model ceiling, so roasOS cut it              |

## The cache

roasOS keeps one extraction of one site for your workspace for **seven days**.

* A second request inside those seven days is a **cache hit**. It costs 0 credits. The answer is `succeeded` at once, with `cache_disposition: cache_hit`. Read the result immediately.
* A request after those seven days is a new crawl at the full price.

The cache belongs to your workspace. It is not shared with another workspace. There is no field that forces a new crawl of an unchanged site.

## What the extraction feeds

* The logo is mirrored into your workspace storage under `uploads/`. Put its URL into `logo_url` on [`PUT /v1/brand-kit`](/brand/brand-kit). The kit then records the extraction in `source_analysis_id`.
* The brand fields become candidates. Confirm a field in the roasOS app. Refer to [Intelligence](/concepts/intelligence).
* The result feeds the compiled documents. Call [`POST /v1/intelligence/refresh`](/brand/intelligence) to compile them now. A refresh is free.

## Failure and refunds

A failed run carries `settled_credits: 0`. roasOS refunds the reservation in full.

| Code                        | Cause                                            |
| --------------------------- | ------------------------------------------------ |
| `analysis_failed`           | the model call did not produce a usable document |
| `analysis_media_unreadable` | roasOS could not read the site                   |
| `analysis_timed_out`        | the run passed its time limit                    |

## Rate window

The extraction runs on the included lane of roasOS. That lane holds its own hourly window for each workspace: 100 extractions per hour. A request over the window answers `429 included_ancillary_ai_window_exceeded` and carries a `Retry-After` header. Wait for the seconds in that header. Then send the request again with the same idempotency key.

A cache hit never reaches the lane, so it never meets this window.

## Other errors

| Code                       | Status | Cause                                                                                     |
| -------------------------- | ------ | ----------------------------------------------------------------------------------------- |
| `validation_error`         | 400    | `website_url` breaks a rule above                                                         |
| `idempotency_key_required` | 400    | the `Idempotency-Key` header is absent                                                    |
| `quote_expired`            | 410    | the quote expired, or it does not match this body; quote again with a new idempotency key |
| `analysis_not_completed`   | 409    | you read `/result` before the run was terminal                                            |
| `analysis_unavailable`     | 503    | the run could not be read; send the request again                                         |

## From an agent

`roasos_extract_brand` starts an extraction from an MCP client. Read the run with `roasos_get_ad_analysis`, `roasos_get_ad_analysis_status` and `roasos_get_ad_analysis_result`, because an extraction is one analysis run. The quote rule is the same. Refer to [Connect an AI agent (MCP)](/mcp).

## Next

* Store the brand for every later render: [Brand kit](/brand/brand-kit).
* See what roasOS learned: [Intelligence documents](/brand/intelligence).
