Publish to learn
After the first few posts land, read metrics, pick top performers, and commission more of what's working.
What you'll build
The feedback loop an agent runs once a customer has published content: 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 the Getting started flow and have enough live posts and views for ranking to be meaningful.
The running example is acme-todo, a productivity app with published content.
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 synced posts before expecting ranked rows. See Requires top-performer data below.
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.
/v1/projects/:projectId/metrics- Auth
- Bearer
- Scope
- metrics:read
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"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();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"]{
"scope": "project",
"id": "prj_254a4ce1-f4ca-42b1-9e36-17ca45ef3d39",
"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
}
}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 instead. Asking for conversions/spend/roas on this endpoint returns VALIDATION.
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.
/v1/projects/:projectId/top-performers- Auth
- Bearer
- Scope
- metrics:read
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"{
"metric": "engagement_rate",
"window": "30d",
"items": [
{
"rank": 1,
"sourceType": "platform_post",
"sourceId": null,
"platformPostId": "pp_01H9ZXT7...",
"adsContentId": "adc_6f5d4c3b...",
"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
}
]
}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 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 item 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.
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."
Generate a new piece with the winner's hook pinned
When a hook is landing but the execution feels stale, take the winner's hook and pin it into a fresh generation. You get a new container with the winner's hook verbatim but different visuals, captions, and editing.
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": "slideshow-builder",
"variantCount": 2,
"hook": "Get things done. Faster than your to-do list app did yesterday."
}'Poll the resulting job like any other content generation — see Jobs.
Nudge the scoring pool when you need to
organic_score on ads_content rows decays over time. Older winners slide down the ranking as they age, which makes room for fresh winners. Layers runs this scoring for you.
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:
/v1/projects/:projectId/ads-content/:id- Auth
- Bearer
- Scope
- ads:write
curl -X PATCH "https://api.layers.com/v1/projects/$PROJECT_ID/ads-content/adc_6f5d4c3b..." \
-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.
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:
-
Connected social accounts have recent published posts.
-
Ads have run long enough to produce paid metrics.
-
GET /v1/projects/:projectId/top-performersreturns non-emptyitems. -
For a new customer, wait until posts or ads have accumulated enough signal before promoting winners.
-
Zero rows in the first query → no social accounts linked, or platform post sync hasn't run yet. 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_scoreisNULLor low → content-generation ran but organic scoring hasn't processed it yet. 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
A reasonable loop for an agent:
- Regular review - read
/top-performersranked 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. Generate fresh variants with the winning hook pinned in the brief and hold the results for approval.
- Paid-performance review - read
/ads-metricsto close the loop on which creatives drove installs or purchases, not just views. Use that to inform future briefs.
Poll intervals: /top-performers and /metrics are cached, so poll for product need rather than tight loops.
What's next
Upload finished content
Bring your own finished posts. Two transports, byte-for-byte publishing, advisory platform fit, and a quota that always names its limits.
Run ads as a partner
End-to-end flow for partner-issued ad writes — connect, import existing campaigns or create one, set authority, trigger optimizer, observe pending queue, approve/reject.