facade

Manage Messages

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.

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)

Headers

Idempotency-Keystring 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

actionstring required

The operation to perform — see the tool description for the action vocabulary and each action's id/payload semantics.

payloadobject 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.

itemsobject[] 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

Successful Response

unknown required

Changes

No recorded changes to this operation across all 1 revision of this API.