/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
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"
}
}
}