---
title: "Manage Messages"
method: POST
path: "/api/facade/manage_messages"
tags: ["facade"]
---

# Manage Messages

`POST /api/facade/manage_messages`

## Request

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

Triage and edit outreach messages — the consolidated message writer.

Actions (id = message UUID unless noted):
- reply_plan_prepare: propose a next step for an existing ordinary email or LinkedIn
  reply draft. First fetch type=conversation_reply_plan id=<person>
  filters={message_id}. Payload: person_id and context_fingerprint. Returns
  a cited answer, human meeting/material handoff, future check or hold.
  Preparation persists nothing and grants no sending authority.
- reply_plan_save: only after the person reviews the proposal, save its
  fields, person_id, context_fingerprint and stable request_id. Atomically
  records the canonical commitment and owned manual follow-up. A reply
  updates the unsent draft; a hold/handoff rejects that reviewed unsent
  draft. Return to full native draft review for separate exact approval.
  No meeting, material delivery or customer outcome is established.
- link_commitment_meeting: explicitly link or unlink a captured meeting to a
  reply Action. Read fetch type=conversation_meetings id=<person> with
  message_id first. Pass the exact snapshot, expected_link_id, commitment ID,
  decision (link/unlink) and rationale. This records operator attribution,
  never provider confirmation, calendar changes or completion.
- commitment_followup: assign, stop or close manual follow-up work. Scalar
  id is the reply message. Read conversation_commitments first. Payload pins
  person_id, action_id, commitment_evidence_id, expected_review_id and the
  latest expected_followup_id/version (null if absent), plus request_id,
  decision and rationale. assign requires an active workspace owner_user_id
  and future due_at; stop/close omit both. close requires a current completed
  review for the same commitment. Exact retries reuse request_id. Assignment
  authorizes manual tracking only, never contact or calendar execution.
- review_commitment: record a sourced completion review or reopen it.
  Fetch conversation_commitments first. Pass person_id, action_id,
  commitment_evidence_id, expected_review_id (null for the first review),
  request_id (retain on exact retry), decision (completed|reopened), and
  rationale. Completion source is {kind: provider_send|meeting_booked|
  meeting_held, id: receipt/booking/attendance evidence UUID, snapshot: the
  exact delivery or meeting object returned by that read}. Meeting links
  must be exact. Reopening requires a prior completion and source=null.
  This records operator judgment, not a customer outcome or metric.
  Source corrections invalidate completion on read. Scalar message id only.
- record_commitment: record, revise or withdraw a reviewed team next step
  (including existing commitments after the reply Action ends). For revisions
  and withdrawals pin expected_commitment_id and expected_review_id from the
  current read, and supply revision_reason. Initial records use null references.
  Completion reviews and follow-up assignments do not carry over to revisions.
  Approved/executing reply work remains frozen; terminal reply state, version
  and delivery never change. Read conversation_commitments again after saving.
  First fetch type=conversation_commitments,
  id=person_id, filters={message_id}. Pass that source_message_id,
  source_hash, action_id and action_version with person_id, request_id
  (a UUID retained on exact retries), owner_user_id, due_at (timezone-bearing),
  next_step, completion_condition and disposition (active|withdrawn).
  An active record needs a current workspace owner and future due date.
  This does not approve/send a message or confirm a customer promise.
  A delivery or calendar fact does not complete the free-text condition.
  Scalar message id only. A stale refusal requires a fresh read and review.
- approve: mark a draft READY TO SEND. Sending happens asynchronously and
  respects the objective's designed schedule plus a per-person cooldown —
  approval may SCHEDULE rather than fire immediately. The response's
  `processing_state` says which ('queued_for_send', 'scheduled',
  'held_for_channel', or 'channel_state_unknown'). A hold/unknown state
  includes machine-readable `channel_hold`
  quota/reconnect feedback and `send_not_before` when it auto-resumes;
  a scheduled/held message is NOT lost and must NOT be re-approved or
  re-drafted (get_outreach_review shows `channel_holds`, the plan's
  `plan_next_action_at`, and `approved_pending_send_count`). An id
  ARRAY routes to the native bulk endpoint with per-message results.
  A saved email has retained_email metadata. Review its exact sender,
  recipient, subject and body, then use a SCALAR id and
  payload.retained_email_decision={action_id, action_version,
  proposal_revision, fingerprint} from that fresh review. This pins the
  immutable proposal. A stale or missing token refuses the decision.
- reject: reject a draft (array → native bulk, same as approve).
  Saved emails require the same scalar retained_email_decision token.
  Saved emails cannot be edited, regenerated, or decided in a batch;
  reject and start new work when their content must change.
- edit: replace subject/content (`payload` = {subject?, content?}).
  Scalar id ONLY — one call per message (an id array is rejected;
  sending identical content to many people is not a supported flow).
  Optional `attachments` sets the media sent with this draft
  (linkedin_inmail / linkedin_message / email only): a list where each item
  is a new upload {content_base64 (raw base64, no data: prefix; PNG/JPEG/GIF/
  WebP/PDF, 10MB max), filename} OR {storage_path} to keep one the draft
  already has (from get_outreach_review). Omit = leave unchanged, [] = remove
  all, max 5. Attachments on any other channel are rejected.
  Optional `asset_id` binds an outreach asset's newest version (null
  unbinds): the content must then carry {asset_link} exactly once, where
  the recipient's own link goes at send. A version whose source check
  failed is refused (`asset_check_failed`); a connection request cannot
  carry one. The reviewer sees the asset beside the draft, and approving
  the draft approves that version.
- asset_create: make up-front work for a prospect (no id; `payload` =
  {request_id, kind: pipeline_sample|account_teardown|one_pager, title,
  body (Markdown), sources: [{url, title, accessed_at?}], person_id?,
  objective_id?, company_id?, made_by: agent|member}). Every source is
  checked; a pipeline sample or teardown needs at least one. Name a
  person at another company only with a public source showing that role
  now. Reuse request_id on a retry. Creating approves and sends nothing;
  bind it with edit. Returns the asset and its version (check_state).
- asset_update: a new version (id = asset id; `payload` =
  {expected_version: the latest_version you read, title, body, sources}).
  Recipients' links keep showing the last approved version until a
  person approves this one, with its next draft or via asset_approve.
- asset_approve: a person approves a version they reviewed without a
  draft (id = asset id; `payload` = {version}). Recorded as an Action;
  links show it from then on. Never call this on your own judgment.
- asset_revoke: every link to the asset stops working (id = asset id;
  `payload` = {reason?}).
- regenerate: return the message to the authoring queue (status becomes
  needs_draft, content cleared) — author the replacement at triage via
  edit (no payload); id array loops with per-item status. Works on
  draft, rejected, AND failed rows — regenerate is the recovery path
  for a send the provider bounced (attachments survive; re-author,
  then re-approve). The row gets a FRESH 14-day authoring window; the
  response's expires_at reports the new sweep deadline (VRU-745).
- draft_reply: queue a reply for AUTHORING (creates a needs_draft
  inbound_reply row — write it via edit) — id is the COMPANY-PERSON id
  (the person you're talking to), not a message id.
- draft_followup: queue a follow-up for AUTHORING (creates a
  needs_draft followup row) — id is the COMPANY-PERSON id. Returns 409
  `touch_already_exists` (with the existing row's id and status) when the
  person's plan already has a live or delivered row for the resolved
  touch — review THAT row instead of re-drafting; never loop on this call.
  Returns 422 `content_gate_blocked` when the resolved step is a breakup
  and the person never received a content-bearing message (VRU-746) —
  author an opener instead or stop the plan; never retry as-is.
- send: send a one-off email NOW to a person (id = the PERSON id, not a
  message id; scalar only). `payload` = {client_request_id,
  sender_config_id, to_email,
  subject, body (PLAIN TEXT — blank lines become paragraphs, newlines become
  line breaks; HTML is escaped, do NOT pass tags), to_name?, thread_id?,
  in_reply_to_provider_message_id?}.
  Recorded as a manual touch (the engine won't follow up) and lands on the
  timeline attributed to you. The backend enforces the safety floor:
  to_email must match the person's known email, suppressed/archived
  recipients are rejected, the mailbox must be an active one in your
  workspace, an exact retry reuses the same client_request_id without
  double-sending (a changed payload with the same UUID is rejected), and a
  per-person cooldown blocks a second outbound within ~20h of the last
  one (error_code='cooldown_active' with `blocked_until` and the safe
  next action — if it fires, the person was contacted recently and their
  sequence is handling them; do not re-send or re-word). A deliberate
  repeat send inside the window is possible via payload
  {override_cooldown: true, override_reason} — owner/operator role only,
  audit-logged. Use ONLY when the human operator explicitly asks for a
  repeat send; NEVER decide to override on your own or in reaction to a
  cooldown block.
- send_linkedin: send a one-off LinkedIn touch NOW to a person (id = the
  PERSON id, scalar only) — no objective, no enrollment. `payload` =
  {client_request_id, send_type, body, subject?, override_cooldown?,
  override_reason?}.
  send_type is 'dm' (message an existing connection), 'inmail' (message a
  non-connection, uses an InMail credit, subject REQUIRED), or 'connection'
  (send a connection request; body is the note, truncated to the sender's
  tier cap). Send_type is validated against the person's LIVE connection
  status: a mismatch (DM to a non-connection, invite to someone already
  connected/pending) returns error_code='channel_unavailable' with the safe
  next action — pick a send_type that matches, do not retry blindly. Same
  manual safety floor as `send`: suppressed/archived recipients rejected,
  idempotent on retry, and the per-person ~20h cooldown applies to ALL
  three types including connection requests (error_code='cooldown_active').
  override_cooldown/override_reason work as in `send` (owner/operator only).
- reconcile_delivery: inspect or apply provider evidence for one unresolved
  delivery (id = PERSON id). Dry-run is the default. Applying requires
  provider_message_id, provider_sent_at, and reason; message_id disambiguates
  multiple unresolved intents and a missing/resolved explicit message_id is
  rejected (it never falls through to create). Vruum fetches the exact
  provider object through the tenant-owned Unipile account and verifies the
  outbound sender, recipient, channel payload, and provider timestamp before
  mutation; caller strings alone are not evidence. For a first-ever external
  send, omit message_id and also supply provider_account_id, channel, content,
  and subject for email/InMail (a connection request may use body/content="").
  Evidence-free operator attestation is rejected.
- reconcile_external_email: preview or apply provider-read-only Gmail
  reconciliation for one objective (id = objective UUID, scalar only).
  `payload` includes {account_id, action, preview_id?, after?, before?}.
  Resolve account_id first with fetch type=settings,
  subtype=channel_status: choose channels.email.accounts[].id for the
  intended sender mailbox. Legacy Unipile account ids remain accepted.
  action='preview' is mutation-free and returns a preview_id plus exact
  matched/ambiguous/unmatched counts. action='apply' requires that exact
  preview_id and creates reservations/finalizations without sending.
  action='exceptions' returns the exception-first rescue queue. Guarded
  action='hold' or 'release'/'mark_cancelled' requires reservation_id and
  reason; release restores dispatch permission and must only follow
  affirmative cancellation evidence or an explicit human instruction.
- backfill_external_sends: import HISTORICAL external sends (emails typed
  in Gmail/Outlook, DMs typed in LinkedIn) for known people — NO id
  (workspace-scoped). `payload` = {dry_run (required — start true and
  review result_data.items before re-running false), channel?
  ('email'|'linkedin'|'all', default all), lookback_days? (1-90, default
  30), email_account_id?, linkedin_account_id?}. Enqueues one background
  job and returns job_id — poll get_job_status for per-account summaries.
  Idempotent (provider-identity + content dedup), owner/operator only.

Bulk approve/reject hits the same endpoint the dashboard's bulk triage
uses (max 50 per call — the target's own ceiling); `payload` may carry
its extra fields but not message_ids — ids always travel via `id`.
regenerate arrays loop with per-item status (max 100); edit takes a
scalar id only. Partial failure is reported per item, never silent.
Payloads are validated by the underlying route's own schema.

Prose gate: approve and edit re-lint the content server-side and can
return error_code='prose_gate_blocked' with structured `failures[].fix`.
`payload` accepts `reason` (corpus note), `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.

## 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

- WriterRequest: Uniform request body for every facade writer.
  - `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`.

## 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)
