Fine. I Built an NLP Tool to Detect Passive-Aggressive Texts.
A working Python script that detects passive-aggressive tone in text messages using NLP sentiment analysis, phrase matching, and punctuation patterns. Copy it, run it, break it.

Researchers at Binghamton University found that text messages ending with a period are perceived as less sincere than texts without one. "Sounds good" reads differently than "Sounds good." The period adds weight. It turns acknowledgment into judgment.
A 2025 Preply survey found 73% of Americans experience passive-aggressive communication at work. 52% of those experience it weekly.
On March 15, 2026, the Bobby Bones Show spent fifteen minutes debating whether the thumbs-up emoji is passive-aggressive. A week earlier, CNN reported that Gen Z is outsourcing difficult conversations to AI because they cannot parse tone in text.
The pattern is clear: we communicate more through text than ever, and we are worse at reading tone than ever.

So I built a detector.
What does the script do?
The Passive-Aggressive Tone Detector is a Python script that analyzes any text message across three NLP layers:
- Sentiment analysis using TextBlob (polarity and subjectivity)
- Phrase matching against 20 known passive-aggressive patterns
- Punctuation and structural analysis for signals research has linked to hostility
It outputs a PA score from 0 to 100 and a verdict: Clean, Mild shade, Passive-aggressive, or Weaponized politeness.
One dependency. No API keys. Runs locally.
pip install textblob
How do you get the full script?
#!/usr/bin/env python3
"""
Passive-Aggressive Tone Detector
Built by Angel at vervo.app | License: MIT
"""
import sys, re
from textblob import TextBlob
class Colors:
HEADER = '\033[95m'; BLUE = '\033[94m'; CYAN = '\033[96m'
GREEN = '\033[92m'; YELLOW = '\033[93m'; RED = '\033[91m'
BOLD = '\033[1m'; DIM = '\033[2m'; RESET = '\033[0m'
PA_PHRASES = [
("per my last email", 25, "Translation: 'I already told you this.'"),
("as i mentioned", 20, "Implies you weren't listening."),
("going forward", 15, "Often follows unstated criticism."),
("just to clarify", 18, "Suggests you misunderstood something obvious."),
("no worries", 12, "Can mask actual worries."),
("it's fine", 20, "Rarely means it's actually fine."),
("i'm not mad", 25, "Almost always means they're mad."),
("do what you want", 22, "Permission that's actually a warning."),
("interesting choice", 20, "Polite way to say 'bad decision.'"),
("good for you", 18, "Often sarcasm."),
("noted", 15, "Dismissive acknowledgment."),
("thanks for letting me know", 15, "Ends a conversation they didn't want."),
("i'll handle it myself", 22, "Martyrdom statement."),
("must be nice", 20, "Jealousy wrapped in fake congratulations."),
("whatever you think is best", 18, "They don't think it's best."),
("sure", 8, "Depends on punctuation and context."),
("fine", 10, "One of the most loaded words in English."),
("if you say so", 18, "Disagreement disguised as compliance."),
]
SARCASM_STARTERS = ["wow", "great", "cool", "nice", "awesome", "wonderful"]
def analyze_message(text):
signals = []
text_lower = text.lower().strip()
blob = TextBlob(text)
words = text.split()
if len(words) <= 3 and text.endswith('.') and not text.endswith('...'):
if text_lower.rstrip('.') in ['fine','ok','okay','sure','whatever','great','cool']:
signals.append((25, "Period on short message",
f"'{text}' -- Periods on brief texts signal insincerity."))
if '...' in text:
signals.append((15, "Trailing ellipsis",
"Creates an unfinished, loaded feeling. Something is left unsaid."))
for phrase, weight, explanation in PA_PHRASES:
if phrase in text_lower:
signals.append((weight, f"PA phrase: '{phrase}'", explanation))
if text_lower in ['k', 'k.']:
signals.append((30, "Single letter 'K'",
"Universally recognized as dismissive or annoyed."))
caps_words = re.findall(r'\b[A-Z]{2,}\b', text)
caps_words = [w for w in caps_words if w not in ['I','OK','US','UK','TV','IT','HR']]
if caps_words and len(caps_words) <= 3:
signals.append((12, "Strategic caps",
f"CAPS on '{', '.join(caps_words)}' adds frustrated emphasis."))
first_word = text_lower.split()[0] if words else ""
if first_word.rstrip('.,!') in SARCASM_STARTERS and len(words) <= 2:
signals.append((18, "Standalone sarcasm marker",
f"'{text}' as a complete response often signals sarcasm."))
politeness_count = sum(1 for p in ['please','kindly','appreciate','grateful','thank you']
if p in text_lower)
if politeness_count >= 2 and len(words) < 20:
signals.append((10, "Stacked politeness",
"Multiple politeness markers in a short message can feel weaponized."))
polarity = blob.sentiment.polarity
if any(m in text_lower for m in ['but','however','although','though','yet']):
if polarity > 0.2:
signals.append((12, "Sentiment contradiction",
"Positive words paired with 'but/however' negate the positivity."))
raw_score = min(100, sum(s[0] for s in signals))
if raw_score == 0: verdict, color = "Clean", Colors.GREEN
elif raw_score <= 20: verdict, color = "Mild shade", Colors.YELLOW
elif raw_score <= 50: verdict, color = "Passive-aggressive", Colors.RED
else: verdict, color = "Weaponized politeness", Colors.RED + Colors.BOLD
return {
'text': text, 'signals': signals, 'pa_score': raw_score,
'verdict': verdict, 'verdict_color': color,
'polarity': blob.sentiment.polarity,
'subjectivity': blob.sentiment.subjectivity,
}
def print_results(r):
print(f"\n{Colors.BOLD}{'='*60}{Colors.RESET}")
print(f"{Colors.CYAN}{Colors.BOLD}PASSIVE-AGGRESSIVE TONE DETECTOR{Colors.RESET}")
print(f"{Colors.BOLD}{'='*60}{Colors.RESET}\n")
print(f"{Colors.BOLD}Analyzing:{Colors.RESET} \"{r['text']}\"\n")
print(f"{Colors.DIM}{'-'*60}{Colors.RESET}")
if r['signals']:
print(f"\n{Colors.BOLD}Detected Signals:{Colors.RESET}\n")
for weight, label, explanation in r['signals']:
print(f" {Colors.YELLOW}[+{weight}]{Colors.RESET} {Colors.BOLD}{label}{Colors.RESET}")
print(f" {Colors.DIM}{explanation}{Colors.RESET}\n")
else:
print(f"\n {Colors.GREEN}No passive-aggressive signals detected.{Colors.RESET}\n")
print(f"{Colors.DIM}{'-'*60}{Colors.RESET}")
bar_w = 30
filled = int((r['pa_score'] / 100) * bar_w)
print(f"\n PA Score: [{'#'*filled}{'-'*(bar_w-filled)}] {r['pa_score']}/100")
print(f" Verdict: {r['verdict_color']}{r['verdict']}{Colors.RESET}")
p = "positive" if r['polarity'] > 0 else "negative" if r['polarity'] < 0 else "neutral"
print(f"\n {Colors.DIM}Polarity: {r['polarity']:.2f} ({p}){Colors.RESET}")
print(f" {Colors.DIM}Subjectivity: {r['subjectivity']:.2f}{Colors.RESET}")
print(f"\n{Colors.BOLD}{'='*60}{Colors.RESET}")
print(f"{Colors.DIM}Built by Angel at vervo.app{Colors.RESET}\n")
if __name__ == "__main__":
text = ' '.join(sys.argv[1:]) if len(sys.argv) > 1 else input("> ").strip()
if not text: sys.exit(1)
print_results(analyze_message(text))
How does each detection layer work?
How does sentiment analysis help?
TextBlob returns two values for any text:
- Polarity (-1.0 to 1.0): negative to positive
- Subjectivity (0.0 to 1.0): factual to opinionated
Passive-aggressive text is unusual because it often registers as neutral or slightly positive on polarity. "No worries!" has positive sentiment. But paired with "per my last email," that positivity is performing a different function.
Sentiment alone will not catch passive aggression. But sentiment combined with other signals creates the pattern.
What phrases does the script flag?
Some phrases are red flags regardless of context. The script checks against 18 known patterns, each with a weighted score. "Per my last email" (weight: 25) translates to "I already told you this." "I'm not mad" (weight: 25) almost always means they are mad. "Do what you want" (weight: 22) is permission that is actually a warning.
These phrases are not inherently hostile. But they have been weaponized so consistently that the hostility is structural.
How does punctuation analysis work?
This is where the Binghamton research matters. The script checks:
- Period on short messages: "Fine." "Ok." "Sure." Research shows these signal insincerity.
- Trailing ellipsis: "I guess..." creates a loaded, unfinished feeling.
- Standalone sarcasm markers: "Wow." or "Great." as complete responses.
- Single letter K: universally dismissive.
- Strategic ALL CAPS: emphasis that signals frustration.
- Stacked politeness: multiple "please/kindly/appreciate" in a short message can feel performative.

What do the test results look like?
Six test cases. Real output from the script.
Test 1: "Fine." -- Detected signals: Period on short message (+25), PA phrase: fine (+10). Verdict: Passive-aggressive. TextBlob polarity: 0.42 (positive). TextBlob reads Fine as positive. The detector catches what sentiment analysis misses: the period and the word itself.
Test 2: "Per my last email, I've attached the document again. No worries." -- Detected signals: PA phrase: per my last email (+25), PA phrase: no worries (+12). PA Score: 37/100. Verdict: Passive-aggressive. Polarity: 0.00 (neutral). Two stacked PA phrases. Neutral sentiment. Textbook corporate passive aggression.
Test 3: "Sounds good, I'll take care of it!" -- No passive-aggressive signals detected. PA Score: 0/100. Verdict: Clean. Polarity: 0.88 (positive). High positive polarity. No phrase matches. No structural flags.
Test 4: "K" -- Detected signals: Single letter K (+30). PA Score: 30/100. Verdict: Passive-aggressive. Polarity: 0.00 (neutral). Zero sentiment. Zero subjectivity. But the single letter K is one of the strongest passive-aggressive signals in texting.
Test 5: "Thanks for finally getting back to me..." -- Detected signals: Trailing ellipsis (+15). PA Score: 15/100. Verdict: Mild shade. Polarity: 0.07 (positive). The ellipsis does the work here. "Finally" is doing something too, but the current version does not flag temporal qualifiers. That is a clear improvement path.
What does it not catch?
The script has blind spots. Passive aggression is contextual. "No worries!" from a friend after you cancel plans is probably sincere. "No worries!" from a coworker after you missed a deadline is probably not. The script cannot know that context.
It also misses:
- Temporal qualifiers like "finally" or "at last"
- Emoji-only responses (would need Unicode handling)
- Tone shifts within a conversation (single-message analysis only)
- Cultural variation (what reads as passive-aggressive in American English may not in British English)
These are real limitations, not excuses. They are also clear extension points if you want to fork the script.
What research supports this?
Three studies inform this script:
Binghamton University (2015): Text messages ending with periods are perceived as less sincere. The researchers tested responses to questions and found that one-word replies with periods were consistently rated as less genuine than the same replies without periods. The period is not grammar in texting. It is tone.
Preply (2025): Surveyed 1,200+ Americans. 73% experience passive-aggressive communication at work. The most passive-aggressive people: coworkers (20%), mothers (18%), friends (16%). 83% said they would end a relationship over consistent passive aggression.
CNN / Gen Z AI usage (March 2026): Growing numbers of young people paste entire text conversations into AI chatbots to decode tone. Your texting style reveals more about you than you realize. Researchers warn this outsourcing may degrade the social skills it is trying to compensate for.
How can you extend the script?
The phrase list is intentionally editable. Add your own:
PA_PHRASES.append(("friendly reminder", 18, "The word friendly is doing heavy lifting."))
Other extension ideas:
- Weight by context length: "sure" in a 50-word message is different from "sure" as the entire reply
- Conversation-level analysis: feed multiple messages, detect escalation patterns
- Emoji analysis: map common emoji-only responses to PA scores
- Export to JSON: pipe results into dashboards or Slack bots
The script is MIT licensed. I have written about why short replies make people spiral. The answer is usually context collapse. You lose tone, intent, everything except raw characters. Your brain defaults to the worst interpretation.
This script is one way to get a second opinion before you spiral.
How do you install and run it?
pip install textblob
python tone_detector.py "Per my last email, no worries."
Or interactive mode:
python tone_detector.py
> Fine.
The full script is above. Copy it. Break it. Add the phrases your coworkers actually use.
If you want to stop overthinking texts entirely, that is why I built vervo.app. But the script is free.
Sources:
- Texting insincerely: The role of the period in text messaging -- Binghamton University (2016)
- The Most Passive-Aggressive Phrases, Ranked -- Preply (2025)
- Is Thumbs Up Passive Aggressive? -- Bobby Bones Show (March 2026)
- Gen Z is outsourcing hard conversations to AI -- CNN Health (March 2026)
- TextBlob: Simplified Text Processing -- MIT License