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.
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
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
Response
Successful Response
Changes
No recorded changes to this operation across all 1 revision of this API.