jsonloupe
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset=".github/assets/banner-dark.svg">
<img src=".github/assets/banner-light.svg" width="620" alt="jsonloupe — a loupe for large JSON: lossless, local, fast">
</picture>
</p>
<p align="center">
<a href="https://github.com/priyanshuN/jsonloupe/actions/workflows/ci.yml"><img src="https://github.com/priyanshuN/jsonloupe/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://scorecard.dev/viewer/?uri=github.com/priyanshuN/jsonloupe"><img src="https://api.scorecard.dev/projects/github.com/priyanshuN/jsonloupe/badge" alt="OpenSSF Scorecard"></a>
<a href="https://www.npmjs.com/package/jsonloupe"><img src="https://img.shields.io/npm/v/jsonloupe.svg" alt="npm"></a>
<a href="https://glama.ai/mcp/servers/priyanshuN/jsonloupe"><img src="https://glama.ai/mcp/servers/priyanshuN/jsonloupe/badges/score.svg" alt="jsonloupe MCP server – quality and maintenance score on Glama"></a>
<a href="https://www.bestpractices.dev/projects/13920"><img src="https://www.bestpractices.dev/projects/13920/badge" alt="OpenSSF Best Practices"></a>
<a href="https://developer.mozilla.org/en-US/observatory/analyze?host=jsonloupe.dev"><img src="https://img.shields.io/mozilla-observatory/grade/jsonloupe.dev" alt="Mozilla Observatory grade"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
<a href="tsconfig.json"><img src="https://img.shields.io/badge/TypeScript-strict-3178c6.svg" alt="TypeScript strict"></a>
<a href="SECURITY.md"><img src="https://img.shields.io/badge/local--first-no%20backend%2C%20no%20telemetry-2ea44f.svg" alt="Local-first"></a>
<a href="#why-another-json-viewer"><img src="https://img.shields.io/badge/numbers-int64%20exact-8a2be2.svg" alt="Lossless int64"></a>
</p>
<p align="center">
<a href="https://raw.githubusercontent.com/priyanshuN/jsonloupe/main/.github/assets/demo.gif">
<img src=".github/assets/demo.gif" width="820" alt="Demo: the sample opens unfolded with exact int64 ids, the command palette finds compare, the comparison opens on the changed values, a local query runs, the document previews as linked spreadsheet tables, and it encodes as Base64-Zstd. Click to view full size." />
</a>
</p>
jsonloupe is a browser-based workbench for inspecting, diffing, editing, and
querying JSON documents that are too big or too precise for ordinary viewers —
and for turning them into spreadsheets: nested JSON goes in, linked tables come
out, as one `.xlsx` or a zip of CSVs. The questions you ask of every file can be
kept as checks in a playbook: one press judges them all over the open document,
and the same file gates CI through `jsonloupe check`. Nothing leaves your
machine: in the browser, documents are parsed in a web worker, stored in
IndexedDB, and never uploaded anywhere. No backend, no account, no telemetry.
## Run it
Use the hosted app at **[jsonloupe.dev](https://jsonloupe.dev)**, rebuilt from
`main` on every merge — or run the latest npm release from your own machine,
fully offline:
```bash
npx jsonloupe
```
That starts a loopback-only static server (`127.0.0.1:5199`, or the next free
port) serving the prebuilt app from the package — no runtime dependencies, no
network calls, one auditable file of about 200 lines with its comments:
[bin/jsonloupe.mjs](bin/jsonloupe.mjs). `--port <n>` and `--no-open` do what
they say; `--key-file <path>` serves a model key from that file to the page,
over loopback only. The same command carries the converter's verbs (`inspect`,
`draft`, `convert`, [below](#nested-json-to-linked-tables)) and the CI gate
(`check`, [below](#in-ci-jsonloupe-check)); `npx jsonloupe --help` lists them.
## Why another JSON viewer?
- **Lossless numbers.** Int64 IDs beyond 2^53 (snowflake/order/entity IDs) and
precise decimals survive paste → view → edit → copy → download with their exact
digits. Nothing is ever coerced to a float.
- **Built for huge documents.** The tree is virtualized and expansion is chunked
(`[0 … 9999]` range rows), so opening a five-million-element array is instant.
Parsing, diffing, search, and compression all run off the UI thread.
- **Compressed payloads are first-class.** Paste or drop raw `.zst`, Base64-Zstd
(standard or URL-safe), or PostgreSQL `\x…` bytea — it's detected, decoded in
the worker, and opened as a document with a transformation trace. An encoder
and a transport-size inspector (exact UTF-8 / Zstd / Base64 / envelope bytes
against editable budgets) round-trip the other way.
- **Nested JSON becomes a spreadsheet, on your machine.** Repeating arrays and
object maps become their own linked tables — `orders` and `order_items` joined
by `order_id`, never `items/0/sku` columns. You see real rows before anything
is written, and the mapping saves as a small file that produces the same
columns from next month's file, with no model involved.
- **Semantic diff.** Side-by-side compare with identity-based array alignment
(match by `id`, composite keys, or auto-detected), so reordered arrays don't
drown you in false changes. Ignore noisy fields by key or path prefix.
- **A real query language.** A JSONPath subset with predicates and aggregation
pipes (`$.tasks[?(@.status == 'FAILED')] | group(@.failureReason)`), executed
locally with live preview as you type. Numeric literals, predicates, sums,
averages, minima, and maxima remain exact for int64 and precise decimals.
- **Checks you can gate on.** Keep the questions you ask of every file as
checks — a query and the count it should come to — and one press judges them
all, naming the first paths behind each failure. A count it could not finish
is *inconclusive*, never a pass it has not earned. The same playbook file
gates CI through `jsonloupe check` and an agent through MCP's `run_checks`.
## Quick start
```sh
git clone https://github.com/priyanshuN/jsonloupe && cd jsonloupe
npm ci
npm run dev # http://localhost:5199
```
Paste JSON on the landing page — it parses instantly and is saved to local
memory (Recents sidebar: reopen, pin, delete). Malformed input (trailing commas,
single quotes, Python literals, truncated log extracts) is auto-repaired and
flagged, with the original bytes preserved.
## Feature tour
- **Tree / Code / Split views** — virtualized tree, editable CodeMirror code
view (fold, search, apply-with-`⌘S`), or both side by side with click-to-line
sync. Inline-edit primitives in the tree; full undo/redo across edits. A small
document opens unfolded to fill the screen, and scrolling deep keeps the
parent keys you are inside pinned at the top of the tree.
- **Command palette** — `⌘K` / `Ctrl K` from anywhere: type a few letters of a
view, an action, a theme or a recent document's name, and press Enter.
- **Colour schemes** — Tokyo Night, Catppuccin Mocha, Dracula, Gruvbox and
GitHub Dark for dark; Solarized Light, Catppuccin Latte, Rosé Pine Dawn and
GitHub Light for light; plus jsonloupe's own pair. Hover one in the sidebar picker (or arrow through them
in `⌘K`) to try it on the whole app. Each mode keeps its own scheme, so
"follow the system" swaps between your two at sunset. Every scheme meets
WCAG AA contrast, checked in CI.
- **Search & filter** — `/` to search (literal or `/regex/i`), filter mode
prunes the tree to matching branches and restores your expansion state after.
- **Path & identity** — breadcrumb with one-click copy as JSONPath, JSON
Pointer, or JS accessor; "same value" jumps to every node holding a value
(schema-free correlation-ID tracing).
- **Value lenses** — epoch → your local date and "3 days ago", the same
relative time on ISO timestamps, a colour chip on colour strings,
latitude/longitude, uuid/url/base64 flags, and byte weight on collapsed
containers so the heavy node is findable at a glance.
- **Tables & CSV** — any array gets a sortable table view; export exact-digit
CSV (RFC 4180) of tables or query results.
- **Playbook — your checks and functions on one sheet** — a check is a query
and the count it should come to (none, at most 3 groups, between 1 and 10);
`run all` judges every one over the open document and points at the first
nodes behind each failure. A function is JavaScript for the questions no
query language should have to answer, run in a sandbox with the network
removed; tick several and one press answers all of them as a single report.
The same checks gate CI through `jsonloupe check`.
([below](#playbooks-your-functions-and-checks-as-a-file).)
- **JSON → Excel / CSV converter** — rename and reorder columns, choose source
fields, add constants, set date and coordinate handling, take the column names
from a target CSV header, and save or share the mapping for the next file.
([below](#nested-json-to-linked-tables).)
- **File handles** — drop `.json`/`.jsonl`/`.zst` files; reload re-reads from
disk and shows what changed since the version you were looking at.
- **Query** — run JSON queries locally, save useful ones, or turn a result into
a reusable check. Optional **English → query** mode translates a question using
**only the document's shape (field names/types — never values)**, then stops
so you can review the generated query before running it locally. Bring your
own connection: authorize with OpenRouter without pasting a key, or use the
advanced manual-key option — paste a key, or load it from a local file (a raw
key or a `.env` line, read in the page and never uploaded). Until you
explicitly connect, the feature is inert.
A disclosure panel records exactly what was sent. See [SECURITY.md](SECURITY.md).
### Browser query cookbook
Open a document, press **query**, leave **JSON query** selected, and paste a
query. It runs directly in this tab: no API key and nothing sent to a model.
Given this small document:
```json
{"tasks":[
{"id":9007199254740992,"status":"FAILED"},
{"id":9007199254740993,"status":"READY"}
]}
```
- Filter to the failed rows:
```text
$.tasks[?(@.status == 'FAILED')]
```
Result: one match, `$.tasks[0]`.
- Count rows by status:
```text
$.tasks[*] | group(@.status)
```
Result: `FAILED 1`, `READY 1`.
- Compare an integer beyond JavaScript's safe range without rounding it:
```text
$.tasks[?(@.id > 9007199254740992)] | count
```
Result: `1` — only the exact `9007199254740993` id is greater.
## Nested JSON to linked tables
If a spreadsheet is the only thing you came for,
[jsonloupe.dev/json-to-excel.html](https://jsonloupe.dev/json-to-excel.html) is
that job on a page of its own: paste, convert, done.
Open a document and press the table glyph in the toolbar's tools group
(**convert**; every glyph there names itself on hover). Every repeating array becomes its own
table, joined back to its parent by the id that was already in the data — so
this:
```json
{ "orders": [
{ "id": 7, "cust": "ACME",
"items": [ { "sku": "A", "qty": 2 }, { "sku": "B", "qty": 1 } ] } ] }
```
comes out as two sheets rather than one row full of `items/0/sku` columns:
```
orders order_items
id cust order_id sku qty
7 ACME 7 A 2
7 B 1
```
jsonloupe finds those tables for you but never runs the guess blindly: the
mapping and a preview of real rows stay on screen while you rename columns,
choose which fields to keep, and say how dates and coordinates should be read.
What you approve is saved as a small mapping file — keep it in this browser,
export it, or use it from the command line:
```bash
jsonloupe inspect payload.json # what tables are in here?
jsonloupe draft payload.json -o payload.spec.json # a first mapping, to read
jsonloupe convert payload.json --spec payload.spec.json -o payload.xlsx
```
The same mapping produces the same columns through the browser, the CLI, and the
MCP server, with no model in the loop — that is the point of freezing it. What
lands in the cells is what was in the document: dates arrive as real dates you
can sort and subtract, numbers and coordinates as numbers, and text that only
looks numeric stays text, so `"1.10"` keeps its trailing zero and an int64 id
keeps every digit instead of being rounded. Excel's limits and values a column
cannot read are reported or refused, never quietly written wrong.
## Playbooks: your functions and checks as a file
The document changes daily; the handful of questions you ask of it does not. So
**playbook** in the toolbar (the **functions** view before 1.8; `⌘K` still finds
it by that word) opens them beside the document, as one sheet: your
**checks** — a query and the count it should come to — and your **functions**,
each list in name order. `run all` (`⌘⇧↵` / `Ctrl ⇧↵` while the playbook is
open; press it again to stop) measures every check over whatever is open and
paints each row's verdict in place, then runs the ticked functions.
The first row that did not pass opens by itself, showing the first nodes it
matched; press a path and the tree beside the sheet selects that node, while
the sheet stays where it is.
### Checks
Press **+ check** in the sheet, or turn a Query result into one with **save as
check**. A check counts matches — groups for `| group`, distinct values for
`| distinct` — and passes when the count meets its expectation:
| Pass when | `expectation` | Playbook version |
|---|---|---|
| no matches are found | `{"type": "no-matches"}` | 3 |
| at least one match is found | `{"type": "at-least-one"}` | 3 |
| exactly N are found | `{"type": "exact-count", "count": N}` | 3 |
| at most N are found | `{"type": "at-most", "count": N}` | 4 |
| at least N are found (N ≥ 1) | `{"type": "at-least", "count": N}` | 4 |
| between M and N are found, both included | `{"type": "between", "min": M, "max": N}` | 4 |
A count is a whole number within JavaScript's safe range; one past it is
refused, never rounded. A query that ends in `| sum`, `| avg`, `| min` or
`| max` yields one value rather than a count, so a check on it says it could
not run instead of judging a number it was never meant to count.
Every check comes back as **✓ pass**, **✕ fail**, **? inconclusive**, or **✗
could not run**. Inconclusive is the honest third answer: `| group` and
`| distinct` keep at most 100,000 groups or values (the cardinality cap). Past
that, the count is only a lower bound: at least 100,001. A lower bound passes
only when it proves the expectation by itself (*at least one*, or *at least N*
already reached), fails when it is already over the limit (*none*, or *at most
100,000*), and is inconclusive otherwise — it is never reported as a pass it
has not earned. Narrowing the query decides it.
A check whose query names a field the open document does not have says so on
its row, and its panel adds "this reads `status` — this document has none of
that, so a pass here may prove nothing". A *no matches* check passes just as
well on a file whose field was renamed.
A saved check keeps one last verdict, with up to five matched paths and a short
preview of each. It belongs to the document the check last ran on: run it on
another document and that run replaces it. Reopen the document it ran on and
the verdicts are still there; edit it and they fade, marked "document edited",
until you run again. That record stays in this browser: it is never written
into an exported playbook, and removing the document removes it.
### Functions
A function learns what it reads. The first time one runs it is handed the
document through a recording proxy, and the paths it actually touched are kept
with it. That is what lets the sheet say
> this reads `orders`, `orders[].deliveredInHours` — this document has none of that
*before* you press run, instead of leaving you to read an empty result as "none
today". It is always a remark and never a gate: the reading knows only the branch
that run took, so the run button still works. Tick several functions and
`run all` answers all of them, as a single object keyed by function name.
**open report** in the functions head shows it: today's report, which
downloads and reopens as a document like any other. A function's result lasts
while that document stays open. Open another document, or reload, and its row
says it has not run. Which functions are ticked is remembered in this browser.
Scripts run in an ephemeral worker with `fetch`, `XMLHttpRequest`, WebSockets,
IndexedDB, `importScripts` and nested workers removed before any user code, terminated on its
result or after ten seconds (a ticked batch shares those ten seconds, and one
that runs past them is stopped whole) — a pasted script cannot reach the
network or the documents you have opened. `JavaScript` number mode preserves the behavior of
existing functions and says when a document contains values it will round.
`exact text` mode instead hands those unsafe literals to the function as their
original digit strings (`"9007199254740993"`); ordinary values such as `3` and
`1.5` remain numbers, so normal counting and arithmetic still work. The mode is
saved and exported with the function rather than left as a browser preference.
### The file
A playbook is that reusable work as a file — the questions and expectations,
never the data:
```json
{
"playbookVersion": 4,
"name": "carrier dumps",
"functions": [
{
"name": "slow orders",
"script": "data.orders.filter(o => o.deliveredInHours > 48)",
"reads": ["orders", "orders[].deliveredInHours"],
"numberMode": "exact-text"
}
],
"checks": [
{
"name": "no failed orders",
"query": "$.orders[?(@.status == 'FAILED')]",
"expectation": { "type": "no-matches" }
},
{
"name": "a full day of orders",
"query": "$.orders[*]",
"expectation": { "type": "at-least", "count": 1000 }
},
{
"name": "a handful of carriers",
"query": "$.orders[*] | group(@.carrier)",
"expectation": { "type": "between", "min": 1, "max": 5 }
}
]
}
```
**export** in the playbook bar writes one, as version 3 unless a check needs
version 4: a library with no `at-most`, `at-least` or `between` check exports
as version 3, which jsonloupe 1.4 to 1.7 read. **import**, or dropping the file
on the window, adds what it holds. Import **merges and never replaces** — a
name you already use keeps yours and the incoming one lands as `slow orders 2`,
because a duplicate is one **forget** away and an overwrite is not. Unknown fields are refused by name rather than dropped, so
a file from a newer jsonloupe cannot import as a subset of itself and look like
it worked. The library keeps 100 functions and 100 checks: saving a 101st is
refused with a message saying so, and a playbook that would not fit is refused
whole, rather than pushing your oldest ones out. Version 1, 2 and 3 playbooks
still import; version 1 functions keep their original JavaScript-number mode.
### In CI: `jsonloupe check`
The same checks gate a pipeline. `jsonloupe check` runs a playbook's checks
against one or more files with the browser's own engine, and exits non-zero
when one fails:
```bash
npx -y jsonloupe@^1.8.0 check carrier-dumps.playbook.json out/orders.json
```
```
out/orders.json · 3 checks · 4 ms
✕ no failed orders — Fail — 2 matches; expected no matches
first: $.orders[312]
$.orders[877]
✓ a full day of orders — Pass — 1,204 matches; expected at least 1,000 matches
✓ a handful of carriers — Pass — 3 groups; expected between 1 and 5 groups
1 function not run — functions run only in the browser
✕ 1 failed · ✓ 2 passed
```
Keep the `@^1.8.0`. jsonloupe before 1.8 has no `check` verb: it starts the
viewer's local server instead, which never exits, so the job hangs until the
runner times it out. From 1.8 on, a word jsonloupe does not know exits 1 rather
than starting anything.
The playbook comes first, then any number of files: `.json`, `.jsonl`/`.ndjson`,
raw Zstd, or a Base64-Zstd / bytea payload, detected as they are in the
browser. A file that is not valid JSON fails the gate rather than being repaired
behind your back; `--allow-repair` checks the repaired document instead. Every
query is checked before any file is read, so a typo in the last check costs no
parsing. Functions are JavaScript and never run outside the browser's sandbox,
so `check` counts them and says it did not run them. The text report prints
matched paths and never values, since CI logs are often public. A path still
names the keys it passes through, so on a document keyed by e-mail address or
account id, pass `--no-evidence`. `--format json` writes one object, with a
short preview of each matched node, for a program to read; `--max-evidence N`
(0–100, default 3) or `--no-evidence` sets how many paths a check that did not
pass names.
| Exit status | Meaning |
|---|---|
| 0 | every check passed on every file |
| 1 | usage error, a file or playbook that could not be read, or a file that is not valid JSON — including one that would need repairing |
| 2 | the playbook is not valid JSON or not a playbook, has no checks or more than 100 (what a library holds), or has a query no check can count; nothing was checked |
| 3 | at least one check failed |
| 4 | nothing failed, but a check was inconclusive or could not run |
With several files the most actionable status wins: 3, then 1, then 4. Undecided
has its own status so a pipeline can tolerate it on purpose
(`… || test $? -eq 4`).
In GitHub Actions, `--format github` adds annotations after the text report: one
on the playbook for each check that did not pass — a warning when it was
inconclusive, an error otherwise — and an error on each file it could not check,
or on the playbook when it is invalid:
```yaml
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
# … the steps that write out/orders.json
- run: npx -y jsonloupe@^1.8.0 check carrier-dumps.playbook.json out/orders.json --format github
```
An agent gets the same gate from the MCP server's `run_checks`
([below](#use-with-ai-agents)), with a playbook's path or the checks inline. It
refuses a document that is not valid JSON in the same way; `allowRepair: true`
checks the repaired document and says so.
## Use with AI agents
The same engine runs as an [MCP](https://modelcontextprotocol.io) server, so an
agent can work on a document that would never fit in its context:
```bash
claude mcp add jsonloupe -- npx -y -p jsonloupe jsonloupe-mcp
```
**If you have jq and plain JSON, use jq.** This is for the cases jq is awkward
about: compressed payloads (`.zst`, Base64-Zstd, PostgreSQL `\x…` bytea) that
have to be decoded first, int64 and decimal digits that must survive the whole
pipeline exactly, identity-keyed semantic diff between two documents, and MCP
hosts that have no shell to run jq in.
Twelve tools (`run_checks` from 1.8): `load_doc`, `get_schema`, `run_query`,
`profile`, `sample`, `diff_docs`, `export_csv`, `export_result`, plus `inspect`,
`draft_spec`, and `convert` for the spreadsheet path above, and `run_checks`,
which gates a document on a
[playbook](#playbooks-your-functions-and-checks-as-a-file)'s checks and never
runs its functions. For one question, pass `filePath` straight to `get_schema`,
`run_query`, `profile`, `sample`, `export_csv`, `export_result` or
`run_checks`; it opens the document and returns a reusable `docId` in the same
call. For a sequence of questions, and for `diff_docs` and the converter tools,
which take a `docId`, call `load_doc` once and reuse that `docId`. Up to eight
documents stay open; past that the least recently used is closed, and the next
response says so. Query details default to 10 rows (use `limit=0` for summary only, or
`offset` + `limit` to page), and text and structured MCP results are both
bounded. A typical run over a 37 MB routing payload:
```
run_query filePath=payload.json "$.tasks[?(@.status == 'FAILED')] | count"
→ docId: d1 · count: 17500
profile d1 "$.tasks[*]" → auto-discovered field coverage, null/missing/types,
exact sums/stats, lengths, distincts and top values
run_query d1 "$.tasks[?(@.status == 'FAILED')] | group(@.region, @.failureReason)"
→ composite breakdown, exact complete counts
run_query d1 "$.tasks[*] | top(@.delayMinutes, @.id, @.status)" limit=5
→ only the five highest rows; the full array never enters context
sample d1 "$.tasks[0]" → the whole element, id 9007199254740993 intact
export_result d1 "…| pluck(@.id, @.failureReason)" format=csv outPath=failed.csv
→ complete, atomic, rows + UTF-8 bytes only
inspect d1 → candidate tables, fields, types, deterministic suggestions
draft_spec d1 outPath=tasks.spec.json
→ reviewable mapping on disk
convert d1 specPath=tasks.spec.json outPath=tasks.xlsx
→ workbook path + row/skipped/warning counts, never its rows
run_checks d1 playbookPath=tasks.playbook.json
→ pass, fail or inconclusive per check (or could not run);
a failed or inconclusive check names its first paths
($.tasks[3]); functions counted, never run
```
The common schema → profile → query loop is deliberately cheaper than loading
JSON or writing a one-off Python script: counts and aggregates stream over every
match without materializing the result list; `present`, `missing`, and `isNull`
keep false/null/absent distinct; composite grouping and bounded `top`/`bottom`
replace local sort scripts; and profiles inspect multiple or automatically
discovered fields in one scan. Complete CSV/JSONL exports stream to a temporary
file and publish atomically. Existing paths are refused unless the agent passes
`overwrite=true` explicitly.
The server makes no network calls and accesses only explicitly supplied
input/output paths. A bad query returns the grammar and a suggestion rather than
an empty result. The reproducible [agent-choice evaluation](docs/agent-choice-eval.md)
measures whether an agent selects these operations when Python is also available.
If an agent knows the MCP is installed but still writes one-off scripts, put
this routing rule in its project instructions (for example `AGENTS.md` or
`CLAUDE.md`):
> For small plain JSON, use jq when it is already the shortest safe option.
> Otherwise prefer the JsonLoupe MCP over ad-hoc Python whenever it supports the
> operation; use custom shell code only when JsonLoupe cannot answer it.
### In the browser: WebMCP
Where the browser exposes [WebMCP](https://webmachinelearning.github.io/webmcp/)
(`document.modelContext` — a Chrome origin trial from Chrome 149, and the
ChatGPT desktop app's built-in browser), [jsonloupe.dev](https://jsonloupe.dev)
registers the same bounded verbs pointed at the document already on screen, so a
browser agent and the person watching share one document instead of mailing
copies back and forth. Eight tools: `run_query`, `get_schema`, `profile` and
`sample` work as they do over stdio, and four act on the viewer itself, which no
headless server can do: `load_doc` opens a document in it, `reveal_path` scrolls
the tree to a row and flashes it, and `highlight_matches`/`clear_highlights`
drive the toolbar's own filter state — visible, counted, and reversible by the
human's own button. Every tool that
returns document content is annotated `untrustedContentHint`: values are data,
never instructions. Browsers without the API fetch nothing and run the same app
they always did. Details, testing instructions, and provenance in
[WEBMCP.md](WEBMCP.md).
## Privacy & security
Documents never leave your machine. The complete network-call inventory (four
`fetch` calls, all in opt-in model connection or English query translation), the shape-only LLM contract, and
the XSS posture are documented in [SECURITY.md](SECURITY.md). The corresponding
threat model, trust boundaries, and assurance argument are in
[SECURITY-ASSURANCE.md](SECURITY-ASSURANCE.md).
## Deploying
jsonloupe builds to a fully static site:
```sh
npm run build # dist/
```
Serve `dist/` from any static host (GitHub Pages, Cloudflare Pages, Netlify…).
The app needs nothing server-side to work. To match jsonloupe.dev's
protection, set the response headers in [SECURITY-HEADERS.md](SECURITY-HEADERS.md)
at the host — above all the Run sandbox's own CSP on `/assets/run-sandbox-*.js`,
without which a pasted script's `import()` can reach any host. The local
`/__api-key` convenience endpoint (dev mode, or `npx jsonloupe --key-file`)
simply doesn't exist in a static deploy, and English translation activates only
when a visitor connects their own model account or key.
## Development
```sh
npm run dev # Vite on :5199, with the loopback-only /__api-key endpoint
npm test # vitest — engine, worker, converter, playbook, CLI, MCP, WebMCP, NL and DOM suites
npm run lint:security # FLOSS security analysis + checked TypeScript style
npm run lint:contract # the UI contract in src/style.css: tokens, contrast, motion, type ramp
npm run lint:headers # every source file carries the MIT SPDX and copyright notice
npm run coverage # same suite with enforced 90% statement / 80% branch floors
npm run build # type checks + vite build + the MCP and CLI bundles
npm run build:mcp # or build:cli — one target's type check and bundle only
npm run test:a11y # real Chromium: axe, keyboard/focus, playbook and WebMCP journeys (run after build)
npm run check:site-headers # verify the live OpenSSF hardening headers
npm run check:reproducible-build # clean locked installs must produce identical bytes
npm run smoke:mcp # drives the built MCP server over real stdio against a 37 MB fixture (run after build)
npm run eval:agent -- --help # black-box MCP-vs-Python agent-choice benchmark
npm run eval:ask -- --dry-run # Ask correctness/injection suite; --dry-run costs nothing
```
Start with [CONTRIBUTING.md](CONTRIBUTING.md). Architecture and internals are
documented in [ARCHITECTURE.md](ARCHITECTURE.md) and [SPEC.md](SPEC.md); the
converter has its own [SPEC-converter.md](SPEC-converter.md), including what it
deliberately refuses to do. Project decisions and the next twelve months are in
[GOVERNANCE.md](GOVERNANCE.md) and [ROADMAP.md](ROADMAP.md). Official publishing
and consumer verification are documented in [RELEASING.md](RELEASING.md). What
changed in each release is in [CHANGELOG.md](CHANGELOG.md).
## License
[MIT](LICENSE). Bundled third-party packages are listed in
[THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md).
Software is provided "as is", without warranty of any kind — see the license
text. If you connect a model for English queries, your use of the configured
LLM provider is governed by that provider's terms.
TDQS
Scored across 12 tools
Most tools target distinct operations (load, query, checks, profile, sample, export, convert), and descriptions explicitly cross-reference alternatives. However, get_schema/inspect/profile/sample/run_query have some overlapping schema/value/statistics territory, and export_csv is a near-duplicate shortcut of export_result, so an agent must read carefully to choose.
All names use snake_case and are readable, with most following verb_noun (load_doc, run_query, get_schema, export_result). Occasional single-verb/noun names (inspect, convert, profile, sample) are minor deviations but still understandable.
12 tools is appropriate for a JSON analysis/query/conversion/export surface; each capability (load, schema, query, checks, profile, sample, export, diff, conversion) has a clear role. No excessive duplication or trivial single-tool thinness.
The surface covers loading, schema discovery, querying, validation checks, profiling, sampling, diffing, exporting, and table conversion. For an analysis-focused JSON tool, this provides complete lifecycle coverage without obvious dead ends.