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
| Name | Required | Description | Default |
|---|---|---|---|
| thread_id | Yes | The thread/topic ID to reply to | |
| body | Yes | Reply body content (HTML supported) | |
| to | No | 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 | No | Optional CC override. Only honoured when `to` is also provided. |
Implementation Reference
- src/index.ts:620-649 (schema)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 } - src/tools/send.ts:737-941 (handler)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", } } } - src/tools/send.ts:653-735 (helper)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 } } - src/tools/send.ts:629-648 (helper)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, ) }