# Publish to learn (/docs/api/guides/publish-to-learn)



## What you'll build [#what-youll-build]

The feedback loop an agent runs once a customer has a week of content live: read per-post metrics, find which creatives are winning, and commission more content shaped by those winners. Layers runs its own optimization under the hood — this guide is the read-side, so your product surfaces the same signal to the customer and your agent can act on it in its own voice.

Prerequisite: you've completed [Onboard a customer](/docs/api/guides/onboard-customer) and have at least 5–10 posts live. Anything below roughly 50 total views per post is noise — wait a couple of days before trusting the ranking.

The running example is **acme-todo**, a productivity app with a week of published content.

<Callout type="warn">
  **This loop requires real platform post data.** `/top-performers` ranks posts Layers has synced from Instagram, TikTok, YouTube, or your ad accounts — it returns an empty `items` array on a project with no social accounts linked or no posts yet. If you're testing against a fresh project, connect a social account and wait for the 30-minute sync cadence to complete before expecting ranked rows. See [Requires top-performer data](#requires-top-performer-data) below.
</Callout>

<Steps>
  <Step>
    ## Read per-post metrics [#read-per-post-metrics]

    `GET /v1/projects/:projectId/metrics` is the unified organic metrics endpoint. Scope by `platform_post` for a single post, `social_account` for one handle, or `project` for the whole customer — and the URL-level `:projectId` guards that the scoped entity belongs to your org.

    <Endpoint method="GET" path="/v1/projects/:projectId/metrics" scope="metrics:read" phase="1" />

    <Tabs items="['curl', 'TypeScript', 'Python']">
      <Tab value="curl">
        ```bash
        curl "https://api.layers.com/v1/projects/$PROJECT_ID/metrics?scope=project&id=$PROJECT_ID&metrics=views&metrics=engagement_rate&metrics=watch_time_ms&since=2026-04-10T00:00:00Z&until=2026-04-17T00:00:00Z&granularity=day" \
          -H "Authorization: Bearer $LAYERS_API_KEY"
        ```
      </Tab>

      <Tab value="TypeScript">
        ```ts
        const params = new URLSearchParams({
          scope: "project",
          id: projectId,
          since: "2026-04-10T00:00:00Z",
          until: "2026-04-17T00:00:00Z",
          granularity: "day",
        });
        for (const m of ["views", "engagement_rate", "watch_time_ms"]) {
          params.append("metrics", m);
        }

        const res = await fetch(
          `https://api.layers.com/v1/projects/${projectId}/metrics?${params}`,
          { headers: { Authorization: `Bearer ${process.env.LAYERS_API_KEY}` } },
        );
        const { series, totals } = await res.json();
        ```
      </Tab>

      <Tab value="Python">
        ```python
        import os
        import requests

        res = requests.get(
            f"https://api.layers.com/v1/projects/{project_id}/metrics",
            headers={"Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}"},
            params=[
                ("scope", "project"),
                ("id", project_id),
                ("since", "2026-04-10T00:00:00Z"),
                ("until", "2026-04-17T00:00:00Z"),
                ("granularity", "day"),
                ("metrics", "views"),
                ("metrics", "engagement_rate"),
                ("metrics", "watch_time_ms"),
            ],
        )
        body = res.json()
        series, totals = body["series"], body["totals"]
        ```
      </Tab>
    </Tabs>

    <Response status="200">
      ```json
      {
        "scope": "project",
        "id": "prj_01HX9Y7K8M2P4RSTUV56789AB",
        "window": {
          "since": "2026-04-10T00:00:00Z",
          "until": "2026-04-17T00:00:00Z",
          "granularity": "day"
        },
        "series": [
          { "bucket": "2026-04-10", "views": 3120, "engagement_rate": 0.038, "watch_time_ms": 58120000 },
          { "bucket": "2026-04-11", "views": 5402, "engagement_rate": 0.044, "watch_time_ms": 102400000 }
        ],
        "totals": {
          "views": 48210,
          "engagement_rate": 0.041,
          "watch_time_ms": 892100000,
          "post_count": 23
        }
      }
      ```
    </Response>

    `since` and `until` are ISO-8601 datetimes — date-only strings like `2026-04-10` return `VALIDATION`. `series[].bucket` is the bucket start (date string at `day`/`week` granularity, full timestamp at `hour`). `totals.post_count` is always included so UIs can show "N posts" alongside the aggregates.

    For paid metrics (spend, CPA, ROAS), call [`GET /v1/projects/:projectId/ads-metrics`](/docs/api/reference/ads/ads-metrics) instead. Asking for `conversions`/`spend`/`roas` on this endpoint returns `VALIDATION`.
  </Step>

  <Step>
    ## Find top performers [#find-top-performers]

    `GET /v1/projects/:projectId/top-performers` is the ranking endpoint. It cross-references organic post performance with any ads spend against those creatives and returns a single sorted list. You pick the axis with `metric` and the lookback with `window`.

    <Endpoint method="GET" path="/v1/projects/:projectId/top-performers" scope="metrics:read" phase="1" />

    ```bash
    curl "https://api.layers.com/v1/projects/$PROJECT_ID/top-performers?metric=engagement_rate&window=30d&sourceType=platform_post&limit=5" \
      -H "Authorization: Bearer $LAYERS_API_KEY"
    ```

    <Response status="200">
      ```json
      {
        "metric": "engagement_rate",
        "window": "30d",
        "items": [
          {
            "rank": 1,
            "sourceType": "platform_post",
            "sourceId": null,
            "platformPostId": "pp_01H9ZXT7...",
            "adsContentId": "adc_01H9ZXT...",
            "title": "Get things done. Faster than your to-do list app did yesterday.",
            "thumbnailUrl": "https://media.meetsift.com/videos/ig_.../cover.jpg",
            "metricValue": 0.082,
            "organic": {
              "views": 14820,
              "engagementRate": 0.082,
              "watchTimeMs": 61400000,
              "platforms": ["tiktok"]
            },
            "paid": null
          }
        ]
      }
      ```
    </Response>

    `metric` options: `views`, `engagement_rate`, `conversions`, `roas`, `watch_time_ms`. Pick the one the customer's stage actually cares about — early-stage apps should look at `engagement_rate` and `watch_time_ms` because absolute `views` rewards luck; later-stage accounts should lean on `conversions` and `roas`.

    `window` is `7d`, `30d` (default), or `90d`. There is no offset or custom range — use [`/metrics`](/docs/api/reference/metrics/unified-metrics) for that.

    `sourceType[]` restricts to `content_container` (Layers-generated), `platform_post` (UGC / organic posts), or `manual`. `platform[]` restricts organic rows by platform and has no effect on paid-only rows.

    Every row carries both `organic` and `paid` blocks (either may be `null`). `metricValue` mirrors the value the ranker used so your UI can render "0.082" or "5.2x" without peeking into the subtree.

    <Callout type="info">
      `adsContentId` is present when Layers has already promoted the creative to paid. Those are your strongest signals — they cleared the organic bar and are now being spent against. Use `sourceType=content_container` plus `metric=roas` to get the short list of "things already earning their keep."
    </Callout>
  </Step>

  <Step>
    ## Commission more content from the winner [#commission-more-content-from-the-winner]

    Two choices. If you want a close variant — same hook, same influencer, fresh execution — clone the post. If you want to try a new angle on the same theme, generate a new piece with the winner's hook pinned in the brief.

    ### Option A: Clone the top performer [#option-a-clone-the-top-performer]

    Clone-from-post is a target-first shape: you mint a fresh UUID for the new container client-side, PUT-style, and the `:containerId` in the URL carries that id. Replay with the same id and you get the same container back — the path itself is your idempotency key.

    <Endpoint method="POST" path="/v1/content/:containerId/clone-from-post" scope="content:write" phase="1" />

    ```bash
    NEW_CONTAINER_ID=$(uuidgen)

    curl -X POST "https://api.layers.com/v1/content/$NEW_CONTAINER_ID/clone-from-post" \
      -H "Authorization: Bearer $LAYERS_API_KEY" \
      -H "Idempotency-Key: $(uuidgen)" \
      -H "Content-Type: application/json" \
      -d '{
        "projectId": "'"$PROJECT_ID"'",
        "sourcePlatformPostId": "pp_01H9ZXT7...",
        "mode": "reimagine",
        "variations": 3
      }'
    ```

    <Response status="202">
      ```json
      {
        "jobId": "job_01H9ZXV...",
        "kind": "content_clone_from_post",
        "status": "running",
        "containerId": "cnt_01H9ZXV...",
        "locationUrl": "/v1/jobs/job_01H9ZXV..."
      }
      ```
    </Response>

    A single job handles all `variations`. Poll it like any other content generation — see [Jobs](/docs/api/concepts/jobs). See [Clone a top performer](/docs/api/guides/clone-top-performer) for the difference between `fork` and `reimagine`.

    ### Option B: Generate with the hook pinned [#option-b-generate-with-the-hook-pinned]

    ```bash
    curl -X POST "https://api.layers.com/v1/projects/$PROJECT_ID/content" \
      -H "Authorization: Bearer $LAYERS_API_KEY" \
      -H "Idempotency-Key: $(uuidgen)" \
      -H "Content-Type: application/json" \
      -d '{
        "format": "video_remix",
        "variantCount": 2,
        "brief": {
          "hook": "Get things done. Faster than your to-do list app did yesterday.",
          "cta": "Download acme-todo",
          "targetPlatforms": ["tiktok", "instagram"],
          "themeTags": ["productivity", "speed"]
        }
      }'
    ```

    Use this when the hook is landing but the execution feels stale. You get a new container with the winner's hook as a starting point but different visuals, captions, and editing.
  </Step>

  <Step>
    ## Nudge the scoring pool when you need to [#nudge-the-scoring-pool-when-you-need-to]

    `organic_score` on `ads_content` rows decays over time. A post that was a top performer two weeks ago slides down the ranking as it ages, which makes room for fresh winners. Layers runs this for you on a 6-hour cadence.

    If you need to force a creative into or out of the pool — say, a top performer you want to keep eligible past normal decay, or a post the customer asked you to stop promoting — use the override:

    <Endpoint method="PATCH" path="/v1/projects/:projectId/ads-content/:id" scope="ads:write" phase="1" />

    ```bash
    curl -X PATCH "https://api.layers.com/v1/projects/$PROJECT_ID/ads-content/adc_01H9ZXT..." \
      -H "Authorization: Bearer $LAYERS_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "override": "include" }'
    ```

    `override: "include"` keeps the creative eligible regardless of score decay. `"exclude"` forces it ineligible. `null` clears the override and lets scoring run.

    Use `"include"` sparingly. The scoring pool works because it's unopinionated about yesterday's winner — overriding too often turns off the learning.
  </Step>
</Steps>

## Requires top-performer data [#requires-top-performer-data]

`/top-performers` returns `items: []` when the project hasn't synced any platform posts or promoted any ads. That's the expected shape — not an error. Before you trust the loop for a given customer, confirm there's data for it to rank:

```sql
-- Do we have organic posts synced for this project?
SELECT pp.id, pp.external_id, pp.platform, pp.published_at
FROM platform_posts pp
INNER JOIN social_accounts sa ON pp.social_account_id = sa.id
WHERE sa.project_id = '<projectId>'
ORDER BY pp.published_at DESC
LIMIT 5;

-- Do we have any ads_content scored for this project?
SELECT ac.id, ac.source_type, ac.organic_score, ac.scored_at
FROM ads_content ac
INNER JOIN project_ads_content pac ON pac.ads_content_id = ac.id
WHERE pac.project_id = '<projectId>'
ORDER BY ac.organic_score DESC NULLS LAST
LIMIT 5;
```

* Zero rows in the first query → no social accounts linked, or SIFT sync hasn't run (runs every 30 minutes on new connections). Top-performers for organic metrics will be empty.
* Zero rows in the second query → no creatives generated or promoted yet. Paid metrics (`conversions`, `roas`) will be empty regardless of window.
* Rows exist but `organic_score` is `NULL` or low → content-generation ran but the `update-organic-performance` job hasn't scored it yet (6-hour cadence). Rankings by paid metrics still work; ranking by organic metrics will return fewer rows than you'd expect.

If the loop has to run before real data exists, show the customer an empty state rather than a 500 — the endpoint is returning a valid empty response.

## Running the loop in production [#running-the-loop-in-production]

A reasonable cadence for an agent:

* **Daily** — read `/top-performers` ranked by whatever the customer actually cares about. Surface the top 3 in your UI.
* **On breakout detection** — engagement\_rate above 2x the customer's median. Auto-clone with `variations: 2` and hold the results for approval.
* **Weekly** — read `/ads-metrics` to close the loop on which creatives drove installs or purchases, not just views. Use that to inform the next week's briefs.

Poll intervals: `/top-performers` is cached at the platform-sync cadence, so polling it faster than every few minutes buys nothing. `/metrics` is similar — daily reads are plenty for most partner UIs.

## What's next [#whats-next]

<Cards>
  <Card title="Clone a top performer" href="/docs/api/guides/clone-top-performer" description="Focused playbook on fork vs reimagine." />

  <Card title="Onboard a customer" href="/docs/api/guides/onboard-customer" description="The prerequisite if you skipped it." />

  <Card title="Configure auto-pilot engagement" href="/docs/api/guides/configure-auto-pilot-engagement" description="First-comments and replies on live posts." />
</Cards>
