API reference

Inspect and count text

Exact counts of characters, bytes, words and lines; find substrings; reveal invisible characters.

GET POST /api/text/inspect MCP tool text_inspect

Counts text the ways software disagrees about: grapheme clusters (what people see as characters), code points, UTF-16 code units (JavaScript length) and UTF-8 bytes; lines with their line-ending style; words and sentences (Intl.Segmenter); and letters, digits, whitespace and punctuation by Unicode property. With find, counts occurrences of a substring or character and returns each position (e.g. find=r in strawberry is 3). It also reports invisible and deceptive characters with positions: zero-width characters, bidirectional controls (Trojan Source, CVE-2021-42574), unusual spaces such as NBSP, BOM, control characters, Unicode tag characters (hidden-prompt smuggling), and Latin look-alikes in mixed-script words. Use this instead of counting characters yourself.

Browser version (runs locally): Text Inspector.

Try it

Credentials
Remember these credentials

Not saved: you will paste them again next time.

No credentials yet? Get a client ID and secret. They are sent only to this site, in an Authorization: Basic header.

POST /api/text/inspect with a JSON body

Parameters

Parameter Type Default Description
input required string (≤ 20000 chars) — The text, up to 20,000 UTF-16 code units. Sent as-is: no trimming or normalization.
find string (≤ 1000 chars) — A substring or single character to count and locate. Matched literally (not a regex) and without Unicode normalization.
case_sensitive boolean true Whether find is case-sensitive. When false, Unicode simple case folding is used (R matches r).
breakdown integer (0–2000) 0 Return a per-code-point breakdown (U+XXXX, UTF-8 hex, category, script) of the first N code points. 0 (default) for none; at most 2000.
locale string (≤ 64 chars) — BCP 47 locale for word and sentence segmentation, e.g. en, th, ja. Default: the runtime default.

Also accepted on every endpoint: format (json | text). Parameters may come from the query string, a JSON object body, an urlencoded or multipart form, or (for input) a text/plain body.

Global parameters
Parameter Type Default Description
format json | text json json returns the documented object. text returns only the primary value as text/plain (see "Text output").

Response 200 application/json

Field Type Description
locale string Locale used by Intl.Segmenter after resolution.
counts object
counts.graphemes integer Extended grapheme clusters (UAX #29): what a person counts as characters.
counts.codePoints integer Unicode code points (a lone surrogate counts as one).
counts.utf16CodeUnits integer UTF-16 code units: JavaScript length, Java length().
counts.utf8Bytes integer Bytes when encoded as UTF-8 (lone surrogates as U+FFFD, 3 bytes).
counts.words integer Word segments with isWordLike (letters/numbers/ideographs; not punctuation or spaces).
counts.sentences integer Sentence segments containing a non-whitespace character.
counts.lines integer Lines: 0 for empty text; a final line break does not start a new line.
counts.lineBreaks integer LF + CRLF + CR line breaks (CRLF counts once).
counts.nonEmptyLines integer Lines with at least one character.
lineEndings object
lineEndings.style "none" | "LF" | "CRLF" | "CR" | "mixed" none, LF, CRLF, CR, or mixed.
lineEndings.lf integer Bare LF count.
lineEndings.crlf integer CRLF count.
lineEndings.cr integer Bare CR count.
lineEndings.endsWithLineBreak boolean The text ends with LF, CRLF or CR.
characterClasses object Counts of code points by Unicode property (emoji counts grapheme clusters).
characterClasses.letters integer \p{L}
characterClasses.uppercase integer \p{Lu}
characterClasses.lowercase integer \p{Ll}
characterClasses.marks integer \p{M} (combining marks, including variation selectors)
characterClasses.digits integer \p{Nd} (decimal digits in any script)
characterClasses.numbers integer \p{N} (digits plus letter-like and other numerals)
characterClasses.punctuation integer \p{P}
characterClasses.symbols integer \p{S}
characterClasses.whitespace integer \p{White_Space} (includes line breaks and tabs)
characterClasses.spaces integer \p{Zs} (space separators, including NBSP)
characterClasses.control integer \p{Cc} (includes tab, LF, CR)
characterClasses.format integer \p{Cf} (invisible format characters)
characterClasses.emoji integer Grapheme clusters containing an \p{Extended_Pictographic} character.
normalization object
normalization.isNFC boolean The text is unchanged by NFC normalization.
normalization.isNFD boolean The text is unchanged by NFD normalization.
normalization.isNFKC boolean The text is unchanged by NFKC normalization.
occurrences object | null
occurrences.find string The searched string.
occurrences.caseSensitive boolean Whether case was significant.
occurrences.count integer Non-overlapping matches, scanning left to right.
occurrences.overlappingCount integer Matches starting at every code point (e.g. "aa" in "aaa" is 2).
occurrences.positions array of object Position of each non-overlapping match (first 1000).
occurrences.positions[].match string The matched text (differs from find only in case).
occurrences.positions[].splitsGrapheme boolean The match starts or ends inside a grapheme cluster (e.g. e in e + U+0301).
occurrences.positions[].utf16Index integer 0-based offset in UTF-16 code units (JavaScript string index).
occurrences.positions[].codePointIndex integer 0-based offset in Unicode code points.
occurrences.positions[].graphemeIndex integer 0-based index of the grapheme cluster (user-perceived character).
occurrences.positions[].line integer 1-based line number (lines end at LF, CRLF or CR).
occurrences.positions[].column integer 1-based column, counted in code points.
occurrences.truncated boolean More than 1000 matches; positions lists the first ones.
invisible object
invisible.total integer Total findings, including those beyond the list limit.
invisible.byKind map of integer Findings per kind (kinds with zero are omitted).
invisible.findings array of object Invisible, bidirectional, control and look-alike characters in text order (first 1000).
invisible.findings[].kind "bom" | "zero-width" | "bidi-control" | "bidi-mark" | "unusual-space" | "control" | "line-separator" | "tag" | "variation-selector" | "invisible-format" | "invisible-letter" | "replacement" | "lone-surrogate" | "private-use" | "confusable" Category of the finding.
invisible.findings[].codePoint string U+XXXX.
invisible.findings[].name string Unicode character name where known, else empty.
invisible.findings[].looksLike string | null For confusable: the ASCII character it imitates.
invisible.findings[].utf16Index integer 0-based offset in UTF-16 code units (JavaScript string index).
invisible.findings[].codePointIndex integer 0-based offset in Unicode code points.
invisible.findings[].graphemeIndex integer 0-based index of the grapheme cluster (user-perceived character).
invisible.findings[].line integer 1-based line number (lines end at LF, CRLF or CR).
invisible.findings[].column integer 1-based column, counted in code points.
invisible.truncated boolean More than 1000 findings.
invisible.bidiUnterminatedLines array of integer Lines where a bidi embedding, override or isolate is still open at the end (Trojan Source, CVE-2021-42574).
invisible.mixedScriptWords array of object Words mixing Latin with Cyrillic, Greek or Armenian letters.
invisible.mixedScriptWords[].word string The word.
invisible.mixedScriptWords[].scripts array of string Scripts mixed in the word.
invisible.mixedScriptWords[].utf16Index integer Start offset in UTF-16 code units.
invisible.mixedScriptWords[].line integer 1-based line.
breakdown object | null
breakdown.entries array of object One entry per code point.
breakdown.entries[].codePointIndex integer 0-based code point index.
breakdown.entries[].utf16Index integer 0-based UTF-16 offset.
breakdown.entries[].graphemeIndex integer Grapheme cluster this code point belongs to.
breakdown.entries[].char string The character itself.
breakdown.entries[].codePoint string U+XXXX.
breakdown.entries[].utf8 string UTF-8 bytes as space-separated uppercase hex.
breakdown.entries[].utf16 string UTF-16 code units as space-separated uppercase hex.
breakdown.entries[].category string Unicode General_Category, e.g. Lu, Ll, Nd, Zs, Cf.
breakdown.entries[].script string | null Unicode Script property, e.g. Latin; null if not one of the scripts checked.
breakdown.entries[].name string | null Unicode name for controls, spaces and invisible characters; null otherwise.
breakdown.truncated boolean The text has more code points than were broken down.
warnings array of string Security-relevant findings and anomalies.

Errors

Errors are JSON: {"error": "<message>", "code": "<code>", "param": "<name>"?}.

Status Code When
400 invalid_parameter locale is not a valid BCP 47 tag, find is empty, breakdown is out of range, or input is longer than 20,000 UTF-16 code units (split it and add the counts).
400 missing_parameter A required parameter is absent.
400 unknown_parameter A parameter name is not in the table above (typos are rejected, not ignored).
413 payload_too_large The request body exceeds 1 MiB.
405 method_not_allowed A method other than GET, POST or OPTIONS.

Behaviour

  • Idempotent: the same arguments always return the same result.
  • Requires a client ID and secret (authentication). Each call is recorded as one metadata row against your client ID; inputs and outputs are never stored or logged. Responses carry cache-control: no-store.
  • CORS: any origin may call this endpoint from a browser, using your credentials.

Examples

Responses below were produced by running each request through this code at build time. Long strings are shortened.

How many r’s in strawberry?

curl 'https://util.caruana.me/api/text/inspect?input=strawberry&find=r'

Response 200

{
  "locale": "en-US",
  "counts": {
    "graphemes": 10,
    "codePoints": 10,
    "utf16CodeUnits": 10,
    "utf8Bytes": 10,
    "words": 1,
    "sentences": 1,
    "lines": 1,
    "lineBreaks": 0,
    "nonEmptyLines": 1
  },
  "lineEndings": {
    "style": "none",
    "lf": 0,
    "crlf": 0,
    "cr": 0,
    "endsWithLineBreak": false
  },
  "characterClasses": {
    "letters": 10,
    "uppercase": 0,
    "lowercase": 10,
    "marks": 0,
    "digits": 0,
    "numbers": 0,
    "punctuation": 0,
    "symbols": 0,
    "whitespace": 0,
    "spaces": 0,
    "control": 0,
    "format": 0,
    "emoji": 0
  },
  "normalization": {
    "isNFC": true,
    "isNFD": true,
    "isNFKC": true
  },
  "occurrences": {
    "find": "r",
    "caseSensitive": true,
    "count": 3,
    "overlappingCount": 3,
    "positions": [
      {
        "match": "r",
        "splitsGrapheme": false,
        "utf16Index": 2,
        "codePointIndex": 2,
        "graphemeIndex": 2,
        "line": 1,
        "column": 3
      },
      {
        "match": "r",
        "splitsGrapheme": false,
        "utf16Index": 7,
        "codePointIndex": 7,
        "graphemeIndex": 7,
        "line": 1,
        "column": 8
      },
      {
        "match": "r",
        "splitsGrapheme": false,
        "utf16Index": 8,
        "codePointIndex": 8,
        "graphemeIndex": 8,
        "line": 1,
        "column": 9
      }
    ],
    "truncated": false
  },
  "invisible": {
    "total": 0,
    "byKind": {},
    "findings": [],
    "truncated": false,
    "bidiUnterminatedLines": [],
    "mixedScriptWords": []
  },
  "breakdown": null,
  "warnings": []
}
Same call as an MCP tools/call
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "text_inspect",
    "arguments": {
      "input": "strawberry",
      "find": "r"
    }
  }
}

Emoji, accents and a hidden zero-width space

curl -X POST 'https://util.caruana.me/api/text/inspect' \
  -H 'content-type: application/json' \
  -d '{"input":"Café 👍🏽​ ok","breakdown":8,"locale":"en"}'

Response 200

{
  "locale": "en",
  "counts": {
    "graphemes": 10,
    "codePoints": 11,
    "utf16CodeUnits": 13,
    "utf8Bytes": 20,
    "words": 2,
    "sentences": 1,
    "lines": 1,
    "lineBreaks": 0,
    "nonEmptyLines": 1
  },
  "lineEndings": {
    "style": "none",
    "lf": 0,
    "crlf": 0,
    "cr": 0,
    "endsWithLineBreak": false
  },
  "characterClasses": {
    "letters": 6,
    "uppercase": 1,
    "lowercase": 5,
    "marks": 0,
    "digits": 0,
    "numbers": 0,
    "punctuation": 0,
    "symbols": 2,
    "whitespace": 2,
    "spaces": 2,
    "control": 0,
    "format": 1,
    "emoji": 1
  },
  "normalization": {
    "isNFC": true,
    "isNFD": false,
    "isNFKC": true
  },
  "occurrences": null,
  "invisible": {
    "total": 1,
    "byKind": {
      "zero-width": 1
    },
    "findings": [
      {
        "kind": "zero-width",
        "codePoint": "U+200B",
        "name": "ZERO WIDTH SPACE",
        "looksLike": null,
        "utf16Index": 9,
        "codePointIndex": 7,
        "graphemeIndex": 6,
        "line": 1,
        "column": 8
      }
    ],
    "truncated": false,
    "bidiUnterminatedLines": [],
    "mixedScriptWords": []
  },
  "breakdown": {
    "entries": [
      {
        "codePointIndex": 0,
        "utf16Index": 0,
        "graphemeIndex": 0,
        "char": "C",
        "codePoint": "U+0043",
        "utf8": "43",
        "utf16": "0043",
        "category": "Lu",
        "script": "Latin",
        "name": null
      },
      {
        "codePointIndex": 1,
        "utf16Index": 1,
        "graphemeIndex": 1,
        "char": "a",
        "codePoint": "U+0061",
        "utf8": "61",
        "utf16": "0061",
        "category": "Ll",
        "script": "Latin",
        "name": null
      },
      {
        "codePointIndex": 2,
        "utf16Index": 2,
        "graphemeIndex": 2,
        "char": "f",
        "codePoint": "U+0066",
        "utf8": "66",
        "utf16": "0066",
        "category": "Ll",
        "script": "Latin",
        "name": null
      },
      {
        "codePointIndex": 3,
        "utf16Index": 3,
        "graphemeIndex": 3,
        "char": "é",
        "codePoint": "U+00E9",
        "utf8": "C3 A9",
        "utf16": "00E9",
        "category": "Ll",
        "script": "Latin",
        "name": null
      },
      {
        "codePointIndex": 4,
        "utf16Index": 4,
        "graphemeIndex": 4,
        "char": " ",
        "codePoint": "U+0020",
        "utf8": "20",
        "utf16": "0020",
        "category": "Zs",
        "script": "Common",
        "name": "SPACE"
      },
      {
        "codePointIndex": 5,
        "utf16Index": 5,
        "graphemeIndex": 5,
        "char": "👍",
        "codePoint": "U+1F44D",
        "utf8": "F0 9F 91 8D",
        "utf16": "D83D DC4D",
        "category": "So",
        "script": "Common",
        "name": null
      },
      {
        "codePointIndex": 6,
        "utf16Index": 7,
        "graphemeIndex": 5,
        "char": "🏽",
        "codePoint": "U+1F3FD",
        "utf8": "F0 9F 8F BD",
        "utf16": "D83C DFFD",
        "category": "Sk",
        "script": "Common",
        "name": null
      },
      {
        "codePointIndex": 7,
        "utf16Index": 9,
        "graphemeIndex": 6,
        "char": "​",
        "codePoint": "U+200B",
        "utf8": "E2 80 8B",
        "utf16": "200B",
        "category": "Cf",
        "script": "Common",
        "name": "ZERO WIDTH SPACE"
      }
    ],
    "truncated": true
  },
  "warnings": []
}
Same call as an MCP tools/call
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "text_inspect",
    "arguments": {
      "input": "Café 👍🏽​ ok",
      "breakdown": 8,
      "locale": "en"
    }
  }
}