How-to guide

How to classify social media repliesand publish the answer

Eight steps, end to end. Collect the replies, classify each one with a single Jev request fast enough to run at ingest, route by bucket behind a confidence gate, draft the response with an LLM or a person, and publish it through OpenTweet. Every code block below matches a real API. The one thing this pipeline will not do is send replies for you in bulk, and that is on purpose.

Last updated: September 2026

7-day free trial. No X developer account needed.

Three parts, three jobs

Jev decides which replies matter. An LLM, or you, writes the response. OpenTweet publishes it. Jev cannot write a single word of text, so it is not a replacement for your LLM, and it has no publishing surface, so it is not a replacement for a scheduler. It is the judgement step that used to be too slow and too expensive to run on everything.

What you need first

  1. A Jev key from TypeSafe

    Jev is TypeSafe’s model and is in early access behind a waitlist as of September 2026. It is also reachable through Vercel AI Gateway, OpenRouter and Cloudflare AI Gateway. The SDK reads TYPESAFE_API_KEY from the environment. OpenTweet does not resell Jev and does not bundle a key.

  2. An OpenTweet API key

    Created at /developer. It is shown once, so copy it then. The key format is ot_ followed by 48 hex characters, and it goes in an Authorization: Bearer header.

  3. Node 20 or newer

    npm install @typesafe-ai/sdk. The publishing side is plain fetch, so it needs no dependency at all.

  4. Somewhere the replies already live

    A database table, a webhook sink, an export. The pipeline below reads from that. Collecting them is the one step neither service does for you.

Two services, two bills. TypeSafe prices Jev at $0.042 per 1M input tokens with output unmetered. OpenTweet is a flat monthly plan from $11.99, with the REST API, the CLI and the hosted MCP server on every tier. Neither one resells the other.

1. Collect the replies

This is the step nobody else does for you. OpenTweet's v1 API publishes, it does not read your inbox: there is no mentions endpoint and no in_reply_to field on POST /api/v1/posts. So the replies come from wherever they already land for you, and you normalise them into one shape.

types.ts
export interface InboundReply {
  id: string;
  text: string;
  inReplyToText: string;
  authorHandle: string;
  authorFollowers: number;
  createdAt: string;   // ISO 8601, compared in code, never by the model
  platform: 'x' | 'bluesky' | 'linkedin';
}

Keep the timestamp out of the model

createdAt is on the object so your code can filter on it. It is not sent to Jev, and it should not be used in a question. TypeSafe documents that dates are treated as text rather than ordered values, and that ordering, duration and window membership are unreliable. Its own advice is to split the work: extraction is a judgement, arithmetic is not.

2. Write the question set

One Choice for the bucket, one Score for urgency, four Nouls for the flags. They go in one map because Jev evaluates every declared question in a single parallel pass, so this is one request, one copy of the state, one round trip.

questions.ts
import { TypeSafeClient, choice, noul, score } from '@typesafe-ai/sdk';

const client = new TypeSafeClient({
  timeout: 4000,
  retry: { maxRetries: 1, backoffInitialMs: 200 },
});

const QUESTIONS = {
  bucket: choice('Which single bucket best describes what this reply is asking the author to do', {
    support: 'Reports something broken, or asks for help with an account, a bill, or a bug',
    sales: 'Asks about price, plans, limits, or capability, in a way that reads like someone deciding whether to buy',
    question: 'Asks about the subject of the post itself, and is not about buying or support',
    praise: 'Compliments the post or the product and asks for nothing',
    spam: 'Promotes something unrelated, drops a link, or is generic engagement filler',
    hostile: 'Insults the author or the product, with no request that could be answered',
  }),

  urgency: score('How soon this reply stops being worth answering', [
    'Never urgent. Answering next week is the same as answering now.',
    'Low. Worth an answer this week.',
    'Medium. Worth an answer today.',
    'High. Public, specific, and getting worse while it sits there.',
    'Drop everything. Money, data, safety, or a legal threat, in public.',
  ]),

  needs_human_reply: noul('The author should answer this personally rather than ignore it or let a macro answer it'),
  is_paying_customer: noul('The person writing this says they already pay for the product'),
  mentions_money: noul('The reply mentions a charge, a refund, a subscription, or a price'),
  is_spam: noul('The reply is promotional filler rather than a genuine response to the post', {
    true: 'Promotes an unrelated product, drops a link, or is generic praise ending in a call to follow.',
    false: 'Responds to something specific in the post it is replying to.',
  }),
};

Do not put the flags in the Choice

is_spam and needs_human_reply are not buckets, they are independent facts, and a reply can be a sales question and need a human answer at the same time. Adding them as Choice options forces Jev to split one probability budget across things that are all true, which destroys the answer and the confidence together. The classification guide shows what that failure looks like in the response JSON, and Choice, Score and Noul covers what each primitive returns.

3. Classify

One call per reply, every question inside it. Read choice from the Choice, score from the Score, and noul from each Noul.

classify.ts
export async function classify(reply) {
  const { answers, usage } = await client.systemOne({
    state: {
      reply_text: reply.text,
      in_reply_to: reply.inReplyToText,
      author_handle: reply.authorHandle,
      author_followers: reply.authorFollowers,
      platform: reply.platform,
    },
    questions: QUESTIONS,
  });

  return {
    bucket: answers.bucket.choice,
    bucketConfidence: answers.bucket.confidence,
    bucketProbabilities: answers.bucket.probabilities,
    urgency: answers.urgency.score,
    urgencyConfidence: answers.urgency.confidence,
    needsHumanReply: answers.needs_human_reply.noul,
    isPayingCustomer: answers.is_paying_customer.noul,
    mentionsMoney: answers.mentions_money.noul,
    isSpam: answers.is_spam.noul,
    model: 'jev-1.13.0',
    inputTokens: usage.input_tokens,
  };
}

A Noul has no confidence field

answers.is_spam.noul is a float from 0 to 1 and it is the only field on that answer. There is no answers.is_spam.confidence, and reading one gives you undefined, which quietly compares false against every threshold you write. Only Choice and Score return confidence.

bucketProbabilities is kept on purpose. It is the full distribution across your six buckets, and it is the only record of what the model nearly said. You will want it the first time a routing decision is disputed, and again when you calibrate the threshold in step 8.

4. Run it over the backlog without melting anything

A fixed worker pool, not an unbounded Promise.all. TypeSafe publishes a rate limit of 1,200 requests per minute and 250,000 tokens per second, and notes that the limits adjust dynamically.

pool.ts
const CONCURRENCY = 12;
const RETRYABLE = new Set([429, 529]);

async function classifyWithRetry(reply, attempt = 0) {
  try {
    return await classify(reply);
  } catch (err) {
    // 401 is a bad key, 422 is a malformed question set. Both fail the same way forever.
    if (err.status === 401 || err.status === 422) throw err;

    if (RETRYABLE.has(err.status) && attempt < 4) {
      await new Promise((r) => setTimeout(r, 250 * 2 ** attempt + Math.random() * 100));
      return classifyWithRetry(reply, attempt + 1);
    }

    // Fail open. An unclassified reply goes to a person, it does not disappear.
    return { bucket: 'unclassified', bucketConfidence: 0, urgency: null, needsHumanReply: 1, isSpam: 0 };
  }
}

export async function classifyAll(replies) {
  const out = new Array(replies.length);
  let cursor = 0;

  async function worker() {
    while (cursor < replies.length) {
      const i = cursor++;
      out[i] = { reply: replies[i], scores: await classifyWithRetry(replies[i]) };
    }
  }

  await Promise.all(Array.from({ length: CONCURRENCY }, worker));
  return out;
}
  • Retry 429 and 529. Rate limit and temporary overload, in TypeSafe's own wording. Exponential backoff, then lower the concurrency if they keep coming.
  • Never retry 401 or 422. A bad key and a malformed question set. Both fail identically on every attempt, and retrying them just makes the outage take longer to diagnose.
  • Fail open, not closed. The fallback returns unclassified with needsHumanReply: 1. A triage system that silently drops what it could not score is worse than none, because the missing replies are invisible.

5. Route by bucket, behind a confidence gate

The model returns judgements. The thresholds are a product decision and they belong in one reviewable file, not scattered through the codebase.

route.ts
const MIN_BUCKET_CONFIDENCE = 0.5;
const SPAM_CUTOFF = 0.85;
const REPLY_CUTOFF = 0.6;

type Destination =
  | 'drop'
  | 'human_queue'
  | 'support_ticket'
  | 'sales_queue'
  | 'draft_reply'
  | 'archive'
  | 'escalate';

export function route(scores): Destination {
  // Independent flags are checked before the bucket, because they are independent.
  if (scores.isSpam > SPAM_CUTOFF) return 'drop';
  if (scores.urgency !== null && scores.urgency >= 3.5) return 'escalate';

  // A bucket you are not confident about is not a bucket. Send it to a person.
  if (scores.bucketConfidence < MIN_BUCKET_CONFIDENCE) return 'human_queue';

  switch (scores.bucket) {
    case 'support':
      return scores.mentionsMoney > 0.5 ? 'escalate' : 'support_ticket';
    case 'sales':
      return 'sales_queue';
    case 'question':
      return scores.needsHumanReply > REPLY_CUTOFF ? 'draft_reply' : 'archive';
    case 'praise':
      return scores.needsHumanReply > REPLY_CUTOFF ? 'draft_reply' : 'archive';
    case 'hostile':
      return 'archive';
    default:
      return 'human_queue';
  }
}
DestinationFires whenWhat happens there
drop
is_spam above 0.85
Nothing. Logged and counted, never actioned.
escalate
urgency 3.5 or higher, or support plus mentions_money
Pages a person. This is the only path that interrupts anyone.
support_ticket
bucket support, no money mentioned
Your helpdesk, with the reply text and the scores attached.
sales_queue
bucket sales
A person, or a DM campaign lead, which is human-approved before any send.
draft_reply
question or praise, needs_human_reply above 0.6
An LLM writes a draft. You approve it and send it from X’s composer.
human_queue
bucket confidence below 0.5, or unclassified
A person decides. This queue is your calibration data.

Different actions, different gates

TypeSafe's confidence documentation refuses to publish universal numbers and says to start conservative, test on your own data and adjust, with different actions in the same system gated at different levels depending on the consequences of getting it wrong. Dropping a reply as spam is irreversible from the author's point of view, so it sits behind 0.85. Archiving praise is reversible, so it does not. Setting a confidence threshold covers how to find these from a labelled sample.

6. Draft the response

Jev produced no text and cannot. What it produced is a shortlist, and the shortlist is what makes using a frontier model for the writing affordable.

Before triage

  • Every reply is a candidate for an expensive generation call.
  • Most of them did not need one.
  • The cost scales with inbound volume, which you do not control.

After triage

  • Only the draft_reply and escalate paths reach an LLM.
  • The classification bill is a fraction of a cent per reply.
  • The generation bill scales with what deserved an answer.

Before the draft goes out, score it. The free post scorer runs seven Jev questions over a draft, including whether it reads as machine-written, for a fraction of a cent. A reply to an angry customer that reads as generated is a second incident.

7. Deliver it the right way

This is where most tutorials on this subject are quietly wrong. There is no API path for a one-to-one X reply on a self-serve access tier, and there has not been since February 2026.

What you are sendingThe path that worksNotes
A one-to-one reply on X
Browser extension into X’s own composer
Not the API. Self-serve tiers return 403 since February 23, 2026.
A public statement answering a cluster of replies
POST /api/v1/posts, one call, X and Bluesky and LinkedIn
Works today
A thread explaining the fix
Same endpoint, text plus thread_tweets, up to 25 parts
Works today
Fifty replies at once
No path, deliberately
This is the behaviour that gets accounts actioned.

X restricted programmatic replies on February 23, 2026

On self-serve access tiers a reply sent through the API returns 403. No product can automate that send, and any that claims to is either on an enterprise agreement or about to lose the account. OpenTweet automates the two slow parts, finding the reply that deserves an answer and drafting it against the post, and the send goes through X's own composer with the browser extension. Replies is that feature. There is no bulk send and there will not be one.

What does publish through the API is everything that is not a reply. When triage shows nine people asking the same question, the right answer is usually one public post rather than nine private ones, and that is one call:

publish.sh
curl -X POST https://opentweet.io/api/v1/posts \
  -H "Authorization: Bearer ot_your_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: billing-reply-cluster-2026-09-18" \
  -d '{
    "text": "Nine of you asked about the double charge on Tuesday. It was a retry in our billing worker. Refunds went out automatically this morning and nothing needs to be requested.",
    "is_thread": true,
    "thread_tweets": [
      "If you want the detail: the worker retried without the idempotency key it is supposed to carry, so a handful of charges went through twice.",
      "214 accounts were affected and every one has already been refunded. If your statement still looks wrong, reply here and we will check it by hand."
    ],
    "platforms": ["x", "bluesky", "linkedin"],
    "publish_now": true
  }'
publish.mjs (Node 20+)
const res = await fetch('https://opentweet.io/api/v1/posts', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ot_your_key',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'billing-reply-cluster-2026-09-18',
  },
  body: JSON.stringify({
    text: 'Nine of you asked about the double charge on Tuesday. Refunds went out this morning.',
    is_thread: true,
    thread_tweets: [
      'The detail: a retry in our billing worker ran without its idempotency key.',
      '214 accounts affected, all already refunded. Reply here if your statement still looks wrong.',
    ],
    platforms: ['x', 'bluesky', 'linkedin'],
    publish_now: true,
  }),
});

const data = await res.json();
if (!res.ok) {
  // v1 errors are { error, code, details? } on this route.
  throw new Error((data.code ?? res.status) + ': ' + data.error);
}

console.log(data.posts[0].id, data.posts[0].status, data.posts[0].url);
  • text is the lead post. thread_tweets holds the replies only, and does not repeat the lead. Up to 25 parts including the lead. A body with is_thread and no text is rejected with a 400.
  • publish_now is synchronous. The call returns 201 with the post URL, or 502 with a results[] array showing which network rejected it. You are not polling for a status.
  • Idempotency-Key is worth using here. A retry during a busy morning cannot post the same statement twice.
  • Check quota before you act, not after. GET /api/v1/usage returns your remaining requests and remaining posts for the day, which is cheaper than eating a 429.

8. Log every decision

You cannot calibrate a threshold you did not record. Store the answer, the distribution, the pinned model version and what the human eventually did.

log.ts
await decisions.insert({
  reply_id: reply.id,
  model: scores.model,              // pinned version, not jev-latest
  bucket: scores.bucket,
  bucket_confidence: scores.bucketConfidence,
  bucket_probabilities: scores.bucketProbabilities,
  urgency: scores.urgency,
  nouls: {
    needs_human_reply: scores.needsHumanReply,
    is_paying_customer: scores.isPayingCustomer,
    mentions_money: scores.mentionsMoney,
    is_spam: scores.isSpam,
  },
  routed_to: destination,
  human_override: null,             // filled in when someone moves it
  decided_at: new Date().toISOString(),
});
  • Pin jev-1.13.0, not jev-latest. Thresholds you calibrated against one version should not move because an alias moved. Log the version you actually called.
  • human_override is the whole point. Every time somebody moves a reply out of the bucket it was routed to, you have one labelled example. A few hundred of those is a calibration curve.
  • Run it in shadow mode first. Classify everything, route nothing, and read the log for a week. It costs cents and it is the only way to find out that your sales and question buckets overlap before that fact is routing real people.

What this pipeline still cannot do

Four limits worth stating plainly before you build on it.

On the Jev side

  • It cannot write the reply. No prose, no summaries, no explanation of its own answer.
  • Text only. A reply that is just a screenshot is invisible to it.
  • A schema-valid answer can still be the wrong answer, which TypeSafe concedes.

On the delivery side

  • No API path for a one-to-one X reply on a self-serve tier.
  • No bulk send, by design rather than by limitation.
  • No mentions endpoint. Collecting the replies is your code.

On accuracy, the honest position: TypeSafe's own workflow eval puts Jev at 67.8% against GPT-5.6 Terra's 67.9%, with GPT-6 Sol at 74.1% and Claude Opus 5 at 73.1% both ahead, and the reference labels there are the average of two other models rather than human judgements. The case for Jev in a triage pipeline is cost and latency at comparable accuracy, which is exactly what lets you run it on every reply instead of a sample. It is not a claim that it grades better than a frontier model.

Frequently asked questions

How do you classify social media replies automatically?

Send each reply to a classification model with the buckets declared up front, then branch on what comes back. With Jev that is one request carrying a Choice for the bucket, a Score for urgency and a few Nouls for independent flags, answered in the sub-second range TypeSafe publishes, for a fraction of a cent. Your code routes on the result and a human approves anything that gets sent back out.

Can I auto-reply to the replies once they are classified?

Not on X, and not through any API on a self-serve access tier. X restricted programmatic replies on those tiers on February 23, 2026, so a reply sent through the API comes back 403. OpenTweet automates the slow parts, finding the reply that deserves an answer and drafting it, and the send itself goes through X’s own composer with the browser extension. You read it and you press reply.

Why not just use an LLM to classify replies?

You can, and for a few hundred replies a day nobody will notice the difference. It stops working when classification has to run on everything, because that is where cost and latency decide whether the pipeline exists at all. TypeSafe prices Jev input at $0.042 per 1M tokens with output unmetered, which puts a million classified replies near $10.50, and the sub-second per-call latency TypeSafe publishes means you can classify at ingest rather than in a nightly batch.

Which Jev primitive should I use for reply buckets?

A Choice for the bucket, but only if your buckets are genuinely mutually exclusive. A reply that is both a complaint and a buying question will force a Choice to split probability between the two, which loses both signals and collapses the confidence. Anything that can be independently true, like "needs a human answer" or "is spam", belongs in its own Noul.

Does OpenTweet read my mentions and replies?

The v1 API publishes, it does not read your inbox. There is no mentions endpoint and no in_reply_to field on POST /api/v1/posts, so the replies you classify come from your own store or your own X access. What OpenTweet gives you is the other end: one call that publishes a post or a thread to X, Bluesky and LinkedIn, plus the Replies feature and the browser extension for the one-to-one answers.

Is there a bulk reply button anywhere in this?

No, and there should not be. Firing replies in bulk is the exact behaviour that gets accounts actioned, and the reply that earns you a follower is never one of fifty sent in a minute. Classification is there to decide what deserves your attention, not to remove you from the loop.

What does the whole pipeline cost to run?

Two bills. Jev is priced by TypeSafe at $0.042 per 1M input tokens with output unmetered, so 50,000 classified replies a month is roughly $0.53. OpenTweet is $11.99 a month on Pro, $29 on Advanced and $49 on Agency, with the REST API and the MCP server on all three.

Triage the inbox. Publish the answer.

Jev decides which replies earned your time. OpenTweet publishes the response to X, Bluesky and LinkedIn in one call, and hands the one-to-one replies to the extension. Plans start at $11.99 a month with the API and the MCP server on every one.

  • 7-day free trial
  • No X developer account needed
  • Cancel anytime