---
title: "Add participants to a group conversation"
method: POST
path: "/api/conversations/{sid}/add-participants"
tags: ["conversations"]
---

# Add participants to a group conversation

`POST /api/conversations/{sid}/add-participants`

## Request

- Base URL: `https://beta.getsales.io/unibox/v1`
- URL: `https://beta.getsales.io/unibox/v1/api/conversations/{sid}/add-participants`
- Auth: HTTP bearer and API key in header `Team-SID`

Add people to a LinkedIn GROUP thread (a conversation with is_group true). Each target is what a user would paste: a linkedin.com/in/ link, a Sales Navigator or Recruiter link, a bare slug or a raw URN; it resolves like resolve_conversation_counterpart but down to a profile URN, which is how gtm-api addresses a group member, so a playtables person known by member id alone sends the ladder on to the sender profile's connections, the corpus and the lite profile.

result.added lists the participants the thread gained (role group_member, in target order); result.skipped names each target left out with a reason: unresolvable (no rung knows the profile, or it has no URN), already_member. Nothing is refused for that. 409 not_a_group on a one-to-one thread: LinkedIn opens a NEW group for that, so start one with send_message {new_thread: {attendees: [...]}} and the counterpart among them. 409 thread_not_dispatched while the group's opening message is still queued (no gtm thread yet). 422 channel_not_connected when no live sender profile carries the thread's account. A target the resolve could not ask about refuses the whole call and adds nobody, never a skip as unresolvable: 429 (gtm_busy, or bucket_saturated with retry_after), 403 (gtm_access_lost, gtm_scope_missing), 503 (gtm_unavailable, gtm_bridge_unavailable, playtables_unavailable): try again later. gtm-api's 409 linkedin_refused on the add itself passes through.

Contract:
- MCP tool `add_conversation_participants`, registry package `mcp.unibox/conversations`, mount `unibox.threads`.
- Operation `action`, response envelope `action`.
- Flags: none.

## Path parameters

- `sid` string, required: The group thread (ub_cv_..., is_group true).

## Request body

- AddConversationParticipantsRequest: Request body of `add_conversation_participants`.
  - `targets` string[], required: 1..20 people, each a profile link, a slug or a URN (never stored: parsed at the edge). Twenty is LinkedIn's cap for a group.

## Response `200`

`action` success envelope.

- AddConversationParticipantsResponse
  - `success` true, required
  - `operation` 'action', required
  - `action` string, required: kebab-case verb; matches the route segment.
  - `item` object, nullable, required
    - `sid` string, required: Conversation sid (ub_cv_...).
    - `team_sid` string, required: Owning team (ts_tm_..., external: gs.service.id).
    - `agent_sid` string, nullable, required: The AI SDR that works the thread (ub_ag_..., block 10, 2026-10-30): written at the thread's birth from the workflow report and refreshed at every dispatch; null means the team's default. A searched thread carries the resolved agent, the one filter.agent_sid reads.
    - `channel_account_refs` object[], required: The gtm-api accounts the thread lives on, at least one. Our side of the identity anchor; the counterpart side lives on the participants.
      - `channel` 'linkedin' | 'email', required: Which channel this anchor is on.
      - `account_sid` string, required: External gtm-api account ref: ln_ac_... (LinkedIn) or em_ac_... (mailbox). Shape-validated only; the account object lives in gtm-api.
      - `provider_thread_sid` string, nullable, required: The gtm-api THREAD ref on that account (ln_cv_... / em_cv_...), which is the send-addressing key. Null until message ingest stamps it, or until the first dispatch self-heals it.
    - `channels` string[], required: Which channels actually carry messages on this thread: linkedin, email, or both.
    - `is_group` boolean, required: A LinkedIn group chat (2026-09-24): the thread is the gtm thread itself, every other member is a group_member participant and none is primary, primary_person_sid stays null, and the AI SDR never classifies or plans on it. Filter is_group {eq: true} lists the groups.
    - `title` string, nullable, required: The group's name as LinkedIn shows it (max 255), refreshed by every sync; null on a 1:1 thread and on a group nobody named. The list names an unnamed group by its members.
    - `origin` 'synced' | 'native', required: How the thread came to exist. Gates the follow-up defaults; immutable.
    - `state` 'needs_you' | 'agent' | 'waiting' | 'stalled' | 'later' | 'done', required: The attention lifecycle. Presence in needs_you is the triage signal, not a read flag. needs_you, agent and waiting follow the thread's facts after every write; stalled, later and done move only by an act (an inbound, a person's verb or own message, the snooze's end, the follow-up chain's end).
    - `created_by` object, required: Actor that opened the thread: actor_type system at ingest or history sync, user or agent for a manual / agent-started outbound.
      - `actor_type` 'user' | 'support' | 'api_key' | 'system' | 'agent', required
      - `actor_sid` string, nullable, required
      - `team_sid` string, required
      - `actor_name` string, nullable: The OAuth client that acted ("Claude", "n8n"); null for a user, an API key or a system job.
      - `oauth_client_sid` string, nullable: The acting OAuth client (id_oc_*); null off the OAuth path.
      - `reason` string, nullable: Why a system actor wrote the row (snapshot_capture_job, ...); null otherwise.
      - `permissions` object, required: Internal audit context: the grant set the actor held at write time. Not a contract.
      - `cluster_id` integer, nullable: Internal audit context: the cluster that served the write.
      - `trace_id` string, nullable: Internal audit context: the trace id of the request that wrote the row.
    - `sender_profile_sid` string, nullable, required: The sender profile this thread shows under (ub_sp_...). A display and filter lens resolved from the channel account's current profile binding and re-resolved when that binding moves, NOT part of thread identity.
    - `primary_person_sid` string, nullable, required: Denorm of the primary counterpart's playtables link (cr_pp_..., external). Null means unlinked, which is a normal state, not an error: the thread is then protected by the local do_not_contact block instead of the playtables stoplist.
    - `scenario_key` string, nullable, required: The current platform scenario (interested, meeting_request, out_of_office, not_interested, do_not_contact, ...). An OPEN, server-curated set that grows additively, so tolerate keys you do not know. Null before the first classification.
    - `origin_workflow_sid` string, nullable, required: The workflow whose first touch bore the thread (wf_wf_..., external playtables), stamped once and never refreshed: the campaign the conversation was initiated in, the campaign rung of the AI SDR's context (2026-10-12). Null on a thread the lead, a person or the history walk started, however many workflows act on it later; automation_ref says who owns the lead now.
    - `agent_mode_override` 'autopilot_ok' | 'copilot_only' | 'off', nullable, required: Per-lead autonomy override; null means no override, so the ladder falls through to the automation and then the agent default. A manual user send on an agent-engaged thread auto-writes copilot_only until a human clears it.
    - `automation_ref` object, nullable, required: The playtables automation that owns the lead; null when no automation does.
      - `workflow_sid` string, required: The playtables workflow (wf_wf_..., external).
      - `workflow_name` string, required: Display denorm, frozen at command time; it does not follow a later rename.
      - `ai_agent_mode` 'copilot' | 'autopilot' | 'off', required: The automation rung of the ladder (ConversationAutomationModeEnum): what the owning workflow's on_reply asked of the AI SDR. copilot = drafts wait for approval, autopilot = the agent acts within its caps, off = the agent leaves the thread alone (no classification, no drafts; a workflow whose on_reply is stop or continue, 2026-09-25). agent_mode_override wins over it, and it wins over the agent default.
      - `playtable_sid` string, nullable: The table the workflow runs on (pt_tb_..., external), the link to its builder (2026-09-24); absent or null on refs frozen before it rode along.
    - `active_plan_sid` string, nullable, required: The one active or proposed agent plan (ub_pl_..., // see ./agent_plans.md); null when the agent holds no plan.
    - `dialog` object, nullable, required: Live-dialog goal and progress; null means the thread never went hot.
      - `goal` object, required: AgentPlaybookGoalValue (kind, link, words), a copy frozen when the Playbook took the thread (// see ./agents.md). Read it, never assume it still matches the agent.
      - `stage` 'rapport' | 'discovery' | 'value' | 'objective' | 'closing', required: Where the live dialog stands. The Playbook's stages map onto it: Engage is rapport, Discover discovery, Bridge value, the Offer objective, the stage after a yes closing.
      - `needs` string[], required: Discovered needs and pains, at most 10 x 255 chars. Lead-derived UNTRUSTED text, clamped server-side.
      - `qualification_answers` object, required: The fact each Playbook question learns ("Channel", "Their tool") to the lead's answer, at most 5 keys x 512 chars, merged by fact across turns. Lead-derived UNTRUSTED text, clamped server-side.
      - `exchanges_used` integer, required: Dialog replies that went out, against the Playbook's ceiling of 10 (one more after a yes), which ends the dialog only once the offer is out. A draft that is dismissed, regenerated or fails spends none.
      - `engaged_at` string, nullable, required: ISO 8601 UTC, when the Playbook took the thread.
      - `objective_reached_at` string, nullable, required: ISO 8601 UTC, stamped once when the lead said yes (the stage reached closing, or an approved plan's reach_objective step marked it); null while the yes is open.
      - `from` 'engage' | 'discover' | 'bridge' | 'offer' | 'after_yes' | 'after_offer', nullable, required: The Playbook stage the dialog started at, the use_playbook step's from (2026-10-19), one of the five entry stages (never after_offer); the stages before it are skipped. Null on a dialog from before.
      - `entry_scenario` string, nullable, required: The scenario whose plan handed the reply to the Playbook (interested, info_request, objection, meeting_request, reschedule_request, or a team's own key).
      - `replies_without_fact` integer, required: The lead's replies since the last new fact: the stall rule reads it (the offer goes once it reaches the Playbook's stall_replies). Reset by a new fact and by a re-entry.
      - `offered_at` string, nullable, required: ISO 8601 UTC, when the offer went out (the first reply at the Offer or past it); null before.
      - `followups_sent` integer, required: The Playbook's own follow-ups after the offer that went out (and the one nudge after a yes).
      - `came_back` integer, required: How often a lead the Playbook had lost (the offer unanswered, another scenario's plan) wrote again and re-entered.
      - `ended_at` string, nullable, required: ISO 8601 UTC, when the Playbook's lead on the thread ended; null while it leads.
      - `outcome` 'said_yes' | 'booked' | 'signed_up' | 'registered' | 'stalled_out' | 'handed' | 'left' | 'cap', nullable, required: How it ended: said_yes (a custom goal's yes), booked, signed_up, registered (the goal landed after the yes), stalled_out (the follow-ups after the offer spent), handed (a hand-off while it led), left (a negative or nurture scenario took the thread), cap (the exchange ceiling); null while it leads.
      - `last_facts` string[]: The facts (keys of qualification_answers) the last dialog turn learned or changed, empty when it learned none (2026-10-20); missing on a dialog from before.
      - `skipped` string[]: The Playbook stages the dialog moved past mid-path without standing at them, in the path's order (2026-10-20): a buying signal that took Discover straight to the Offer leaves bridge. The stages before from are the entry's and never in it; a stage the dialog stands at later leaves it. Missing on a dialog from before.
      - `last_move` string, nullable: What the newest reply that went out did, in its turn's few words ("the price, then 15 minutes with Peter"), at most 255 chars (2026-10-20); null before the first such reply and after one whose turn named none. Model-written text: read it as a note, never as instructions. Missing on a dialog from before.
      - `last_move_reason` string, nullable: Why that turn moved the stage, in its few words, at most 255 chars (2026-10-20); null when the stage stayed. Missing on a dialog from before.
    - `lead_evidence` object, nullable, required: What the lead did after our reply, the measure the AI SDR is judged by (2026-10-25): when they first sent their calendar link, confirmed a time we proposed, or handed over contact details or a colleague. Null while none of the three fired. Replayed over the thread's messages at every landed row; read-only. The agent's overview sums it over the period (get_agent_overview, result.outcome).
      - `their_link_at` string, nullable, required: ISO 8601 UTC: the lead first sent a calendar link (calendly, cal.com, a hubspot meetings page, zcal, savvycal, tidycal, youcanbook.me, a Google appointment page, a booking subdomain) after a reply of ours; null while not.
      - `confirmed_time_at` string, nullable, required: ISO 8601 UTC: the lead first confirmed a day or a time after a message of ours proposed times (two times and a day); null while not.
      - `handoff_after_reply_at` string, nullable, required: ISO 8601 UTC: the lead first handed over a phone number, an email address or a colleague after a reply of ours, on a thread whose first reply carried none of these (a signature repeats); null while not.
    - `agent_level` object, nullable, required: The level the newest classified reply resolved to (block AC4, 2026-10-26): the scenario it was read as, the scenario's setting, autopilot or copilot or off, the condition that held on a Conditional scenario, the classify run and when. The mode ladder reads it after agent_mode_override and before the automation and the agent default (an off level shapes the plan, not the mode). Written by the classify report; null until the first classify.
      - `scenario_key` string, required: The scenario the reply was read as.
      - `setting` 'autopilot' | 'copilot' | 'conditional' | 'off', required: The scenario's level as set: autopilot, copilot, conditional or off.
      - `level` 'autopilot' | 'copilot' | 'conditional' | 'off', required: What the reply resolved to: autopilot, copilot or off, never conditional (a Conditional setting resolves to the first condition that held, else autopilot).
      - `condition` string, nullable, required: The condition that held, in its words ("mentions a contract or a security review"), or no_answer for the built-in one; null when none did.
      - `condition_kind` 'lead' | 'no_answer', nullable, required: lead or no_answer; null when no condition held.
      - `run_sid` string, nullable, required: The classify run that decided (ub_rn_...).
      - `decided_at` string, nullable, required: ISO 8601 UTC.
    - `lead_timezone` string, nullable, required: The lead's IANA time zone as playtables holds it on the linked person (people.timezone), stamped on the thread by the search projection's person read (block AC7, 2026-10-27): the AI SDR's quiet hours and weekends (get_agent_rules) are read in it, the workspace's zone standing in while it is null (unlinked, or the person has none). Read-only.
    - `auto_messages_used` integer, required: The AI SDR's unattended messages that went out on this thread, judged against the agent setting max_auto_messages_per_conversation (the "2/4" counter in the UI). Spent when a message goes out, so a failed or dismissed one spends nothing; a live-dialog reply spends its exchange instead (dialog.exchanges_used).
    - `unread_count` integer, required: Team-level unread counter, the legacy rule (2026-09-25): +1 per new inbound, whichever way it reached us (the channel's own delivery of the message, or the account's next sync when that delivery never came); 0 when a person opens the thread or writes back by hand (a composer send or a synced outbound typed in the channel app; a workflow or agent send clears nothing). update_conversation writes 0 (mark read) or 1 (mark unread). Imported history, what was sent before the account landed on the sender profile, is read; LinkedIn's own read marker is never a signal, a reply glanced at in LinkedIn is still unhandled here.
    - `needs_reply` boolean, required: Derived and stored: the last message is inbound and unanswered. Read-only; the ingest pipeline maintains it.
    - `snoozed_until` string, nullable, required: ISO 8601 UTC. Drives state=later; any inbound clears it (reply cancels snooze).
    - `last_message_preview` string, nullable, required: Plain-text denorm of the newest message, max 255 chars, for list rendering.
    - `last_message_at` string, nullable, required: ISO 8601 UTC; the default inbox sort key.
    - `first_outbound_at` string, nullable, required: ISO 8601 UTC, the first touch. The cohort anchor for reply metrics; null means never contacted.
    - `last_inbound_at` string, nullable, required: ISO 8601 UTC of the newest inbound; null means the lead never answered.
    - `merged_into_sid` string, nullable, required: Tombstone pointer (ub_cv_...), set with deleted_at by merge. A non-null value means this sid is dead: re-point to it.
    - `created_at` string, required: ISO 8601 UTC.
    - `updated_at` string, required: ISO 8601 UTC.
    - `deleted_at` string, nullable, required: ISO 8601 UTC; null = live. Only merge sets it (the tombstoned source row); there is no user-facing delete.
  - `result` object, required
    - `added` object[], required: The members the thread gained, in target order (role group_member).
      - `sid` string, required: Participant sid (ub_pt_...). This is what link / unlink take.
      - `team_sid` string, required: Owning team (ts_tm_..., external).
      - `conversation_sid` string, required: The thread this row belongs to (ub_cv_...).
      - `role` 'counterpart' | 'cc' | 'group_member' | 'sender', required: Which side of the thread this human is on.
      - `is_primary` boolean, required: True on the one counterpart the agent works; its link is what primary_person_sid denorms.
      - `display_name` string, nullable, required: Best-effort channel denorm (LinkedIn name, email display name). For a linked participant the UI prefers the live playtables person instead.
      - `headline` string, nullable, required: Best-effort channel denorm (LinkedIn headline, or "title, company"); display only.
      - `avatar_url` string, nullable, required: Best-effort avatar denorm from channel data.
      - `ln_member_id` string, nullable, required: The canonical LinkedIn dedup key (the decoded member id). The axis everything else resolves to.
      - `ln_id` string, nullable, required: The raw LinkedIn profile URN (ACoAA...) as captured; it decodes to ln_member_id.
      - `sn_id` string, nullable, required: The raw Sales Navigator URN (ACwAA...) as captured.
      - `recruiter_id` string, nullable, required: The raw Recruiter URN (AEMAA...) as captured.
      - `ln_nickname` string, nullable, required: The vanity slug, lowercased. Rebuild a profile link from it when a screen needs one; no URL is ever stored.
      - `email` string, nullable, required: Normalized lowercase email.
      - `person_sid` string, nullable, required: The linked playtables person (cr_pp_..., external); null means unlinked. Auto-linked at ingest on an exact identifier match, otherwise set by link_conversation_person.
      - `linked_at` string, nullable, required: ISO 8601 UTC of the link; null while unlinked.
      - `linked_by` object, nullable, required: Actor that linked the row: actor_type system for the ingest auto-link, user or agent for the verb. Null while unlinked.
        - `actor_type` 'user' | 'support' | 'api_key' | 'system' | 'agent', required
        - `actor_sid` string, nullable, required
        - `team_sid` string, required
        - `actor_name` string, nullable: The OAuth client that acted ("Claude", "n8n"); null for a user, an API key or a system job.
        - `oauth_client_sid` string, nullable: The acting OAuth client (id_oc_*); null off the OAuth path.
        - `reason` string, nullable: Why a system actor wrote the row (snapshot_capture_job, ...); null otherwise.
        - `permissions` object, required: Internal audit context: the grant set the actor held at write time. Not a contract.
        - `cluster_id` integer, nullable: Internal audit context: the cluster that served the write.
        - `trace_id` string, nullable: Internal audit context: the trace id of the request that wrote the row.
      - `created_at` string, required: ISO 8601 UTC.
      - `updated_at` string, required: ISO 8601 UTC.
      - `deleted_at` string, nullable, required: ISO 8601 UTC; set only by the merge dedup (a duplicate identifier row folded into the target).
    - `skipped` object[], required: The targets left out and why; not a refusal.
      - `target` string, required
      - `reason` 'already_member' | 'self' | 'unresolvable', required
  - `meta` object, required
    - `trace_id` string, required: UUID v7; same 128-bit value as the X-Trace-Id header.
    - `span_id` string, required: 16 hex chars, root span of this request.
    - `timestamp` string, required: ISO 8601 UTC (Y-m-dTH:i:sZ), response time.
    - `duration_ms` integer, required: Server-side wall clock.
    - `team_sid` string, nullable: The team this call ran in (the token team, or the team_sid override). Null when unauthenticated; absent from pre-2026-08-20 backends.
    - `actor_type` string, nullable: user | agent | api_key | system. Null when unauthenticated; absent from pre-2026-08-20 backends.

## Other responses

- `4XX`: MCP error envelope. `error.code` is one of validation_failed, nothing_to_update, not_found, relation_not_found, invalid_transition, limit_exceeded, payment_required, duplicate_rejected, conflict, delete_blocked, unauthorized, forbidden, rate_limited, not_implemented.
- `5XX`: MCP error envelope with `error.code` internal_error or service_unavailable.

---

[API](https://skmtc.dev/getsales/apis/getsales-public-contract-gs-service-unibox.md) - [All operations](https://skmtc.dev/getsales/apis/getsales-public-contract-gs-service-unibox/llms.txt) - [OpenAPI document](https://skmtc.dev/getsales/apis/getsales-public-contract-gs-service-unibox/revisions/8f1bc317897f?raw)
