Skip to main content
Glama
Sealjay

mcp-hey

hey_reply

Reply to an email thread in your inbox. Automatically excludes your own address; use the to parameter if you sent the last message to avoid failed delivery.

Instructions

Reply to an email thread. By default the reply goes to the other thread participants (your own address is automatically excluded). If you started the thread or sent the most recent message, pass to explicitly to avoid the reply failing with no valid recipients. Use hey_send_email for new standalone messages, or hey_forward to share content with third parties.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
thread_idYesThe thread/topic ID to reply to
bodyYesReply body content (HTML supported)
toNoOptional override of the To: line. Use this when chasing a thread where you sent the most recent message, so the chase lands on the original recipient instead of looping back to your own address.
ccNoOptional CC override. Only honoured when `to` is also provided.

Implementation Reference

  • Tool registration and input schema for 'hey_reply' — defines the tool name, description, and input schema with thread_id (required), body (required), to (optional), and cc (optional).
    {
      name: "hey_reply",
      description:
        "Reply to an email thread. By default the reply goes to the other thread participants (your own address is automatically excluded). If you started the thread or sent the most recent message, pass `to` explicitly to avoid the reply failing with no valid recipients. Use hey_send_email for new standalone messages, or hey_forward to share content with third parties.",
      inputSchema: {
        type: "object" as const,
        properties: {
          thread_id: {
            type: "string",
            description: "The thread/topic ID to reply to",
          },
          body: {
            type: "string",
            description: "Reply body content (HTML supported)",
          },
          to: {
            type: "array",
            items: { type: "string" },
            description:
              "Optional override of the To: line. Use this when chasing a thread where you sent the most recent message, so the chase lands on the original recipient instead of looping back to your own address.",
          },
          cc: {
            type: "array",
            items: { type: "string" },
            description:
              "Optional CC override. Only honoured when `to` is also provided.",
          },
        },
        required: ["thread_id", "body"],
      },
  • src/index.ts:1386-1445 (registration)
    Registration of the hey_reply tool in the CallToolRequestSchema handler; validates thread_id, body, to, and cc arguments, then calls replyToEmail().
    case "hey_reply": {
      const threadId = validateId(args?.thread_id)
      const body = args?.body as string
      const to = args?.to as unknown
      const cc = args?.cc as unknown
    
      if (!threadId) {
        return {
          content: [
            {
              type: "text",
              text: "Error: thread_id is required and must be valid",
            },
          ],
          isError: true,
        }
      }
      if (!body || typeof body !== "string" || body.trim().length === 0) {
        return {
          content: [{ type: "text", text: "Error: body is required" }],
          isError: true,
        }
      }
    
      if (to !== undefined) {
        if (!Array.isArray(to) || !to.every((e) => typeof e === "string")) {
          return {
            content: [
              {
                type: "text",
                text: "Error: to must be an array of email address strings",
              },
            ],
            isError: true,
          }
        }
      }
    
      if (cc !== undefined) {
        if (!Array.isArray(cc) || !cc.every((e) => typeof e === "string")) {
          return {
            content: [
              {
                type: "text",
                text: "Error: cc must be an array of email address strings",
              },
            ],
            isError: true,
          }
        }
      }
    
      result = await replyToEmail({
        threadId,
        body: body.trim(),
        to: to as string[] | undefined,
        cc: cc as string[] | undefined,
      })
      break
    }
  • Core handler for 'hey_reply': fetches reply context from the thread, creates a reply draft, resolves recipients (auto-detecting from thread participants or using explicit to/cc override), then sends the draft via PATCH.
    export async function replyToEmail(params: ReplyParams): Promise<SendResult> {
      const { threadId, body, to: toOverride, cc: ccOverride } = params
    
      if (!body.trim()) {
        return { success: false, error: "Reply body is required" }
      }
    
      // Cap total recipients to prevent abuse
      const totalRecipients = (toOverride?.length ?? 0) + (ccOverride?.length ?? 0)
      if (totalRecipients > MAX_RECIPIENTS) {
        return {
          success: false,
          error: `Too many recipients (${totalRecipients}). Maximum is ${MAX_RECIPIENTS} across to/cc combined.`,
        }
      }
    
      if (toOverride !== undefined) {
        if (toOverride.length === 0) {
          return {
            success: false,
            error: "`to` must contain at least one recipient when provided",
          }
        }
        const invalidTo = findInvalidEmails(toOverride)
        if (invalidTo.length > 0) {
          return {
            success: false,
            error: `Invalid recipient email(s): ${invalidTo.join(", ")}`,
          }
        }
      }
    
      if (ccOverride && ccOverride.length > 0) {
        const invalidCc = findInvalidEmails(ccOverride)
        if (invalidCc.length > 0) {
          return {
            success: false,
            error: `Invalid CC email(s): ${invalidCc.join(", ")}`,
          }
        }
      }
    
      try {
        // Fetch account info first - we need the user's email to identify
        // which entries in the thread are theirs vs the other participants'.
        const accountInfo = await getAccountInfo()
        const replyContext = await getReplyContext(
          threadId,
          accountInfo.senderEmail,
        )
    
        // Step 1: Create reply draft via POST to /entries/{entryId}/replies
        const draftFormData = new URLSearchParams()
        draftFormData.append("acting_sender_id", accountInfo.senderId)
        draftFormData.append("message[content]", body)
        draftFormData.append("message[auto_quoting]", "false")
    
        debugLog("Step 1: Creating reply draft", {
          entryId: replyContext.entryId,
          threadId,
        })
        const createResponse = await heyClient.post(
          `/entries/${replyContext.entryId}/replies`,
          draftFormData,
        )
    
        debugLog("Draft creation response", {
          status: createResponse.status,
          location: createResponse.headers.get("location"),
        })
    
        // Extract draft ID from redirect Location header
        // Hey redirects to /topics/{id}?expanded_draft={draftId}
        let draftId: string | undefined
        const location = createResponse.headers.get("location") ?? ""
    
        const draftMatch = location.match(/expanded_draft=(\d+)/)
        if (draftMatch) {
          draftId = draftMatch[1]
        }
    
        if (!draftId) {
          const msgMatch = location.match(/\/messages\/(\d+)/)
          if (msgMatch) {
            draftId = msgMatch[1]
          }
        }
    
        // Last resort: try the response body
        if (!draftId) {
          const responseBody = await createResponse.text()
          const bodyMatch =
            responseBody.match(/expanded_draft=(\d+)/) ||
            responseBody.match(/\/messages\/(\d+)/)
          if (bodyMatch) {
            draftId = bodyMatch[1]
          }
        }
    
        if (!draftId) {
          return {
            success: false,
            error:
              "Reply draft created but could not extract draft ID to send it. Check Hey drafts.",
          }
        }
    
        debugLog("Extracted draft ID", { draftId })
    
        // Step 2: Send the draft via PATCH /messages/{draftId}
        // The draft entry ID IS the message ID. Hey's send form uses:
        //   POST /messages/{id} with _method=patch (Rails method override)
        //   data-remote="true" data-turbo-frame="_top"
        // Recipients and subject are NOT pre-populated in the draft.
        //
        // Recipient policy:
        //   1. If `to` override was passed in, honour it verbatim (mirrors Hey's
        //      web UI, which lets you change the To: line when chasing a thread
        //      you started).
        //   2. Otherwise auto-detect: prefer the author of the most recent
        //      non-self entry in the thread, then fall back to any other
        //      participants we found.
        //   3. If we still have nothing, surface the failure with an actionable
        //      error rather than silently posting a topic entry that never
        //      leaves Hey by addressing it back to the caller.
        const recipientEmails = resolveReplyRecipients({
          toOverride,
          replyContext,
          selfEmail: accountInfo.senderEmail,
        })
    
        if (recipientEmails.length === 0) {
          return {
            success: false,
            error:
              "Could not determine reply recipient from thread participants. Pass `to` with the recipient email address(es) you want to chase.",
          }
        }
    
        const sendFormData = new URLSearchParams()
        sendFormData.append("_method", "patch")
        sendFormData.append("acting_sender_id", accountInfo.senderId)
        sendFormData.append("remember_last_sender", "true")
    
        for (const email of recipientEmails) {
          sendFormData.append("entry[addressed][directly][]", email)
        }
    
        if (ccOverride && ccOverride.length > 0) {
          for (const ccRecipient of ccOverride) {
            sendFormData.append("entry[addressed][copied][]", ccRecipient.trim())
          }
        }
    
        const replySubject = replyContext.subject.startsWith("Re:")
          ? replyContext.subject
          : `Re: ${replyContext.subject}`
        sendFormData.append("message[subject]", replySubject)
        sendFormData.append("message[content]", body)
        sendFormData.append("entry[scheduled_delivery]", "false")
        sendFormData.append("entry[scheduled_bubble_up]", "false")
        sendFormData.append("commit", "Send email")
    
        debugLog("Step 2: Sending draft via PATCH", {
          draftId,
          recipients: recipientEmails,
          subject: replySubject,
        })
    
        const sendResponse = await withCsrfRetry(() =>
          heyClient.postTurbo(`/messages/${draftId}`, sendFormData),
        )
    
        debugLog("Send response", {
          status: sendResponse.status,
          location: sendResponse.headers.get("location"),
        })
    
        if (sendResponse.status >= 200 && sendResponse.status < 300) {
          safeInvalidateCache("reply")
          return { success: true, messageId: draftId }
        }
    
        if (sendResponse.status === 302) {
          const classification = classifyRedirect(sendResponse)
    
          if (classification.type === "auth_failure") {
            return { success: false, error: "Session expired, please retry" }
          }
    
          safeInvalidateCache("reply")
          return { success: true, messageId: draftId }
        }
    
        return {
          success: false,
          error: `Reply draft ${draftId} created but send failed with status ${sendResponse.status}. Check Hey drafts.`,
        }
      } catch (err) {
        return {
          success: false,
          error: err instanceof Error ? err.message : "Unknown error",
        }
      }
    }
  • getReplyContext helper: fetches thread HTML and extracts entryId (from reply form action), subject, participant emails, and the latest non-self sender email.
    async function getReplyContext(
      threadId: string,
      selfEmail: string,
    ): Promise<ReplyContext> {
      const html = await heyClient.fetchHtml(`/topics/${threadId}`)
      const root = parseHtml(html)
    
      // Extract entry ID from reply form action: /entries/{id}/replies
      let entryId: string | undefined
    
      const replyForm = root.querySelector('form[action*="/replies"]')
      if (replyForm) {
        const action = replyForm.getAttribute("action") ?? ""
        const match = action.match(/\/entries\/(\d+)\/replies/)
        if (match) {
          debugLog("Found reply entry ID from form action", match[1])
          entryId = match[1]
        }
      }
    
      if (!entryId) {
        const replyLink = root.querySelector('a[href*="/replies/new"]')
        if (replyLink) {
          const href = replyLink.getAttribute("href") ?? ""
          const match = href.match(/\/entries\/(\d+)\/replies\/new/)
          if (match) {
            debugLog("Found reply entry ID from link", match[1])
            entryId = match[1]
          }
        }
      }
    
      if (!entryId) {
        throw new Error(
          "Could not find reply form on thread page. The thread may not support replies.",
        )
      }
    
      // Extract subject from page title (format: "Subject - Hey")
      let subject = ""
      const titleEl = root.querySelector("title")
      if (titleEl) {
        subject = titleEl.text.replace(/\s*[-–—]\s*Hey\s*$/, "").trim()
      }
    
      // Walk every entry in the thread, collecting per-entry sender info.
      const threadEntries = extractThreadEntries(root)
      const latestNonSelfSenderEmail = findLatestNonSelfSender(
        threadEntries,
        selfEmail,
      )
    
      // Participant emails: union of every distinct sender we saw, plus a
      // page-wide avatar sweep so we still expose CC/bcc faces even when an
      // entry-level avatar is missing.
      const participantEmails: string[] = []
      for (const entry of threadEntries) {
        if (entry.senderEmail && !participantEmails.includes(entry.senderEmail)) {
          participantEmails.push(entry.senderEmail)
        }
      }
      for (const avatar of root.querySelectorAll(
        ".avatar, img.avatar, [class*='avatar']",
      )) {
        const alt = avatar.getAttribute("alt") ?? ""
        const match = alt.match(/<([^>\s]+@[^>\s]+)>/)
        if (match) {
          const email = match[1].toLowerCase()
          if (!participantEmails.includes(email)) {
            participantEmails.push(email)
          }
        }
      }
    
      debugLog("Reply context", {
        entryId,
        subject,
        participantEmails,
        latestNonSelfSenderEmail,
        threadEntryCount: threadEntries.length,
      })
      return { entryId, subject, participantEmails, latestNonSelfSenderEmail }
    }
  • resolveReplyRecipients helper: resolves who the reply should address — uses explicit `to` override if provided, otherwise auto-detects from thread participants (excluding the caller's own email).
    export function resolveReplyRecipients(opts: {
      toOverride: string[] | undefined
      replyContext: ReplyContext
      selfEmail: string
    }): string[] {
      const { toOverride, replyContext, selfEmail } = opts
    
      if (toOverride && toOverride.length > 0) {
        return toOverride.map((email) => email.trim())
      }
    
      if (replyContext.latestNonSelfSenderEmail) {
        return [replyContext.latestNonSelfSenderEmail]
      }
    
      const selfLower = selfEmail.toLowerCase()
      return replyContext.participantEmails.filter(
        (email) => email.toLowerCase() !== selfLower,
      )
    }
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations provided, so description carries burden. It discloses default behavior (reply goes to others, own address excluded) and failure condition (need explicit 'to' if last sender), but doesn't mention other aspects like attachment handling.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Three sentences, each adding value. Front-loaded with action, no waste.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Covers purpose, usage, key behavioral constraints. No output schema, so return value not needed. Missing minor details like attachments or rate limits, but sufficient for a reply tool.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100% (baseline 3). Description adds meaning: explains why 'to' is optional, condition for using it, and that 'cc' requires 'to'. Goes beyond schema descriptions.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states 'Reply to an email thread' (specific verb+resource) and distinguishes from siblings like hey_send_email and hey_forward.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicitly explains when to use the 'to' parameter (if you started the thread or sent the most recent message) and when to use alternative tools.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Sealjay/mcp-hey'

If you have feedback or need assistance with the MCP directory API, please join our Discord server