Skip to content

feat: add thread_id support to send_message for replying to InMails#451

Open
czerwiakowskim wants to merge 1 commit into
stickerdaniel:mainfrom
czerwiakowskim:main
Open

feat: add thread_id support to send_message for replying to InMails#451
czerwiakowskim wants to merge 1 commit into
stickerdaniel:mainfrom
czerwiakowskim:main

Conversation

@czerwiakowskim
Copy link
Copy Markdown

Problem

The send_message tool fails for non-connected users because LinkedIn does not show a 'Message' button on their profile pages (see issues #433, #441). This makes it impossible to reply to InMails from recruiters, people you're not connected with, or any conversation where you only have a thread URL.

Solution

Added a thread_id parameter to send_message. When provided, it navigates directly to the existing conversation thread and sends the message there, bypassing the Message-button lookup entirely.

How it works:

  1. Obtain a thread_id via get_inbox or search_conversations
  2. Call send_message with thread_id — the tool navigates to the thread, finds the compose box, types and sends the message

Priority order:

  1. thread_id — direct thread navigation (best for replies)
  2. profile_urn — direct compose URL from profile URN
  3. linkedin_username — profile page + Message button lookup (existing behavior)

Changes

  • messaging.py: Added thread_id parameter to the send_message tool with updated docstring
  • extractor.py: Added thread-based sending path (~105 lines) that reuses existing compose box resolution, keyboard typing, and send button logic

Usage

# Reply to an existing conversation
send_message(
    linkedin_username: 'john-doe',
    message: 'Thanks for reaching out!',
    confirm_send: true,
    thread_id: '2-MTVlOWZiYzUt...'  # from get_inbox or search_conversations
)

Testing

Verified locally — successfully sent a reply to an InMail conversation from a non-connected recruiter.

The send_message tool previously failed for non-connected users because
LinkedIn does not show a Message button on their profile pages.

This commit adds a thread_id parameter to send_message that navigates
directly to an existing conversation thread and sends the message there,
bypassing the Message-button lookup entirely.

Changes:
- messaging.py: Added thread_id parameter and updated docstring
- extractor.py: Added thread-based sending path that reuses existing
  compose box and send logic

Use case: Reply to InMails from recruiters or anyone who messaged you
first, where you may not be connected.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 17, 2026

Greptile Summary

This PR adds a direct thread reply path to the LinkedIn messaging send tool. The main changes are:

  • Adds an optional thread_id parameter to the send_message tool.
  • Navigates directly to an existing LinkedIn messaging thread when thread_id is provided.
  • Reuses the existing composer typing and send-button logic for thread replies.

Confidence Score: 3/5

These issues should be fixed before merging.

  • The new thread workflow is still blocked for callers who only have a thread id.

  • Returned conversation references can be fed back into send_message and produce malformed thread URLs.

  • The send path can operate on the wrong conversation when thread navigation redirects or fails.

  • The success check can report a send when the same text was already visible.

  • linkedin_mcp_server/tools/messaging.py

  • linkedin_mcp_server/scraping/extractor.py

Important Files Changed

Filename Overview
linkedin_mcp_server/tools/messaging.py Adds the public thread_id parameter but still requires a username for calls.
linkedin_mcp_server/scraping/extractor.py Adds the thread-based send path, including navigation, composer lookup, typing, and send confirmation.

Comments Outside Diff (1)

  1. linkedin_mcp_server/tools/messaging.py, line 214-220 (link)

    P1 Thread-only calls fail The new thread path does not use linkedin_username, but the tool still requires it as a positional str. A caller who only has the thread_id from get_inbox or search_conversations, which is the workflow this PR adds, cannot call send_message without inventing a username, so the advertised reply-by-thread path is still blocked at the tool boundary.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: linkedin_mcp_server/tools/messaging.py
    Line: 214-220
    
    Comment:
    **Thread-only calls fail** The new thread path does not use `linkedin_username`, but the tool still requires it as a positional `str`. A caller who only has the `thread_id` from `get_inbox` or `search_conversations`, which is the workflow this PR adds, cannot call `send_message` without inventing a username, so the advertised reply-by-thread path is still blocked at the tool boundary.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
linkedin_mcp_server/tools/messaging.py:214-220
**Thread-only calls fail** The new thread path does not use `linkedin_username`, but the tool still requires it as a positional `str`. A caller who only has the `thread_id` from `get_inbox` or `search_conversations`, which is the workflow this PR adds, cannot call `send_message` without inventing a username, so the advertised reply-by-thread path is still blocked at the tool boundary.

### Issue 2 of 5
linkedin_mcp_server/scraping/extractor.py:3170-3176
**Normalize thread references** The tools that users are told to use for discovery expose conversation references as `/messaging/thread/{id}/`, but this branch interpolates `thread_id` directly into another thread URL. Passing that returned reference produces a malformed URL like `/messaging/thread//messaging/thread/...//`, so normal tool chaining from inbox/search can fail before reaching the conversation.

### Issue 3 of 5
linkedin_mcp_server/scraping/extractor.py:3179-3204
**Verify opened thread** This path treats any visible composer after navigation as the requested recipient and sets `recipient_selected = True` without checking the final URL or extracted thread id. If LinkedIn redirects an invalid or inaccessible thread to the inbox, or the previous thread remains selected after a load problem, `confirm_send=True` can send the message to whatever conversation is currently open while reporting success for the requested thread.

### Issue 4 of 5
linkedin_mcp_server/scraping/extractor.py:3216-3225
**Wrong textbox target** The code resolves `compose_box`, then ignores it and focuses the first matching contenteditable in the whole document. Messaging pages can contain more than one editable surface or draft, so this can type into a different box than the active thread composer and then click a send button elsewhere or fail to send the intended reply.

### Issue 5 of 5
linkedin_mcp_server/scraping/extractor.py:3252-3258
**Confirmation can preexist** `_message_text_visible` checks whether the whole page body contains the message text. In an existing conversation, the same text may already be visible from a previous message or quoted content before this send attempt, so a disabled send button or failed submit can still return `status: "sent"` and `sent: true`.

Reviews (1): Last reviewed commit: "feat: add thread_id support to send_mess..." | Re-trigger Greptile

Comment on lines +3170 to +3176
if thread_id:
thread_url = f"https://www.linkedin.com/messaging/thread/{thread_id}/"
logger.info(
"send_message: thread_id=%s — navigating directly to thread",
thread_id,
)
await self._navigate_to_page(thread_url)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Normalize thread references The tools that users are told to use for discovery expose conversation references as /messaging/thread/{id}/, but this branch interpolates thread_id directly into another thread URL. Passing that returned reference produces a malformed URL like /messaging/thread//messaging/thread/...//, so normal tool chaining from inbox/search can fail before reaching the conversation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 3170-3176

Comment:
**Normalize thread references** The tools that users are told to use for discovery expose conversation references as `/messaging/thread/{id}/`, but this branch interpolates `thread_id` directly into another thread URL. Passing that returned reference produces a malformed URL like `/messaging/thread//messaging/thread/...//`, so normal tool chaining from inbox/search can fail before reaching the conversation.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +3179 to +3204
try:
await self._page.wait_for_selector("main")
except PlaywrightTimeoutError:
logger.debug("Thread page did not load for thread %s", thread_id)

await handle_modal_close(self._page)

# The compose box at the bottom of an open conversation is the same
# element as the standalone composer — reuse existing helpers.
message_surface = await self._wait_for_message_surface()
logger.debug(
"Message surface in thread %s: %s",
thread_id,
message_surface,
)

compose_box = await self._resolve_message_compose_box()
if compose_box is None:
return self._message_action_result(
self._page.url,
"composer_unavailable",
"LinkedIn did not expose a usable message composer in this thread.",
)

# Recipient is already set by the conversation — skip resolution.
recipient_selected = True
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Verify opened thread This path treats any visible composer after navigation as the requested recipient and sets recipient_selected = True without checking the final URL or extracted thread id. If LinkedIn redirects an invalid or inaccessible thread to the inbox, or the previous thread remains selected after a load problem, confirm_send=True can send the message to whatever conversation is currently open while reporting success for the requested thread.

Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 3179-3204

Comment:
**Verify opened thread** This path treats any visible composer after navigation as the requested recipient and sets `recipient_selected = True` without checking the final URL or extracted thread id. If LinkedIn redirects an invalid or inaccessible thread to the inbox, or the previous thread remains selected after a load problem, `confirm_send=True` can send the message to whatever conversation is currently open while reporting success for the requested thread.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +3216 to +3225
focused = await self._page.evaluate(
"""() => {
const el = document.querySelector(
'div[role="textbox"][contenteditable="true"][aria-label*="Write a message"],'
+ 'div[role="textbox"][contenteditable="true"]'
);
if (!el) return false;
el.focus();
return true;
}"""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Wrong textbox target The code resolves compose_box, then ignores it and focuses the first matching contenteditable in the whole document. Messaging pages can contain more than one editable surface or draft, so this can type into a different box than the active thread composer and then click a send button elsewhere or fail to send the intended reply.

Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 3216-3225

Comment:
**Wrong textbox target** The code resolves `compose_box`, then ignores it and focuses the first matching contenteditable in the whole document. Messaging pages can contain more than one editable surface or draft, so this can type into a different box than the active thread composer and then click a send button elsewhere or fail to send the intended reply.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +3252 to +3258
if not await self._message_text_visible(message):
return self._message_action_result(
self._page.url,
"send_unavailable",
"LinkedIn did not confirm that the message was sent.",
recipient_selected=recipient_selected,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Confirmation can preexist _message_text_visible checks whether the whole page body contains the message text. In an existing conversation, the same text may already be visible from a previous message or quoted content before this send attempt, so a disabled send button or failed submit can still return status: "sent" and sent: true.

Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 3252-3258

Comment:
**Confirmation can preexist** `_message_text_visible` checks whether the whole page body contains the message text. In an existing conversation, the same text may already be visible from a previous message or quoted content before this send attempt, so a disabled send button or failed submit can still return `status: "sent"` and `sent: true`.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants