List the replies nobody answered, longest waiting first, from a scheduled job
One function that reads recent replies, checks each conversation for a message sent afterwards, and returns the ones still waiting as text, plus what it could not check. It lists replies and sends nothing.
A prospect replies “yes, send me pricing” and nobody answers, because the reply sat in a shared inbox over a weekend. droppedReplies finds those. It reads the replies since a date, keeps the categories that need an answer, reads each conversation, and reports a reply as dropped when nothing was sent after it and it has waited longer than a number of hours you choose. The list comes back longest waiting first.
It only lists. It does not answer, assign or mark anything, in the SDK or in the vendor tool. It also does not know about answers given outside the tool: a reply you answered from your own mailbox or by phone still shows as unanswered.
Before you start
- Node 22 or later.
- The code below runs on the in-memory adapter, so it needs no vendor key. Each sample on this page is a file in the repository that the test suite runs; the output shown is what it printed.
- For real data: an Instantly, Smartlead or HeyReach API key. A read-only key is enough. No method of the SDK writes to a vendor.
- A scheduler you already run. The SDK schedules nothing.
What you get
2 unanswered for more than 24 hours, since 2026-10-31
99h priya@example.com
email, positive, 2 unanswered, "Re: pricing for 10 seats"
31h https://www.linkedin.com/in/tom-okafor-example
linkedin, positive, 1 unanswered
Read 7 replies in 4 conversations to check: 1 answered, 1 too recent, 0 could not be checked.
Left out: 1 by category, 1 automatic.
Write the list
import type { Outbound } from "outbound-sdk";
import type { InsightsExtension } from "outbound-sdk/insights";
/** Replies waiting longer than `hours` with nothing sent after them, as text. */
export const unansweredReplies = async (
outbound: Outbound & InsightsExtension,
hours = 24
): Promise<string> => {
const { data, meta } = await outbound.insights.droppedReplies({
older_than_hours: hours,
});
const lines = [
`${data.totals.dropped} unanswered for more than ${data.olderThanHours} hours, since ${data.since.slice(0, 10)}`,
"",
];
// Longest waiting first. That order comes from the insight.
for (const reply of data.dropped) {
// A reply names its lead by ref. Reading the lead costs one request, or
// two on a vendor whose lead read is two.
const who =
reply.lead === null
? null
: // oxlint-disable-next-line no-await-in-loop -- one lead at a time keeps the rate budget predictable.
await outbound.lead(reply.lead).get();
lines.push(
` ${Math.floor(reply.hoursWaiting)}h ${who?.email ?? who?.linkedinUrl ?? "unknown lead"}`,
` ${reply.channel}, ${reply.category}, ${reply.unansweredReplies} unanswered${
reply.subject === null ? "" : `, "${reply.subject}"`
}`
);
}
const { totals } = data;
lines.push(
"",
`Read ${totals.repliesRead} replies in ${totals.conversations} conversations to check: ` +
`${totals.answered} answered, ${totals.tooRecent} too recent, ${totals.unchecked} could not be checked.`,
`Left out: ${totals.skippedByCategory} by category, ${totals.skippedAuto} automatic.`
);
// A list cut short by max_replies is not the whole list. Say so.
if (!meta.complete || meta.notes.length > 0) {
lines.push("", ...meta.notes.map((note) => `Note: ${note}`));
}
return lines.join("\n");
};
How a reply ends up on the list:
- Replies received since
sinceare read, up tomax_replies(default 50). Withoutsince, that is 14 days before now. - Replies in a category that needs no answer are left out. By default
positive,neutralandunknownare kept; out-of-office, bounce, unsubscribe and negative replies are not. Replies the vendor marks as automatic are left out too, unlessinclude_autoistrue. - The rest are grouped by conversation. A conversation is read by its thread when the reply has a thread id and the adapter can read one, otherwise by the lead’s timeline.
- A conversation is dropped when it has replies after the last message sent. The oldest of those is reported, with how many there are in
unansweredReplies. - It is listed only once it has waited
older_than_hours(default 24). Younger ones are counted intotals.tooRecent.
Three things in the answer are worth copying:
totals.uncheckedis not zero by accident. A reply with no thread and no lead to read, or no time on it, cannot be checked. It is counted there and never reported as dropped. Print the count, so a short list is not read as a clean one.- A reply names its lead by ref. The function reads each lead to print an address. That is one more read per listed reply.
meta.completeisfalsewhenmax_repliesstopped the read. The list then covers the replies that were read, and a note says so.
The categories come from the vendor or from fixed rules. The SDK calls no AI model, so a reply the vendor filed as neutral is treated as one, whatever it says.
Try it without a key
Ten messages on one made-up tool: two people waiting, one answered, one too recent, and two that need no answer.
// One made-up tool with an email campaign and a LinkedIn campaign. The clock
// in the samples is 2026-11-14 12:00 UTC.
import type { MemoryData } from "outbound-sdk/memory";
export const seed: MemoryData = {
campaigns: [
{ id: "cmp_email", name: "Founders Q4" },
{ channel: "linkedin", id: "cmp_linkedin", name: "LinkedIn founders" },
],
leads: [
{ campaignId: "cmp_email", email: "priya@example.com", id: "lead_priya" },
{ campaignId: "cmp_email", email: "luis@example.com", id: "lead_luis" },
{ campaignId: "cmp_email", email: "mei@example.com", id: "lead_mei" },
{ campaignId: "cmp_email", email: "sam@example.com", id: "lead_sam" },
{
campaignId: "cmp_linkedin",
id: "lead_tom",
linkedinUrl: "https://www.linkedin.com/in/tom-okafor-example",
},
],
messages: [
// Priya replied on Tuesday and again on Thursday. Nothing went back.
{
campaignId: "cmp_email",
direction: "out",
id: "msg_01",
leadId: "lead_priya",
sentAt: "2026-11-09T08:00:00.000Z",
threadId: "thread_priya",
},
{
campaignId: "cmp_email",
category: "positive",
direction: "in",
id: "msg_02",
leadId: "lead_priya",
sentAt: "2026-11-10T09:00:00.000Z",
subject: "Re: pricing for 10 seats",
threadId: "thread_priya",
},
{
campaignId: "cmp_email",
category: "neutral",
direction: "in",
id: "msg_03",
leadId: "lead_priya",
sentAt: "2026-11-12T10:00:00.000Z",
subject: "Re: pricing for 10 seats",
threadId: "thread_priya",
},
// Luis was answered ninety minutes after he replied.
{
campaignId: "cmp_email",
category: "neutral",
direction: "in",
id: "msg_04",
leadId: "lead_luis",
sentAt: "2026-11-11T08:00:00.000Z",
subject: "Re: quick question",
threadId: "thread_luis",
},
{
campaignId: "cmp_email",
direction: "out",
id: "msg_05",
leadId: "lead_luis",
sentAt: "2026-11-11T09:30:00.000Z",
threadId: "thread_luis",
},
// Mei replied five hours ago: unanswered, but not yet a day old.
{
campaignId: "cmp_email",
category: "positive",
direction: "in",
id: "msg_06",
leadId: "lead_mei",
sentAt: "2026-11-14T07:00:00.000Z",
subject: "Re: intro",
threadId: "thread_mei",
},
// Sam is out of office, twice: once filed as such, once filed as neutral
// but marked automatic. Neither needs an answer.
{
campaignId: "cmp_email",
category: "ooo",
direction: "in",
id: "msg_07",
leadId: "lead_sam",
sentAt: "2026-11-10T06:00:00.000Z",
subject: "Out of office",
threadId: "thread_sam_1",
},
{
campaignId: "cmp_email",
category: "neutral",
direction: "in",
id: "msg_08",
isAuto: true,
leadId: "lead_sam",
sentAt: "2026-11-11T06:00:00.000Z",
subject: "Automatic reply",
threadId: "thread_sam_2",
},
// Tom replied on LinkedIn. The message has no thread id, so the
// conversation is read from his timeline.
{
campaignId: "cmp_linkedin",
direction: "out",
id: "msg_09",
leadId: "lead_tom",
sentAt: "2026-11-12T09:00:00.000Z",
},
{
campaignId: "cmp_linkedin",
category: "positive",
direction: "in",
id: "msg_10",
leadId: "lead_tom",
sentAt: "2026-11-13T05:00:00.000Z",
},
],
};
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { seed } from "./seed.ts";
import { unansweredReplies } from "./unanswered.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "acme" }),
plugins: [insights()],
});
console.log(await unansweredReplies(outbound));
That is the script that printed the list above.
Run it on a schedule
Swap the adapter and export a function for your scheduler to call: cron, a GitHub Actions schedule, a Vercel cron route. What you do with the text (post it to a channel, email it to the account owner) is your code.
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { instantly } from "outbound-sdk/instantly";
import { unansweredReplies } from "./unanswered.ts";
// Reads INSTANTLY_API_KEY. Swap the import for smartlead() or heyreach().
const outbound = createOutbound({
adapter: instantly({ workspaceId: "acme" }),
// One run may spend at most 150 vendor requests.
maxRequests: 150,
plugins: [insights()],
});
/**
* What a scheduler calls. It returns the text; posting it to a channel or an
* inbox is yours to do. The SDK answers no reply.
*/
export const job = (): Promise<string> => unansweredReplies(outbound);
The SDK stores nothing, so each run starts from the vendor’s data and lists every reply still waiting, including ones the last run listed. If you want each reply reported once, keep the reply refs you already reported in your own store.
What each vendor can serve
The insight, run on the in-memory adapter limited to what each vendor’s adapter declares. This shows which calls a vendor’s adapter would refuse. It does not show what that vendor’s API returns.
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { asVendor, VENDORS } from "../_shared/as-vendor.ts";
import { seed } from "./seed.ts";
for (const vendor of VENDORS) {
const outbound = createOutbound({
adapter: asVendor(vendor, seed, "acme"),
plugins: [insights()],
});
try {
// oxlint-disable-next-line no-await-in-loop -- one vendor at a time.
const { data, meta } = await outbound.insights.droppedReplies({});
console.log(
`${vendor}: ok, ${data.totals.dropped} unanswered, checked by ${[...new Set(data.dropped.map((reply) => reply.checkedBy))].join(" and ")}`
);
for (const note of meta.notes) {
console.log(` note: ${note}`);
}
} catch (error) {
if (!OutboundError.is(error)) {
throw error;
}
console.log(`${vendor}: ${error.code}: ${error.message}`);
}
}
instantly: ok, 2 unanswered, checked by thread and timeline
smartlead: ok, 2 unanswered, checked by thread and timeline
heyreach: ok, 2 unanswered, checked by thread and timeline
droppedReplies needs only replies.list, which all three declare. All three can also read a thread and a lead’s timeline, and all three take a date filter on replies, so none adds a note here.
With several tools behind combine, a thread id cannot be routed to one tool, so every conversation is checked by the lead’s timeline.
What it costs
One request per page of replies, plus one per conversation checked: at most about max_replies requests, so about 50 by default. The function above adds one lead read per listed reply (two requests each on Smartlead and HeyReach). maxRequests on the instance caps the whole run; past it the call fails with RateLimited instead of spending your vendor quota.
When it cannot answer
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { seed } from "./seed.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "acme" }),
plugins: [insights()],
});
// 1. max_replies stopped the read: the list covers part of the replies.
const capped = await outbound.insights.droppedReplies({ max_replies: 3 });
console.log(
"capped:",
capped.data.totals.repliesRead,
capped.meta.complete,
capped.meta.notes
);
// 2. A tool that can read neither a thread nor a lead's timeline: every
// reply is counted as unchecked. None is reported as unanswered.
const blind = createOutbound({
adapter: memory({
capabilities: { "leads.timeline": false, "messages.byThread": false },
data: seed,
}),
plugins: [insights()],
});
const unchecked = await blind.insights.droppedReplies({});
console.log(
"cannot check:",
unchecked.data.totals.dropped,
unchecked.data.totals.unchecked,
unchecked.meta.notes
);
// 3. A tool that cannot filter replies by date: read, then filtered here.
const noSince = createOutbound({
adapter: memory({ capabilities: { "replies.since": false }, data: seed }),
plugins: [insights()],
});
const filteredHere = await noSince.insights.droppedReplies({});
console.log("no date filter:", filteredHere.meta.notes);
// 4. A date that is not ISO 8601 is refused before any request.
try {
await outbound.insights.droppedReplies({ since: "last monday" });
} catch (error) {
if (OutboundError.is(error)) {
console.log("bad date:", error.code);
}
}
// 5. An adapter that cannot list replies refuses the whole insight.
const noReplies = createOutbound({
adapter: memory({ capabilities: { "replies.list": false }, data: seed }),
plugins: [insights()],
});
try {
await noReplies.insights.droppedReplies({});
} catch (error) {
if (OutboundError.is(error)) {
console.log("no replies:", error.code, "-", error.message);
}
}
capped: 3 false [ 'Stopped at 3 replies; more exist.' ]
cannot check: 0 5 [
"This tool can read neither a conversation nor a lead's timeline, so no reply could be checked."
]
no date filter: [
'This tool cannot filter replies by date, so the newest replies were read and filtered here.'
]
bad date: Invalid
no replies: Unsupported - dropped_replies is not available: the memory adapter does not support replies.list
| What happened | What you get | What to do |
|---|---|---|
More replies than max_replies |
meta.complete is false and a note says where it stopped |
Raise max_replies, or move since later. |
| The adapter can read neither a thread nor a lead’s timeline | Nothing is listed, every reply is in totals.unchecked, and a note says so |
Do not read the empty list as “all answered”. Use an adapter that declares leads.timeline or messages.byThread. |
| The adapter cannot filter replies by date | The newest replies are read and filtered in the SDK, with a note | Nothing. Expect the read to reach max_replies sooner. |
since is not an ISO 8601 date or time |
Invalid, before any request |
Pass 2026-11-01 or a full timestamp with an offset. |
| The adapter cannot list replies | Unsupported, before any request |
Use an adapter that declares replies.list. |
| A rejected key | Unauthorized. It is not retried. |
Check the key and its scope. |
Limits
- “Answered” means a message was sent in the same conversation after the reply, in the vendor tool. An answer from another mailbox, a call or a meeting booking is not seen.
- An automated follow-up step sent after the reply counts as a message sent. If your tool keeps sending a sequence after a reply, that conversation reads as answered.
- A reply the vendor files as
negativeor out-of-office is left out by default. Passcategoriesto change which ones count. - One entry per conversation: the oldest unanswered reply, with the count of unanswered replies after the last send.
- On an
unverifiedadapter, check a list against the vendor’s own inbox before acting on it.