---
title: "Detect grooming patterns in conversations"
method: POST
path: "/api/v1/safety/grooming"
tags: ["Safety"]
---

# Detect grooming patterns in conversations

`POST /api/v1/safety/grooming`

## Request

- Base URL: `https://api.tuteliq.ai`
- URL: `https://api.tuteliq.ai/api/v1/safety/grooming`
- Auth: one of:
  - HTTP bearer
  - API key in header `x-api-key`

Analyze a sequence of messages for multi-stage grooming tactics: trust building, isolation, secrecy requests, boundary testing, and sexual escalation. Accepts full conversation history with sender roles to detect patterns that single-message analysis would miss. Returns grooming risk level, identified tactics as flags, and recommended intervention actions.

## Request body

- object
  - `messages` object[], required: Sequence of messages to analyze. Consecutive same-role messages of ≤3 characters are automatically defragmented before tactic analysis to prevent letter-by-letter evasion.
    - `sender_role` string, required: Role of sender (e.g., "adult", "child")
    - `text` string, required: Message text
    - `sender_age` number: Optional numeric age of THIS message's sender. Strengthens age-gap analysis without forcing the model to infer it from the role label.
  - `continuation_token` string: Opaque signed token from a previous /grooming call. Carries derived analysis state across calls without server-side storage.
  - `reset_conversation` boolean: Discard any continuation_token and start a fresh conversation.
  - `message_id` string: Optional customer-side identifier for this batch (used as the evidence pointer in the issued continuation_token).
  - `context` object
    - `child_age` number: Age of the child / minor in the conversation.
    - `participant_age` number: Optional age of the non-minor participant. When known (e.g. on age-verified platforms), this lets the model compute the actual age gap rather than infer it.
    - `platform` string: Platform name
    - `language` string: Language code (e.g., "en")
    - `conversation_type` 'fraud_report' | 'abuse_report' | 'support_disclosure' | 'helpline' | 'clinical' | 'investigative' | 'legal' | 'advisory' | 'training' | 'journalism' | 'research' | 'moderation_review' | 'healthcare' | 'law_enforcement' | 'education' | 'family' | 'workplace' | 'dating' | 'adult_community' | 'direct_message' | 'group_chat' | 'public_post' | 'customer_service' | 'gaming_chat': Optional, no default: omit it and behaviour is unchanged. The setting this conversation was captured in, declared by your platform from its own configuration, never from end-user input. A declared setting names the responsible party in the conversation: a counsellor or helpline worker (`helpline`, `clinical`), a clinician (`healthcare`), an officer or investigator (`law_enforcement`, `investigative`), an adviser taking a report (`fraud_report`, `abuse_report`, `support_disclosure`, `advisory`), a moderator (`moderation_review`), a lawyer, trainer, journalist or researcher (`legal`, `training`, `journalism`, `research`), a teacher, tutor or safeguarding lead (`education`), a parent or guardian (`family`). In any of these settings, a conversation in which a person is at risk, that responsible party is already handling it, and nothing is happening as the text is written, is kept visible at `monitor` without an alert, and `protective_speech_capped` says so. Harm being done in the conversation, by anyone including the responsible party, a person at risk with nobody acting, and anything acute alert as they would with no setting. Where the harm is discussed and not enacted (`fraud_report`, `abuse_report`, `support_disclosure`, `helpline`, `clinical`, `investigative`, `legal`, `advisory`, `training`, `journalism`, `research`, `moderation_review`) an urgent action is also capped to `flag_for_review`, and the response echoes `conversation_type_applied`. `direct_message`, `group_chat`, `public_post`, `customer_service` and `gaming_chat` change nothing. In `education` and `family` this applies only when the speaker is not the person at risk: a young person speaking for themselves to a teacher or a parent alerts as with no setting.
  - `options` object
    - `support_threshold` 'low' | 'medium' | 'high' | 'critical': Minimum severity to show crisis support resources (default: high). Critical always shows.
    - `verdict_only` boolean: Fast mode: omit the per-message `message_analysis` array and `action_detail` from the response, reducing output length and latency. `rationale` (used for incident summaries) is always generated.
    - `include_relationship_frame` boolean: Opt in to the descriptive `relationship_frame` response field. Off by default because it costs one extra narrow model call. Ignored in `verdict_only` mode, and only computed when at least one tactic was flagged. It never changes the verdict; see the field description.
  - `support_threshold` 'low' | 'medium' | 'high' | 'critical': Top-level alias for options.support_threshold. Minimum severity to show crisis support resources (default: high). Critical always shows.
  - `external_id` string: Your unique identifier (e.g. message ID, content ID) for correlating Tuteliq results with your own system. Echoed back in the response and included in webhook payloads so you can match alerts to the original content.
  - `customer_id` string: Your end-customer identifier for multi-tenant / B2B2C scenarios. If you serve multiple customers (schools, apps, brands) from a single API key, set this to route webhook alerts to the correct customer. Echoed back in the response and included in webhook payloads as "customerId".
  - `metadata` object: Arbitrary key-value pairs for additional context (e.g. { "channel": "discord", "region": "eu" }). Stored with the detection result, echoed in the response, and included in webhook payloads. Maximum 20 properties.
  - `incident_moderation_enabled` boolean: Per-call override of your account incident logging setting. When set, it takes precedence over the account-level flag for THIS request: true forces the incident to be persisted, false suppresses persistence. Omit to use your account default (which itself defaults to enabled). Lets you control incident logging per request, e.g. suppress logging for test traffic.

## Response `200`

Default Response

- object
  - `urgency_kept` boolean: True when a declared `context.conversation_type` would have capped an urgent action to `flag_for_review` and did not, because the case is acute: the analysis named an acute marker, or a separate reading found a person at risk now with something happening as the text is written. A reading that gave no answer also keeps the urgency. Absent otherwise, and never present together with `conversation_type_applied`.
  - `urgency_kept_reason` string: Human-readable explanation. Present only when urgency_kept is true.
  - `conversation_type_applied` string: Present only when a caller-declared `context.conversation_type` actually changed routing. Confirms the setting was taken into account, rather than leaving the platform to assume it was.
  - `grooming_risk` 'none' | 'low' | 'medium' | 'high' | 'critical': Grooming-risk level. "low" indicates a signal worth monitoring but no action required; branch your alerting on `recommended_action` (`flag_for_review` / `block` / `immediate_intervention`), not the bare presence of a low-level risk.
  - `verdict_withdrawn` boolean: True when the analysis returned a high or critical verdict with no tactic and no risk score to support it. The verdict was reconciled to what the response actually evidences and recommended_action brought down to review, rather than returning two fields that contradict each other. Never fires on a finding that names a tactic or carries any score. Absent unless it applied.
  - `verdict_withdrawn_reason` string: Human-readable explanation. Present only when verdict_withdrawn is true.
  - `number_coincidence_cleared` boolean: True when an alert rested on a number that is also known as a code, and a separate reading found the number in ordinary use (an address, a count, a price, a room) with nothing else harmful in the content. The verdict was cleared. Absent when this did not happen.
  - `number_coincidence_cleared_reason` string: Human-readable explanation. Present only when number_coincidence_cleared is true.
  - `verdict_capped` boolean: True when the analysis returned a high or critical verdict carrying a risk score but naming no tactic to support it. The verdict is capped to medium and recommended_action to flag_for_review, so the conversation still reaches a reviewer rather than routing an escalation a reviewer cannot check; risk_score is capped to the top of the medium band. A medium verdict naming no tactic keeps its level and action and has only its risk_score capped to the top of the medium band. Where, in addition, no child was declared or stated anywhere, none of the withdrawn claims was a tactic that presupposes a child, no refusal or threat was reported and a separate reading found no sign of a minor and no pressure, the verdict is lowered to low and recommended_action to monitor: kept visible, not sent for review. `verdict_capped_reason` says which applied. Never fires on a finding that names a tactic. Only ever lowers, never raises. Absent unless it applied.
  - `verdict_capped_reason` string: Human-readable explanation. Present only when verdict_capped is true.
  - `affection_only_capped` boolean: True when an alert that named no tactic was lowered to `low` / `monitor` because a separate reading of the conversation found nothing in it but warmth and everyday logistics: greetings, terms of endearment, kisses, "love you", checking where someone is. A parent calling a child "baby" is the case this exists for. It applies ONLY when no tactic survived verification; one verified tactic and it does nothing. It never clears to `none`, so a pattern can still build across a conversation. Absent when it did not apply.
  - `affection_only_capped_reason` string: Human-readable explanation. Present only when affection_only_capped is true.
  - `adult_exchange_capped` boolean: True when an alert was lowered to `low` / `monitor` because the people in the conversation are adults. It applies only when the request declared no child (no `child_age` under 18, no child `sender_role`, no `sender_age` under 18), nobody states an age under 18, and no tactic that presupposes a child was verified; a separate reading must then find that the people taking part state ages of 18 or more (or every message carries a `sender_age` of 18 or more), nothing indicating that a participant is or may be under 18, and no pressure, threat or refusal. A conversation in which nobody states an age keeps its alert. Grooming is a harm done to a child, and a willing conversation between adults is not one. If your platform knows a participant is a minor, declare it and this never applies. It never clears to `none`. Absent unless it applied.
  - `adult_exchange_capped_reason` string: Human-readable explanation. Present only when adult_exchange_capped is true.
  - `protective_speech_capped` boolean: True when a conversation about a young person who has been approached was kept at `monitor` and not raised to review, because your platform declared a `context.conversation_type` that names the responsible party in the conversation and that party is already handling it. Never `none`. In `education` and `family` this applies only when every message is an adult's. Absent otherwise.
  - `protective_speech_capped_reason` string: Human-readable explanation. Present only when protective_speech_capped is true.
  - `shared_surprise_capped` boolean: True when a review alert was lowered to `low` / `monitor` because the only thing kept quiet in the conversation is a surprise for the person it is kept from (a present, a party), a parent or carer of the child is part of the plan, and the adult asks for nothing alone, personal or deleted. It applies ONLY to `flag_for_review` below critical, and only when a secrecy request and boundary pushing are the only tactics that survived verification: a photo request, a meeting, a gift to the child, isolation or flattery blocks it. A separate reading must find all three things; anything else, or no answer, keeps the alert. The tactics stay in `flags`. What defines this harm is an adult keeping their contact with a child from the people who protect the child. Absent when it did not apply.
  - `shared_surprise_capped_reason` string: Human-readable explanation. Present only when shared_surprise_capped is true.
  - `meeting_logistics_cleared` boolean: True when a single submitted message was flagged for `meeting_request` and nothing else, and a separate reading found that it only gives, asks for or arranges practical details for an ordinary purpose. The result is cleared: `grooming_risk` none, no flags, no action. Never applied to a conversation of more than one message, to a result that names anything else, or to the most urgent action. Absent otherwise.
  - `meeting_logistics_cleared_reason` string: Human-readable explanation. Present only when meeting_logistics_cleared is true.
  - `long_conversation_raised` boolean: True when a long conversation was read a second time with a few of its messages singled out for close reading, and the result shown is that second reading of the whole conversation. Only ever raises a result, never lowers one. Absent otherwise.
  - `long_conversation_raised_reason` string: Human-readable explanation. Present only when long_conversation_raised is true.
  - `declared_age_overridden` boolean: True when the caller declared an adult child_age but a participant states a minor age in the conversation itself. The declared age was not used. Nothing is escalated by this on its own; the analysis simply runs as if no age had been sent. Worth investigating at your end: an age check that disagrees with what the user says in the chat is what a bypassed age verification looks like. A stated ADULT age never has this effect, because message text is attacker-controlled.
  - `declared_age_overridden_reason` string: Human-readable explanation. Present only when declared_age_overridden is true.
  - `confidence` number
  - `flags` string[]: Closed vocabulary. Values outside this list are dropped before the response is built, so this field is safe to switch on. Tuteliq's published grooming taxonomy is EIGHT per-message tactics: `flattery`, `photo_request`, `gift_giving`, `secrecy_request`, `meeting_request`, `isolation`, `boundary_pushing` and `reconnaissance`. Those are the eight to quote, and the ones the model card reports. `sexual_content` also appears here and is NOT a tactic: it is a content category sharing the array. `reconnaissance` is probing whether a child is unsupervised, alone or reachable: who oversees the child's device or person (supervision), or when the child is alone, free or unobserved (availability). As of 25 September 2026 it is emitted PER MESSAGE on the turn carrying the probe, including probes phrased as statements. A question that is useless to the asker unless the answer is "yes, supervised" is a safeguarding check and is deliberately NOT flagged, so a tutor or coach confirming a guardian will be present does not produce it. CONVERSATION-LEVEL ACCUMULATION IS A SEPARATE CAPABILITY AND IS NOT YET SCORED THAT WAY. This field tells you a given message carried a probe; it does not tell you how many probes make a pattern, and no arc-level threshold is applied. Do not read a single `reconnaissance` as a completed profiling arc, and do not read the absence of `reconnaissance` across a conversation as the absence of probing. `sexualization` and `sextortion` reach `flags` by a different route, coded-term matching. Those two assert something weaker than a tactic: that the conversation contains a recognised coded term of that kind, confirmed in context. `grooming` remains in the vocabulary and is not currently emitted. If you are reporting on the taxonomy, use the eight.
  - `relationship_frame` 'established_offline' | 'online_only' | 'unestablished': What the CONVERSATION CONTENT establishes about how the two participants know each other, reported factually so a finding can be routed. `established_offline` = they already knew each other in the physical world before this conversation (school, club, lesson, household, a stated and evidenced role). `online_only` = the contact exists only online or began online, INCLUDING a conversation working toward a first meeting. `unestablished` = the content settles neither. DESCRIPTIVE ONLY. This never changes `grooming_risk`, `risk_score`, `recommended_action` or `flags`, in the same way `speaker_role` is descriptive on /safety/bullying. DO NOT TREAT `established_offline` AS REASSURING, and do not build a rule that lowers risk because an adult is known to the child: most child sexual abuse is committed by someone the child already knows. Measured on the 270-conversation PAN12 problem-2 set, `online_only` was two and a half times MORE common in non-predatory conversations than predatory ones (0.517 vs 0.193), and `established_offline` was three times more common in predatory ones (0.173 vs 0.058). The stranger intuition runs backwards in real transcript data. Present only when at least one tactic was flagged, since it exists to route a finding. Absent if the classification fails.
  - `disclosure_cleared` boolean: True when `grooming_risk` was cleared to `none` because this conversation is a DISCLOSURE, not an incident: every tactic the analysis claimed was withdrawn as described rather than performed, and `speaker_stance` is `describing_third_party`. A child telling someone for the first time, a bystander report, or a parent reporting what their child told them. The grooming verdict is the only thing cleared. `recommended_action` is deliberately NOT lowered, because a disclosure still needs to reach a person: treat it as a support and safeguarding route rather than a moderation one, and never action the speaker. Absent when the clear did not apply.
  - `disclosure_cleared_reason` string: Human-readable explanation of why `disclosure_cleared` was applied. Present only alongside it.
  - `confidentiality_frame` 'unconditional_concealment' | 'limited_confidentiality' | 'guardian_included' | 'none': The adult's own framing of who may know, described factually rather than judged. `unconditional_concealment` asks the child to keep something from someone with no stated limit; `limited_confidentiality` offers privacy but names a condition under which the adult would tell someone ("unless someone's at risk"), which is safeguarding practice rather than a secrecy demand; `guardian_included` tells the child to inform a parent or carer, or says the adult will.
  - `secrecy_frame_cleared` boolean: True when `secrecy_request` was removed from `flags` because the adult stated the limits of confidentiality or included a guardian. Routing is unaffected: the secrecy_request + isolation escalation reads the pre-removal flag list, so this can never lower an action. Absent when nothing was removed.
  - `flag_attribution` object: Which participant each flag is attributed to: tactic -> the `sender_role` values of the messages that actually carry it. Derived from the per-message analysis, so it answers "who is doing this?" rather than assuming the non-child role. OMITTED ENTIRELY when `speaker_stance` is `describing_third_party`, because a speaker reporting someone else's conduct is not performing it. A child disclosing abuse or reporting a contact would otherwise be named as the author of the tactics they are describing, and a platform actioning this field would sanction the person who asked for help. Use `speaker_stance` for those conversations. Also omitted when the model produced no per-message tags. Attribution only; it does not change the verdict or imply the other party is blameless.
  - `ungrounded_flags` string[]: Conversation-level tactics the model asserted but that could not be supported: either tied to no specific message at all, or tied only to messages that a separate adjudication check found do not express that tactic (#262). Removed from `flags` and surfaced rather than silently dropped: a tactic here is an UNSUPPORTED claim, not a finding, and should not be treated as evidence.
  - `reciprocity_floor_applied` boolean: True when the verdict was floored to at least medium / 0.6 / flag_for_review because a separate factual classifier confirmed adult-minor romantic reciprocity - an adult and a minor declaring or reciprocating romantic love, directly or relayed through a third party (#189). A raise-only floor: it never lowers anything, and it exists because this implicit-outcome shape carries no tactic vocabulary and its model-side detection proved unstable across sittings (#292). Absent when the floor did not apply.
  - `consent_floor_applied` boolean: True when `recommended_action` was raised to `immediate_intervention` because the model reported an explicit refusal being overridden, or a threat to expose an intimate image or a confidence, in order to obtain compliance. This is a floor and never lowers an action. It exists because urgent routing on this endpoint is otherwise driven by the adult/child age gap, which is structurally absent when both participants are minors, leaving peer sexual coercion unable to reach the urgent band (#278). Absent when the floor did not apply.
  - `consent_violation` 'explicit_refusal_overridden' | 'threatened_exposure': The factual finding behind `consent_floor_applied`, present only when one was reported. Deliberately describes what happened rather than who did it, so it is independent of the participants' ages.
  - `rationale` string
  - `risk_score` number
  - `recommended_action` 'none' | 'monitor' | 'flag_for_review' | 'block' | 'immediate_intervention': Routing key — a single stable token, safe to switch on. Ordered weakest to strongest: `none` (no signal), `monitor` (log, no human needed), `flag_for_review` (queue for a moderator), `block` (withhold or limit the content), `immediate_intervention` (crisis path). Human-readable guidance is in `action_detail`, not here.
  - `action_detail` string: Optional one-to-two-sentence explanation of `recommended_action`, intended for a moderator UI. Never branch on this field — it is free text and its wording is not stable across releases.
  - `message_analysis` object[]: Per-message risk breakdown showing how risk escalates across the conversation. Rows are emitted for the messages the model judged relevant, so a conversation may have fewer rows than messages: a message without a row carried no per-message finding, and is not the same as a row with risk_score 0. `message_index` is model-supplied; every flag on a row was checked against that message by a separate adjudication, and a flag the check refuted is moved to the row's `unsupported_flags` rather than left in `flags`.
    - `message_index` number: 1-based index of the message. The model names the message each row describes and the service re-keys a row whose name matches a different message, before any flag on it is checked.
    - `risk_score` number: Individual risk score (0.0-1.0). Set to 0 when every tactic the row claimed was withdrawn (see `unsupported_flags`); a row that claimed no tactic keeps its score.
    - `flags` string[]: Tactics detected in this message and supported by it
    - `unsupported_flags` string[]: Tactics the model claimed on this message that were withdrawn: refuted for this message by the adjudication, or withdrawn at conversation level (see `ungrounded_flags`). Not evidence. The summary carries a verification note when this is present.
    - `summary` string: Brief explanation of risk contribution. Prose for a reviewer; wording is not stable and may carry a bracketed verification note. Never branch on this field: use `flags` and `unsupported_flags`.
  - `language` string: Language code used for analysis (auto-detected or provided)
  - `language_status` 'stable' | 'beta': Language support maturity: "stable" (en) or "beta" (all others)
  - `credits_used` number: Number of credits consumed by this request
  - `continuation_token` string: Privacy-first signed token carrying derived analysis state across calls. Pass back on the next /grooming request.
  - `continuation_expires_at` string: ISO 8601 expiry timestamp of the continuation_token.
  - `state_source` 'token' | 'fresh' | 'reset': How prior state was sourced for this call.
  - `trajectory_risk` number: Conversation-level risk 0-1, distinct from `risk_score` which scores only this message. Anchored on the highest severity seen so far and decaying slowly across benign turns, so a friendly message after an escalation does not reset it. Absent on the first turn. Derived from the signed token: no message content is stored to produce it.
  - `trajectory` 'rising' | 'stable' | 'declining' | 'none': Direction of travel across the conversation so far.
  - `severity_series` number[]: Per-turn severity, oldest first — the evidence behind `trajectory_risk`, so a moderator can see why a benign-looking message carries elevated conversation risk.
  - `analysis_status` 'ok' | 'engine_error' | 'out_of_scope_adults': Status of the analysis. `ok` is the default. `engine_error` means the analysis engine could not produce a verdict (malformed/adversarial input) and `recommended_action` is forced to `flag_for_review`. `out_of_scope_adults` means the caller provided 2+ adult ages (via `context.participant_age`, `context.child_age`, or per-message `sender_age`) and the call was short-circuited as out of scope for grooming — consider /safety/coercive-control or /safety/social-engineering for adult-adult dynamics.
  - `speaker_stance` 'deploying' | 'describing_third_party' | 'unclear': The model's own conclusion on whether the content is the speaker deploying a tactic in this exchange, or describing/reporting one performed by a third party (a disclosure, a survivor testimony, a bystander report) — independent of severity. Present when the model returns a recognised value AND the conversation carries at least one tactic, either in `flags` or in `ungrounded_flags`. A stance is a statement about a tactic, so it is omitted on a conversation that never had one, where the value is also unstable between runs. It is NOT omitted when every tactic was withdrawn: that is the shape of a disclosure, where a speaker claims tactics that an adjudication then finds are described rather than performed, and it is exactly the case where a platform needs to know this is a report so it can route to support rather than moderation.
  - `action_capped` boolean: True when `recommended_action` was capped below `block` because `speaker_stance` was `describing_third_party` — withholding a disclosure or a help-seeking report silences the person who needs to be heard. THIS CAP changes routing only and never touches `grooming_risk` or `risk_score`. Those fields can still be cleared to `none`/`0` on the same response by the separate `disclosure_cleared` mechanism, which is a different decision with its own conditions: see that field. Absent when no cap applied.
  - `action_capped_reason` string: Present only when action_capped is true.
  - `external_id` string: Echo of the external_id you provided in the request
  - `customer_id` string: Echo of the customer_id you provided in the request
  - `metadata` object: Echo of the metadata you provided in the request
  - `support` object: Country-specific crisis helplines and response guidance (only included for high/critical severity)
    - `country` string
    - `country_name` string
    - `emergency_number` string
    - `helplines` object[]
      - `name` string
      - `number` string
      - `description` string
      - `category` string
      - `available` string
    - `response_guide` object
      - `category` string
      - `immediateActions` string[]
      - `resources` object[]
        - `name` string
        - `description` string
        - `url` string

---

[API](https://skmtc.dev/tuteliq/apis/tuteliq-api.md) · [All operations](https://skmtc.dev/tuteliq/apis/tuteliq-api/llms.txt) · [OpenAPI document](https://skmtc.dev/tuteliq/apis/tuteliq-api/revisions/8490332e0031?raw)
