---
title: "Manage Content"
method: POST
path: "/api/facade/manage_content"
tags: ["facade"]
---

# Manage Content

`POST /api/facade/manage_content`

## Request

- Base URL: `https://api.vruum.ai`
- URL: `https://api.vruum.ai/api/facade/manage_content`
- Auth: `HTTPBearer` (a scheme the document does not define)

Draft, schedule and publish LinkedIn content — the consolidated content writer.

Objective content (kind='objective_content', id = objective UUID):
First fetch type='marketing' subtype='objective_content' id=<objective>.
- prepare: internally author a sourced LinkedIn text draft. payload =
  {author_user_id, follow_up_owner_user_id, direction?, publication_notes?}.
  Uses current objective/offer/shared voice, never private customer stories.
  Returns typed content, rationale, exact citations and context_fingerprint.
  It sends nothing and does not overwrite saved edits.
- draft: save internal or harness copy. payload = returned preparation fields
  plus client_request_id (reuse on retry). To revise, add post_id and
  expected_revision; revision cancels prior approval and schedule. Harnesses
  can author from the fetched context with exact offer/publication_notes quotes.
- decide: after the person reviews full copy, author, sources and timing,
  payload={post_id, expected_revision, expected_version: action_version,
  decision: publish|schedule|reject, scheduled_at?}. Scheduling requires a
  future ISO datetime. Reuse exact payload after an uncertain response;
  an unknown provider outcome NEVER authorizes a replacement publication.
- handoff: assign reviewed captured interest. payload={post_id, engagement_id,
  owner_user_id, reason, next_check_at}. Reuses existing person/objective work.
  It sends nothing. Publication creates owned response review; neither
  engagement nor follow-up completion establishes pipeline or revenue.

- response_prepare: assess an assigned responder and prepare a cited email or
  hold. payload={action_id, expected_version}. First read fetch type=marketing
  subtype=content_response id=<objective> filters={action_id}. This purchases
  one model response; it does not save, approve or send.
- response_draft: save internal/harness preparation or edits. payload contains
  action_id, expected_version, client_request_id, context_fingerprint, decision
  (contact|hold), rationale, citations [{source_key,quote}], missing_facts,
  subject and body (null for hold). Reuse the complete request on retry.
  Contact uses manage_outreach objective_opportunity_send with the saved
  exact subject/body and returned version, after separate human approval.

Content actions (default kind; id = post UUID unless noted):
- draft: create a post draft (no id; `payload` = {content, topic_tags?,
  scheduled_at?, author_user_id?, author_identity?, external_link?,
  attachment_creative_id?}); bulk via `items`.
- edit: update a draft's content/tags/identity/link/attachment
  (`payload` = the fields to change: content?, topic_tags?,
  author_identity?, external_link? — empty-string external_link clears
  it; attachment_creative_id? — explicit null DETACHES, absent key
  leaves it untouched).
- schedule: set the publish time (`payload` = {scheduled_at}). A
  row-persisted attachment publishes with the scheduled post.
- publish: publish the post now (no payload; the retired publish-time
  creative_id param is rejected with the pointer to edit-attach).
- delete: remove a draft from the calendar (drafts only — published
  posts are not deletable here).
- draft_post: never generates prose — backend generation is removed
  (VRU-676, permanently). Always returns author-in-harness guidance:
  write the post yourself and submit via draft, then schedule/publish
  (no id; `payload` = {topic?, content_type?, author_user_id?}).

Attachments (VRU-726) — document (PDF carousel), image, or video, ONE
contract, persisted on the row (schedulable + reviewable). Golden path
for a PDF, entirely via MCP:
  1. manage_content kind='ad' action='store_creative'
     payload={filename:'my-deck.pdf', size_bytes:<n>,
     content_type:'application/pdf'} → returns upload_url
  2. curl --fail-with-body -T my-deck.pdf '<upload_url>'
  3. store_creative again with payload={creative_id:'<id>'} → validates
     (small assets can skip 1-3: payload={asset_base64:'<b64>'})
  4. manage_content action='draft' payload={content:'<caption>',
     attachment_creative_id:'<id>'}
  5. get_content_review post_ids=['<post_id>'] → OPEN attachment_url and
     review the actual file (it publishes under the author's identity)
  6. manage_content action='publish' id='<post_id>'
The stored filename becomes the rendered LinkedIn document title
(override at store time via `filename`). Rules: one attachment per post;
not on repost_commentary; caption text required; stored assets are
public-at-upload (public bucket, unguessable URL). Media transfers
inline at publish — expect tens of seconds for large files. Attachment
failures revert the post to draft with a structured {code, message, fix}
422 and never burn post budget on a failed fetch; an ambiguous provider
timeout stays terminal `failed` (`publish_outcome_unknown`) — check
LinkedIn before retrying, a blind retry can double-post.

Identities (VRU-659): `author_identity` is 'member' (the author's
personal profile — default) or 'organization' (the Company Page).
Organization publish requires a resolvable Company Page on the author's
LinkedIn account: exactly one Page on the account, or a selected Page
(manage_content kind='ad' action='set_page'); the member account still
authenticates and must hold admin rights on the Page.

`external_link` (http/https, <=2000 chars) renders as the LinkedIn
preview card; UTM params are stamped when the link is SAVED (missing
utm_* added, existing ones never clobbered), so the approved draft is
exactly what publishes. Combines fine with an attachment.

Bulk: edit/schedule/publish/delete take id arrays (server-side loop,
per-item status — partial failure reported per item, never silent; max
100). Payloads are validated by the underlying route's schema.

Prose gate: draft/edit lint the content and schedule/publish re-lint it
server-side; blocked calls return error_code='prose_gate_blocked' with
structured `failures[].fix`. `payload` accepts `override_reason` (taste
override — proceeds despite block failures, logged to the corpus;
honored only for owner/operator roles on the tenant, otherwise ignored
and the gate blocks normally) and `client_rules_version` (responses
flag `rules_changed` when the server's rules differ). Pre-check drafts
cheaply with check_prose.

Ad actions (kind='ad'; id = ad-campaign UUID): reject, pause — `payload`
carries the action's fields (e.g. {rejection_reason} for reject). This
tool never starts, resumes or raises paid spend: a workspace owner or
operator approves a drafted boost in the Vruum app's Work list, or
approves, resumes or re-budgets it with the Vruum CLI (vruum ad approve |
resume | budget).
boost (no id — there's no campaign yet): prepare a paid LinkedIn campaign
as a DRAFT; it pushes nothing and spends nothing, and it appears in the
Work list for an owner to approve or decline. `payload` carries {content_post_id (sponsor an EXISTING
published post) XOR creative_id (a stored VIDEO creative → Direct
Sponsored Content, METRICS-ONLY — no engager bridge), vehicle?,
objective?, budget:{daily_budget_cents/total_budget_cents}, audience (see
below), duration_days?, integration_id?}.

Boost audience — exactly one of:
- {facets}: the PRIMARY form. LinkedIn-native firmographic targeting: a
  map of facet key (titles, seniorities, industries, locations,
  staff_count_ranges) → list of LinkedIn entity URNs. 'locations' is
  REQUIRED (LinkedIn rejects campaigns without location targeting).
  Values must be entity URNs, never display names — resolve names FIRST
  via `fetch type='ads' subtype='targeting_entities'
  filters={facet, q}` (that resolve-then-boost flow is the happy path).
- {matched_audience_id}: a PRE-EXISTING uploaded matched audience only.
- {criteria}: partner-gated — rejected 400 with code
  matched_audience_partner_gated (LinkedIn's audience-member API needs
  partner access); the error's `fix` shows the facets form to use instead.

Vehicle/objective/format legality (enforced server-side BEFORE any spend;
illegal combos 400 with the legal alternatives enumerated):
| vehicle          | objectives                          | source            | destination CTA        |
|------------------|--------------------------------------|-------------------|-------------------------|
| thought_leader   | BRAND_AWARENESS, ENGAGEMENT only    | member post only  | NOT allowed            |
| page_sponsored   | all (BRAND_AWARENESS, ENGAGEMENT,   | org post or video | required for           |
|                  | WEBSITE_VISIT, WEBSITE_CONVERSION,  | creative_id (DSC) | WEBSITE_VISIT /        |
|                  | LEAD_GENERATION, VIDEO_VIEW)        |                   | WEBSITE_CONVERSION     |
Destination = the post's external_link (set at draft/edit via this
action's content kind) or the video creative's cta_destination_url.
Vehicle is inferred from the post's publish identity when omitted
(member ⇒ thought_leader, organization ⇒ page_sponsored). Objectives use
LinkedIn's enum verbatim — do not lowercase.

store_creative (no id — there's no campaign yet): store a harness creative
as a DRAFT (zero spend; approval is separate). Stored assets serve ads AND
organic post attachments (attach via this action's content kind
draft/edit payload.attachment_creative_id — VRU-726). BASE64 `payload`
(small PNG/JPEG/PDF; format decided by server-side sniff):
{asset_base64 (raw base64, no data: URL — renamed from image_base64),
generation_prompt (required for images only), filename?, campaign_id?
(owned draft warming campaign to attach), generation_provenance? ({model,
tool, generated_at, notes} allow-list, string values)}. PRESIGNED
`payload` (the primary path for real files — mp4 ≤200MB 3s–30min, or PDF
≤25MB): {filename, size_bytes, content_type ('video/mp4' |
'application/pdf')} → returns a presigned upload_url; `curl
--fail-with-body -T <file> '<upload_url>'`, then call store_creative
again with {creative_id} to validate/finalize (idempotent; a validation
failure returns the failed row with ok=false and the restart step;
unfinalized rows are swept after 24h). VIDEO also accepts {media_url
(public https — fetched + validated in the background)} and optional
{thumbnail_base64}. `filename` becomes the stored file_name — for
documents it is the rendered LinkedIn carousel TITLE. media_url video
stores are ASYNC — poll `fetch type='ads' subtype='creative'
id=<creative_id>` until upload_status leaves 'uploading' ('failed' +
error_message starting with a probe code means re-export/re-store;
otherwise retriable).

Identity setup (kind='ad', no id — VRU-659):
- set_page: `payload` = {organization_urn? (urn:li:organization:<digits>),
  integration_id?}. Omit organization_urn to DISCOVER the candidate Pages
  from the connected LinkedIn accounts, then call again with one. Required
  before page_sponsored campaigns and video ads.
- authorize_author: `payload` = {author_user_id, auto_approve?,
  integration_id?} — records the operator ATTESTATION that this team
  member consented to Thought Leader Ad sponsorship (required before a
  thought_leader boost; LinkedIn still enforces its own permission).
  Response lists the currently authorized authors.
- revoke_author: `payload` = {author_user_id, integration_id?}.

Ad bulk: `id` arrays loop with per-item status (partial failure is
reported per item, never silent; max 100). Payloads are validated by the
underlying route's own schema.

## Headers

- `Idempotency-Key` string, nullable: Optional. Send a unique key (a UUID) to make this call safe to retry: an exact retry of a SUCCESSFUL call replays the original response instead of re-running the action, so a dropped connection cannot double-send or double-spend. A deterministic 4xx refusal releases the key — the same key may retry once the refusing condition clears; only an ambiguous failure (>=500 or timeout) pins the key to a 409 conflict. Reusing a key with a DIFFERENT request body is rejected with 409. Keys are scoped to your company and expire after 24 hours.

## Request body

- ContentWriterRequest: Content writer request — adds the ``kind`` family selector.
  - `action` string, required: The operation to perform — see the tool description for the action vocabulary and each action's id/payload semantics.
  - `id` union: Target entity id, or an ARRAY of ids to apply the same action+payload to each (max 100; see the tool description for which entity the id names per action).
    - string
    - string[]
  - `payload` object, nullable: Action-specific fields, validated by the underlying route's own schema (a bad field 422s with that route's precise errors). See the tool description per action.
  - `items` object[], nullable: For create-like actions only: an array of per-item payloads processed in order with per-item results (max 100). Mutually exclusive with `id` and `payload`.
  - `kind` string, nullable: Content family: 'content' (default) for LinkedIn posts, 'objective_content' for sourced objective posts and response follow-up (prepare, draft, decide, handoff, response_prepare, response_draft, reply_prepare, reply_decide), or 'ad' to manage ad campaigns (actions: reject, pause, boost (always a draft), store_creative, set_page, authorize_author, revoke_author).

## Response `200`

Successful Response

- unknown

## Other responses

- `422`: Validation Error

---

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