[jsonloupe — a loupe for large JSON: lossless, local, fast]
[CI]
[OpenSSF Scorecard]
[npm]
[jsonloupe MCP server – quality and maintenance score on Glama]
[OpenSSF Best Practices]
[Mozilla Observatory grade]
[License: MIT]
[TypeScript strict]
[Local-first]
[Lossless int64]
[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.]
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, rebuilt from
main on every merge — or run the latest npm release from your own machine,
fully offline:
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. --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) and the CI gate
(check, below); 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
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.)
- 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.)
- 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.
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:
-
Filter to the failed rows:
Result: one match, $.tasks[0].
-
Count rows by status:
Result: FAILED 1, READY 1.
-
Compare an integer beyond JavaScript's safe range without rounding it:
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 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:
comes out as two sheets rather than one row full of items/0/sku columns:
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:
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:
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:
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:
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.
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:
An agent gets the same gate from the MCP server's run_checks
(below), 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 server, so an
agent can work on a document that would never fit in its context:
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'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:
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
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
(document.modelContext — a Chrome origin trial from Chrome 149, and the
ChatGPT desktop app's built-in browser), 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.
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. The corresponding
threat model, trust boundaries, and assurance argument are in
SECURITY-ASSURANCE.md.
Deploying
jsonloupe builds to a fully static site:
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
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
Start with CONTRIBUTING.md. Architecture and internals are
documented in ARCHITECTURE.md and SPEC.md; the
converter has its own SPEC-converter.md, including what it
deliberately refuses to do. Project decisions and the next twelve months are in
GOVERNANCE.md and ROADMAP.md. Official publishing
and consumer verification are documented in RELEASING.md. What
changed in each release is in CHANGELOG.md.
License
MIT. Bundled third-party packages are listed in
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.