Recommendations
A recommendation is one actionable, scored suggestion attached to a page's analysis — e.g. "lead the title with the primary keyword." Recommendations aren't listed or fetched standalone; you read them embedded in GET /pages/{pageId}/analysis, then act on individual ones by recId through the three verbs on this page.
All three verbs require content:write and return the same envelope shape: the updated recommendation plus a refreshed analysis score summary, so you see the effect of your action in the same response — no follow-up GET needed to know whether the score moved.
The verification model
resolve and recheck both call an AI validator (Claude Haiku) that reads the recommendation and the current content and judges whether the recommendation was actually addressed. This is the only place in the API that runs AI on a write path.
- Availability-first. If the validator errors or times out (6s), the recommendation is auto-verified —
addressed: truewithskipped: true— rather than blocking the call. A human already made the edit; a telemetry-grade check failing shouldn't hold up the response.skipped: trueis your signal that the "pass" wasn't independently checked. - A skipped verdict does NOT count toward the verified score. "Verified" means verified: a recommendation applied on a skipped verdict keeps its
status: appliedand still counts towardestimated_score, but it does not promoteoverall_score/score_source. Callrecheckonce the content is final — a real verdict carries noskippedflag, and the promotion lands on the normal recalculation. - Placeholders don't block addressing, but they block score promotion. The validator also flags unresolved template fragments in the content (
"[Your Company]","XX%",TODO, lorem-style filler) asplaceholders. A recommendation with placeholders can still beaddressed: trueandstatus: applied— the structural change was made — but leaving placeholders in place is a publishing-readiness problem, not a scoring one. Replace them and callrecheckto confirm; a clean recheck is what actually promotesoverall_scorefromaudittoverified. Fragments a user has marked as real content in the MetaMonster UI are excluded fromplaceholderson every recheck verdict; regenerating the content (accepting a new AI action or bulk rewrite) starts a fresh scan. force: trueonresolveskips validation entirely. The recommendation is markedappliedwith a syntheticskipped: trueverdict andvalidation_scope: null— use when you're confident the change is right and don't want to spend the validation round-trip. Same caveat as above: it won't promote the verified score until a realrecheckconfirms it.- Content MetaMonster applies for you skips the AI validator too. A recommendation applied by MetaMonster itself — accepting an AI-generated action, or a bulk content rewrite — never runs the Haiku check in the first place. It's verified with a deterministic placeholder scan instead:
addressedis alwaystruefor non-empty applied content, andplaceholdersis what actually tells you whether it's publish-ready. This verdict carriesvalidation_scope: "system_applied". Applied content with no verifiable surface (e.g. an untargeted action) still falls back to the ordinaryskipped: trueverdict — same no-promotion rule as any other skipped case. Callingrecheckon one of these recs stays on the same fast path: it re-runs only the placeholder scan against the current content — no AI validator round-trip — and the stored/returnedvalidation_scopestayssystem_applied. If nothing resolves to validate against on the recheck (e.g. the targeted content was since deleted), it returns the ordinaryskipped: trueverdict instead of400 no_content_to_validate— same no-promotion rule as any other skipped case — see the scope table below.
Verification outcomes
resolve and recheck return the same top-level verdict block — addressed, reasoning, skipped, placeholders, validation_scope, validated_node_ids — plus recommendation and analysis. The same fields (minus reasoning duplication) are mirrored on recommendation.verification so a later GET /pages/{pageId}/analysis replays them.
| Outcome | Status change | Counts toward estimated_score |
Counts toward verified overall_score |
What to do |
|---|---|---|---|---|
addressed: true, skipped: false, placeholders: [] |
→ applied (on resolve) |
Yes | Yes | Nothing — the points landed. |
addressed: false |
none — stays pending/in_progress on resolve, stays applied on recheck |
Yes (still open) | No | Read reasoning, adjust the edit, resolve (or recheck) again. |
skipped: true |
→ applied (on resolve) |
Yes | No | The validator never ran (force: true, it errored/timed out, or — for a system_applied rec — there was no verifiable content surface to scan). Call recheck once the content is final to earn the promotion. |
placeholders non-empty |
→ applied (on resolve) |
Yes | No | Replace the listed fragments verbatim, then recheck. |
400 no_content_to_validate |
none — nothing is written | Yes (still open) | No | Nothing could be resolved to validate against. Pass content explicitly, or write the field draft / save page content first, then retry. |
The Status change column describes resolve. recheck never changes status — its only gate is status: applied, and the rec stays applied whatever the verdict; only validation_result is rewritten. So a rec that fails a recheck (addressed: false) stays applied but stops counting as verified: the promoted overall_score gives its points back, and because a failed verdict isn't an earnable open gain it drops out of estimated_score too. Fix the content and recheck again to re-earn them.
validation_scope and validated_node_ids
validation_scope tells you which slice of the page the verdict was formed against, so you never have to guess why a verdict came out the way it did:
validation_scope |
The validator read | validated_node_ids |
|---|---|---|
inline_content |
the content you passed on the request |
null |
targeted_nodes |
the recommendation's targeted doc nodes — plus the full current document, always, as a second labelled section | the node ids whose markdown was sent |
full_document |
the whole current content version (no targeted nodes resolved) | null |
field_draft |
the field's active draft value | null |
field_snapshot |
the latest crawled snapshot's value for the field (no active draft) | null |
system_applied |
nothing — MetaMonster applied this content itself (AI action accept, bulk rewrite) and ran a deterministic placeholder scan instead of the AI validator | null |
null |
nothing was validated (force: true) — and, on a stored recommendation.verification, also verdicts produced outside the public API (verified in-app via the dashboard's own resolve/recheck) or written before scopes were recorded |
null |
Body recommendations are always validated against the targeted nodes and the full document. Node-scoped validation alone used to fail a recommendation like "mention the primary keyword in the intro" whenever the phrase landed in a neighbouring node; the whole document now rides along as context (capped at 60,000 characters, with [… truncated] appended past the cap), so the recommendation passes wherever the change landed. validation_scope stays targeted_nodes in that case — it names the primary read, and validated_node_ids tells you which nodes that was.
content_excerpt
Every recommendation response includes recommendation.content_excerpt — the markdown of the specific doc node(s) the recommendation targets, so you can locate the passage without touching ProseMirror node ids. It's populated when the recommendation targets body_content and the API was able to resolve the targeted nodes from the current content version; it's null for metadata-target recommendations (title, meta description, etc.) and when nothing could be resolved. GET /pages/{pageId}/analysis populates it for every body_content recommendation up front; the verb responses below populate it only when the verb itself extracted targeted content along the way (e.g. resolve auto-resolving content for a body_content rec) — if you passed content explicitly, or the recommendation targets metadata, expect null here even though the recommendation was still updated correctly.
Internal-link recommendations
An "add internal links" recommendation is scored against your current content — specifically, the links found in whatever GET /pages/{pageId}/content (or the brief's fields.body.value) currently returns for the page, not the links the crawler saw on the live site. So: add links via POST /pages/{pageId}/content (or PUT /pages/{pageId}/drafts/body + publish), then POST /pages/{pageId}/analyze — the new links count on that next audit. Links that only exist in the site's crawled navigation or footer (visible via GET /pages/{pageId}/links) are context for the model, not counted toward this criterion — they were never part of the page's own content in the first place.
The schema workflow
A schema-target recommendation (e.g. "add Organization structured data") is resolved the same way as any metadata rec, but the write step is specific:
PUT /pages/{pageId}/drafts/schemawith{ "draft_value": "<JSON-LD string>" }. The write only fails (400 invalid_request) if the JSON itself doesn't parse; schema.org rule violations save anyway and come back asvalidationon the response — see drafts.md.POST /recommendations/{recId}/resolvewith no body.contentdefaults to the draft you just wrote (falling back to the crawled snapshot'sschemaif you skip step 1), so there's nothing to pass explicitly.
You can also check schema health any time from the brief's fields.schema.validation without a dedicated call — see brief.md.
POST /recommendations/{recId}/dismiss
Dismiss a recommendation — "we're not doing this." Stores an optional rationale (kept for future analysis context) and recalculates scores: a dismissed recommendation's points are forfeited from estimated_score.
Scope: content:write
Path parameters
| Parameter | Type | Description |
|---|---|---|
recId |
integer | The recommendation's ID |
Request body
| Field | Type | Notes |
|---|---|---|
rationale |
string | Optional. Max 2000 chars, trimmed. Stored on the recommendation as dismissal_rationale. |
curl -X POST https://new.metamonster.ai/api/v1/recommendations/5500/dismiss \
-H "Authorization: Bearer mm_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "rationale": "Already A/B tested — keyword-first titles underperformed here." }'
Response 200
{
"data": {
"recommendation": {
"id": 5500,
"analysis_id": 7200,
"page_id": 5001,
"site_id": 42,
"title": "Lead the title with the primary keyword",
"description": "Move \"pricing\" toward the front of the title tag.",
"why_it_matters": "Front-loaded keywords improve relevance signals.",
"impact": "medium",
"status": "dismissed",
"source": "audit",
"source_components": ["title"],
"operation": "replace",
"target_field": "title",
"target_node_ids": null,
"after_node_id": null,
"insertion_point": null,
"rubric_criteria": null,
"resolution_method": null,
"resolved_at": "2026-08-14T10:10:00Z",
"dismissal_rationale": "Already A/B tested — keyword-first titles underperformed here.",
"sort_order": 1,
"point_value": 4,
"carried_from_recommendation_id": null,
"verification": null,
"content_excerpt": null,
"created_at": "2026-07-19T18:00:00Z",
"updated_at": "2026-08-14T10:10:00Z"
},
"analysis": {
"overall_score": 78,
"overall_grade": "B",
"estimated_score": 82,
"estimated_grade": "B",
"score_source": "audit",
"verified_at": null
}
}
}
recommendation uses the same shape documented in GET /pages/{pageId}/analysis, plus content_excerpt (always null on dismiss — dismissing never resolves content). analysis is null if the recommendation had no analysis_id; otherwise it's { overall_score, overall_grade, estimated_score, estimated_grade, score_source, verified_at } re-read after the recalculation triggered by this call.
Errors
| Status | When |
|---|---|
404 not_found |
No such recommendation in your organization (Recommendation not found) |
409 already_resolved |
The recommendation is already dismissed or already applied (message: Recommendation is already dismissed / Recommendation is already applied) |
POST /recommendations/{recId}/resolve
"I made this change" — the agent's feedback signal. Validates the current content against the recommendation and, on a pass, marks it applied (earning its points). See The verification model above.
Scope: content:write
Path parameters
| Parameter | Type | Description |
|---|---|---|
recId |
integer | The recommendation's ID |
Request body
| Field | Type | Notes |
|---|---|---|
content |
string | Optional. Max 1,000,000 chars. The content to validate against (validation_scope: "inline_content"). For body_content recommendations, omit it — the API resolves it itself: the targeted doc nodes' markdown (target_node_ids) if resolvable plus the full current document as context, else the full document alone. For title/meta_description/h1/schema recommendations, omit it too — content defaults to the field's active draft (PUT /pages/{id}/drafts/{field}), falling back to the latest crawled snapshot value if there's no draft. |
force |
boolean | Optional. Skip validation entirely — see The verification model. |
Request — auto-resolved body content
curl -X POST https://new.metamonster.ai/api/v1/recommendations/5501/resolve \
-H "Authorization: Bearer mm_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
Response 200 — addressed
{
"data": {
"addressed": true,
"reasoning": "The section now includes a plan comparison table addressing pricing objections as recommended.",
"skipped": false,
"placeholders": [],
"validation_scope": "targeted_nodes",
"validated_node_ids": ["node_42a"],
"recommendation": {
"id": 5501,
"analysis_id": 7200,
"page_id": 5001,
"site_id": 42,
"title": "Add a plan comparison table",
"description": "Insert a table comparing plan tiers under the intro paragraph.",
"why_it_matters": "Comparison tables reduce bounce on pricing pages.",
"impact": "high",
"status": "applied",
"source": "audit",
"source_components": ["structure"],
"operation": "insert",
"target_field": "body_content",
"target_node_ids": ["node_42a"],
"after_node_id": "node_41",
"insertion_point": "after",
"rubric_criteria": null,
"resolution_method": "manual",
"resolved_at": "2026-08-14T10:12:00Z",
"dismissal_rationale": null,
"sort_order": 2,
"point_value": 6,
"carried_from_recommendation_id": null,
"verification": {
"addressed": true,
"reasoning": "The section now includes a plan comparison table addressing pricing objections as recommended.",
"placeholders": [],
"skipped": false,
"validation_scope": "targeted_nodes",
"validated_node_ids": ["node_42a"]
},
"content_excerpt": "| Plan | Price | Seats |\n| --- | --- | --- |\n| Starter | $19 | 1 |\n| Team | $49 | 5 |",
"created_at": "2026-07-19T18:00:00Z",
"updated_at": "2026-08-14T10:12:00Z"
},
"analysis": {
"overall_score": 82,
"overall_grade": "B+",
"estimated_score": 86,
"estimated_grade": "B+",
"score_source": "verified",
"verified_at": "2026-08-14T10:12:00Z"
}
}
}
resolution_method is "manual" for every API-driven resolve (mirrors the dashboard's manual-resolve path — distinct from "ai_action", which only AI-applied actions set). Note score_source flipping to "verified" with verified_at populated: promoting overall_score out of audit happens automatically the first time a validated, addressed recommendation lands.
Response 200 — not addressed
The recommendation stays open (status unchanged, still pending/in_progress) and no points are earned — only validation_result/verification is updated:
{
"data": {
"addressed": false,
"reasoning": "The title still leads with the brand name, not the primary keyword.",
"skipped": false,
"placeholders": [],
"validation_scope": "field_draft",
"validated_node_ids": null,
"recommendation": {
"id": 5501,
"status": "pending",
"verification": {
"addressed": false,
"reasoning": "The title still leads with the brand name, not the primary keyword.",
"placeholders": [],
"skipped": false,
"validation_scope": "field_draft",
"validated_node_ids": null
},
"content_excerpt": null,
"…": "…other fields unchanged"
},
"analysis": null
}
}
(analysis is null here only because this example recommendation has no analysis_id; normally it's the same refreshed summary object as above — unaffected, since nothing was applied.)
Errors
| Status | When |
|---|---|
400 no_content_to_validate |
No content was provided and none could be resolved (e.g. body_content target with no content version yet, or a title/meta_description/h1/schema target with no draft and no snapshot value). The recommendation stays open and nothing is written |
404 not_found |
No such recommendation in your organization (Recommendation not found) |
409 already_resolved |
The recommendation is already dismissed or already applied |
POST /recommendations/{recId}/recheck
Re-verify an already-applied recommendation after you've fixed something the first verification flagged — typically unresolved placeholders, or a skipped: true verdict from force: true/validator unavailability. Recheck doesn't change status (it's already applied); it re-runs validation and, if the fresh verdict is a clean pass, that's what promotes overall_score/score_source from audit to verified via the normal recalculation. The only gate is status: applied — a skipped verdict is always re-checkable, and a real verdict carries no skipped flag, so the promotion lands.
Where recheck reads content from — recheck never accepts inline content; it validates against what MetaMonster currently holds for the page:
- Body-content recs (
target_field: "body_content"): the latest content version, narrowed to the nodes the original apply landed in (applied_node_ids), else the rec'starget_node_ids, else the whole document. When it narrows to nodes, the full document still rides along as validator context, so a fix made elsewhere on the page counts. Recrawls andPOST /pages/{id}/contentsaves both feed this. - Metadata/schema recs (
title,meta_description,h1,schema): the field's current draft (PUT /pages/{id}/drafts/{field}), falling back to the latest crawled snapshot value if there's no active draft. Note thatresolvewith inlinecontentis a one-shot verdict — it stores nothing — so a metadata rec you resolved that way has nothing to recheck against beyond the snapshot until youPUTthe draft. If you plan to iterate on a metadata field, write the draft first and letresolve/recheckread it.
If neither source yields content you get 400 no_content_to_validate — except for a system_applied rec (see above), which gets the ordinary skipped: true verdict instead of a 400.
Scope: content:write
Path parameters
| Parameter | Type | Description |
|---|---|---|
recId |
integer | The recommendation's ID |
Request
No body.
curl -X POST https://new.metamonster.ai/api/v1/recommendations/5501/recheck \
-H "Authorization: Bearer mm_YOUR_API_KEY"
Content resolution priority: the node ids recorded on the original apply (validation_result.applied_node_ids) → the recommendation's target_node_ids → the full current document (for body_content/no-target recs) or the field's current draft value, falling back to the latest crawled snapshot value (for title/meta_description/h1/schema-target recs).
Response 200
{
"data": {
"addressed": true,
"reasoning": "The placeholder pricing figures have been replaced with real numbers.",
"skipped": false,
"placeholders": [],
"validation_scope": "targeted_nodes",
"validated_node_ids": ["node_42a"],
"recommendation": {
"id": 5501,
"status": "applied",
"resolution_method": "manual",
"verification": {
"addressed": true,
"reasoning": "The placeholder pricing figures have been replaced with real numbers.",
"placeholders": [],
"skipped": false,
"validation_scope": "targeted_nodes",
"validated_node_ids": ["node_42a"]
},
"content_excerpt": "| Plan | Price | Seats |\n| --- | --- | --- |\n| Starter | $19 | 1 |\n| Team | $49 | 5 |",
"…": "…other fields unchanged"
},
"analysis": {
"overall_score": 82,
"overall_grade": "B+",
"estimated_score": 86,
"estimated_grade": "B+",
"score_source": "verified",
"verified_at": "2026-08-14T10:20:00Z"
}
}
}
placeholders, skipped, validation_scope, and validated_node_ids are top-level fields on the response (not just inside verification) — the same values, surfaced for convenience since recheck's whole purpose is usually "did I clear the thing that blocked promotion." An empty placeholders array plus skipped: false means clean; either one otherwise means score promotion (if it hadn't already happened) still won't occur. See Verification outcomes.
Errors
| Status | When |
|---|---|
400 no_content_to_validate |
No content could be resolved to validate against — for metadata/schema recs this means no draft and no snapshot value exist for the field yet (see above). Nothing is written. Does not apply to system_applied recommendations, which return a skipped verdict instead |
404 not_found |
No such recommendation in your organization (Recommendation not found) |
409 not_applied |
The recommendation isn't currently applied — it's still open (resolve it first) or it was dismissed (nothing to recheck) (message: Only applied recommendations can be re-checked) |