Last updated: September 2026
How to route DMs with AI
Classify first, route second. One Choice question sorts an inbound DM into sales, support, partnership, spam or other and returns a probability for every bucket. Two Noul questions say whether the sender is blocked and whether a human has to answer. Your code reads those numbers and decides where the message goes.
The routing logic stays in code, not in a prompt, so it is reviewable and testable. And nothing here ever sends a message on its own. Classification sorts and drafts. A person approves.
7-day free trial. DM Campaigns is on Advanced and Agency.
1. Decide the buckets before you write any code
Buckets are not categories, they are destinations. The right test for a bucket is whether something different happens to a message in it. If sales and partnership end up in the same queue with the same response time, they are one bucket and you have added a decision for nothing.
Five is a good ceiling for a first pass. A Choice can hold up to 255 options, but every option you add is another way for the model to be almost right, and almost right is what the confidence gate has to catch.
Keep an other bucket. Without one the model is forced to squeeze every odd message into a bucket that does not fit, and you lose the signal that it did not know.
2. The classifier
One request, four questions. A Choice for the bucket, a Noul for urgency, a Noul for whether a human is required, and a Score for how much effort the sender put in, which is the cheapest partnership filter there is. All four ride on one copy of the message and one round trip, which is what TypeSafe calls the fan-out pattern: its own cookbook reports an order-of-magnitude saving in both cost and latency from batching 13 questions into one call.
import { TypeSafeClient, choice, noul, score } from '@typesafe-ai/sdk';
const client = new TypeSafeClient({ timeout: 4000 });
const QUESTIONS = {
bucket: choice('What does the sender actually want from us?', {
sales: 'Wants to buy, is pricing us, is comparing us, or is asking what the product does.',
support: 'Is already a customer and something is broken, confusing, or billed wrong.',
partnership: 'Proposes a collaboration, an integration, a podcast, a sponsorship, or a referral deal.',
spam: 'Unsolicited pitch, crypto, growth service, mass outreach, or a link with no context.',
other: 'None of the above. Personal, a compliment, or unclear.',
}),
urgent: noul('The sender is blocked right now, or names a deadline in the next 48 hours.'),
needs_human: noul('Answering this well requires an account detail, a price exception, or a judgement a template cannot make.'),
effort: score('How much effort did the sender put into this message?', [
'Copy-paste. Identical to a template, no reference to us.',
'Some effort. Generic but addressed to us.',
'Real effort. References something specific we did or shipped.',
]),
} as const;
export interface Triage {
bucket: 'sales' | 'support' | 'partnership' | 'spam' | 'other';
confidence: number;
probabilities: Record<string, number>;
urgent: number;
needsHuman: number;
effort: number;
}
export async function triage(message: string, sender: { handle: string; bio: string; followers: number }): Promise<Triage> {
const { answers } = await client.systemOne({
// Send only the fields the question needs. Irrelevant context measurably
// degrades the answer, so no follower graphs and no conversation history.
state: {
message,
sender_handle: sender.handle,
sender_bio: sender.bio,
sender_followers: sender.followers,
},
questions: QUESTIONS,
});
return {
bucket: answers.bucket.choice as Triage['bucket'],
confidence: answers.bucket.confidence,
probabilities: answers.bucket.probabilities as Record<string, number>,
urgent: answers.urgent.noul,
needsHuman: answers.needs_human.noul,
effort: answers.effort.score,
};
}Three things in there are deliberate. The criteria describe the boundary cases, not the happy path, because a classifier reads instructions literally and a vague option description is where the errors come from. The state carries only the fields the questions need, because irrelevant context measurably degrades the answer. And the Noul answers return a bare float from 0 to 1 with no confidence field, which is the single most common mistake people make writing about this API. The three primitives, side by side.
3. Gate each action on its own threshold
One threshold for the whole system is the mistake that makes AI routing feel unreliable. The right threshold depends on what it costs you to be wrong, and those costs are not remotely equal.
Those numbers are a starting point, not a recommendation. TypeSafe's own documentation refuses to publish universal thresholds and tells you to start conservative, test on your own data and adjust from what you observe. Do that: run the classifier in shadow mode for a week, log everything, and only then turn on the archive branch. How to calibrate a threshold properly.
import { triage, type Triage } from './triage';
// Different actions get different thresholds, because the cost of being wrong
// is different. Dropping a real lead into spam is expensive. Sending a lead to
// a human who did not need to see it is cheap.
const AUTO_ARCHIVE_SPAM = 0.9; // only archive when the model is very sure
const ROUTE_CONFIDENTLY = 0.6; // below this, a person decides the bucket
export type Destination = 'sales_queue' | 'support_queue' | 'partnerships' | 'archive' | 'inbox_review';
export function route(t: Triage): { to: Destination; why: string } {
if (t.bucket === 'spam' && t.probabilities.spam >= AUTO_ARCHIVE_SPAM) {
return { to: 'archive', why: 'spam at ' + Math.round(t.probabilities.spam * 100) + '%' };
}
// A flat distribution is the model telling you it could not decide. Believe it.
if (t.confidence < ROUTE_CONFIDENTLY) {
return { to: 'inbox_review', why: 'low confidence on the bucket' };
}
if (t.needsHuman > 0.5) {
return { to: 'inbox_review', why: 'needs an account detail or a judgement call' };
}
switch (t.bucket) {
case 'sales': return { to: 'sales_queue', why: t.urgent > 0.7 ? 'urgent lead' : 'lead' };
case 'support': return { to: 'support_queue', why: t.urgent > 0.7 ? 'blocked customer' : 'support' };
case 'partnership': return { to: 'partnerships', why: 'effort score ' + t.effort.toFixed(1) };
default: return { to: 'inbox_review', why: 'unclassified' };
}
}4. Run it over the inbox
The classifier runs in parallel, so a hundred messages triage in roughly the time the slowest one takes. Two rules matter more than the throughput: fail open, so a message that could not be classified goes to a human rather than to the archive, and log the pinned model version with every answer so a routing decision is reconstructable six weeks later.
import { triage } from './triage';
import { route } from './router';
export interface InboundDm {
id: string;
text: string;
sender: { handle: string; bio: string; followers: number };
}
export async function triageInbox(messages: InboundDm[]) {
// One request per message, all in flight together. Parallel requests triage
// in about the time the slowest one takes.
const results = await Promise.all(
messages.map(async (dm) => {
try {
const t = await triage(dm.text, dm.sender);
return { dm, t, ...route(t) };
} catch {
// Fail open on classification: an unjudged message goes to a human,
// never to the archive.
return { dm, t: null, to: 'inbox_review' as const, why: 'classifier error' };
}
})
);
// Log the model version and the full answer. A routing decision you cannot
// reconstruct six weeks later is a routing decision you cannot fix.
for (const r of results) {
console.log(JSON.stringify({ model: 'jev-1.13.0', id: r.dm.id, to: r.to, why: r.why, answer: r.t }));
}
return results;
}Pin the model version
Usejev-1.13.0 rather than jev-latest in production. Thresholds you calibrated against one version should not silently move when the alias does.5. What happens after the routing
Routing is only useful if each bucket leads somewhere. Three of them lead back out, and this is where it is worth being precise about what OpenTweet does and does not do.
Support, repeated. If the same question keeps arriving, the highest-leverage answer is a public post rather than five private replies. One call schedules it to X, Bluesky and LinkedIn together.
curl -X POST https://opentweet.io/api/v1/posts \
-H "Authorization: Bearer ot_your_key" \
-H "Content-Type: application/json" \
-d '{
"text": "Three people asked the same question this week, so here is the answer in public.",
"platforms": ["x", "bluesky", "linkedin"],
"category": "Support",
"scheduled_date": "2026-09-22T14:00:00Z"
}'Sales, outbound. DM Campaigns is the outbound half: it discovers candidates, qualifies them against an ICP prompt, drafts a message per lead and moves each lead through fifteen states from found to closed_won. Every send is approved individually. There is no bulk send and no auto-reply, on any plan.
dm_leads_list({ campaign_id: "66f1...", state: "qualified" })
dm_lead_approve({
campaign_id: "66f1...",
lead_id: "66f2...",
final_message: "Saw you shipped the Postgres migration. We built the queue side of that. Worth a look?"
})
dm_inbox_list({ campaign_id: "66f1..." })Creating a campaign requires accept_policy: true, and daily send caps, active hours and a timezone are part of the campaign configuration. DM Campaigns is available on Advanced ($29) and Agency ($49), not on Pro.
What this costs to run
Classification is the cheap part of the pipeline, which is the reason to run it on every message instead of only the ambiguous ones.
Figures from TypeSafe's model documentation, read September 2026. Work out your own volume on the cost calculator.
The rules this pipeline does not break
A human approves every send
Classification sorts, prioritises and drafts. It does not press send. No auto-reply, no bulk send, no unattended outreach.
Fail open, never into the archive
A classifier error, a timeout or a low confidence all mean the same thing: a person reads it. The archive is the one branch that needs a high bar.
Schema-valid is not the same as correct
Jev cannot return a bucket you did not declare. It can absolutely return the wrong one. TypeSafe says the 0% hallucination figure is not empirical and that schema matching is what is guaranteed.
Treat the message as untrusted
The state is user-controlled and is not treated as hostile by the model. A DM can try to steer the classifier. Put the boundary cases in the criteria and test with adversarial messages.
What OpenTweet does not do here
OpenTweet does not read your personal X inbox. It gives you outbound DM Campaigns with per-lead approval and a campaign inbox for replies to that outreach. Pulling arbitrary inbound DMs is your read side, and everything on this page works on whatever source you build.Frequently asked questions
How do you route DMs with AI?
Classify first, route second. Send the message plus a couple of sender fields to a model with one Choice question over your buckets, sales, support, partnership, spam and other, plus a Noul or two for urgency and whether a human is needed. The model returns a bucket, a probability for every bucket and a confidence. Your code reads those numbers and decides where the message goes. Keep the routing logic in code, not in the prompt.
Which model should classify inbound DMs?
Any model can do it once. The question is what happens at volume. An LLM takes seconds and costs cents per call, which is fine for ten messages a day and painful for a thousand. Jev answers in the 70ms to 500ms range TypeSafe publishes, at $0.042 per million input tokens with free output tokens, and returns a probability per bucket rather than a word you have to parse. That is what makes classifying everything, including the obvious spam, affordable.
What confidence threshold should I use for DM routing?
Different actions deserve different thresholds, because the cost of being wrong is different. Archiving something as spam is effectively irreversible, so gate it at a high probability such as 0.9. Putting a message in the wrong team queue costs somebody ten seconds, so 0.6 is plenty. TypeSafe deliberately publishes no universal number and tells you to start conservative and measure on your own data, which is the right advice.
Can OpenTweet read my X DMs and route them for me?
No. OpenTweet does not read your personal X inbox. What it has is DM Campaigns, which is outbound: it discovers and qualifies leads, drafts a message per lead, and gives you a campaign inbox of the replies to that outreach through GET /api/v1/dm-campaigns/{id}/inbox. Classifying arbitrary inbound DMs is your read side to build, and the routing code on this page works on whatever source you use.
Does OpenTweet send DMs automatically?
No. Every send is approved by a person, one lead at a time, through dm_lead_approve or the campaign UI. There is no bulk send, no auto-reply and no unattended outreach on any plan. Creating a campaign requires accept_policy set to true, and daily send limits and active hours are part of the campaign configuration.
Which plans include DM Campaigns?
Advanced at $29 a month and Agency at $49 a month. DM Campaigns is not available on Pro. The classification and routing code on this page needs no OpenTweet plan at all, because it runs against your own model key.
What should the classifier never decide on its own?
Sending anything. Use it to sort, to prioritise and to draft, and keep a person between the draft and the send. The model can return a schema-valid answer that is factually wrong, and a wrong bucket costs you a forwarded message while a wrong send costs you a relationship.
Keep exploring
The classification layer, the outbound side, and the agent both belong to.
DM Campaigns
Outbound X DMs with lead discovery, per-lead approval and a campaign inbox.
Classify replies the same way
The public-facing version of this pattern, for the mentions timeline.
Classifying text with Jev
Choice, Score and Noul worked through with real request and response JSON.
Picking a confidence threshold
How to set a different line per action instead of one number for everything.
Spam and bot detection
The Noul side of the same problem, on accounts rather than messages.
Build an AI social media agent
The three-layer architecture this triage step belongs to, with full code.
Sort the inbox. Approve every send.
DM Campaigns runs outbound X DMs with lead discovery, per-lead approval and a campaign inbox, on Advanced ($29) and Agency ($49).