in-amber
v0.8.1
Published
Save any web page as a clean, self-contained offline folder — deterministic capture, judgement by Claude.
Maintainers
Readme
Amber turns a URL into a folder you own: a single index.html with every image, stylesheet, font, and video pulled local, and the clutter — ads, cookie banners,
trackers, newsletter popups, analytics scripts — stripped away. Open it in ten
years with the network unplugged and it still just works.
The mechanical half (render, download, rewrite, package) is deterministic
TypeScript. The judgement half — what's junk? what's the real content? which
embed is a video worth keeping? — is handled by Claude and written out as a
plan.json you can read, edit, and replay.
Features
- 📦 One self-contained folder per page —
index.html+ a localassets/tree, every reference rewritten to a relative path. - 🧹 De-junked — ads, cookie/consent banners, share bars, newsletter popups, and tracking scripts removed; the page's own structure (header, nav, footer, bylines) kept.
- 🔌 Genuinely offline — scripts and connection/preload hints (
preconnect,modulepreload,prefetch, …) are stripped, so an opened archive makes no background requests. (Keep-js archives patch the network APIs instead: recorded data replays offline, unrecorded requests fail synthetically.) - 🎥 Real media, not dead embeds — a YouTube/Vimeo embed becomes a downloaded local
<video>(via yt-dlp); self-hosted clips are localised too. - 🧠 Claude does the judgement — and writes it to an auditable
plan.jsonyou can edit and re-apply. - 🏷️ Auto-tagged — Claude reads the page and adds topical tags to the manifest for later browsing and search.
- 🌐 Handles JS-rendered pages — headless Chromium (Playwright) capture, used only when a page actually needs it.
- 🫀 Preserves living pages — when a page's presentation is its JavaScript (WebGL scenes, scroll choreography), Claude escalates to keep-js mode: the page's own code is kept and bundled, the browsing session's data is recorded and replayed, and the archive collapses into one self-contained
index.htmlthat plays offline. See Living pages. - 🕵️ An escalation path for stubborn pages —
amber agenthas Claude clean the page interactively, tool call by tool call, when the one-shot plan isn't enough.
Quick start
npm install -g in-amber
export ANTHROPIC_API_KEY=... # optional — falls back to heuristics without it
amber https://example.com/some-post(Or without installing: npx in-amber <url>.)
A run narrates every decision it makes — nothing is interactive, everything is overridable next time:
[1/4] Fetching https://example.com/some-post (static probe)
2841 chars of visible text — static capture is enough
[2/4] Asking Claude for a cleanup plan
[3/4] Removing junk, then downloading assets and rewriting references
removed 12 elements; 31 assets, 0 errors
[4/4] Downloading embedded media
1 media item(s)
Archive written to: ~/Documents/Archives/example.com-some-post/
open ~/Documents/Archives/example.com-some-post/index.htmlArchives land in ~/Documents/Archives/<slug>/ by default (override with -o
or AMBER_ARCHIVE_DIR). Run amber doctor to see what your setup can do — every
optional piece degrades gracefully:
ANTHROPIC_API_KEY— without it, cleanup uses heuristics instead of Claude's judgement. With it, you pay per archive on your own key: typically a few cents per page (one planning call; the HTML sent is capped at 400k characters, so even a huge page tops out around $0.30).- Playwright — only needed for JS-rendered pages; without it, amber captures
what a plain fetch can see. Opt in with
npm install -g playwright && playwright install chromium. yt-dlp— needed to download embedded media (brew install yt-dlporpipx install yt-dlp);ffmpegis optional, for muxing separate video+audio streams.
Usage
The bare command is the sensible default: it fetches statically, escalates to a headless render only if the page looks client-rendered, and uses Claude when a key is set. The flags below only force a choice.
amber <url> # auto capture + Claude if a key is set
amber --no-llm <url> # heuristics only, never call the model
amber --static <url> # force a plain HTTP fetch (never boot Chromium)
amber --playwright <url> # force a headless-Chromium render
amber --plan plan.json <url> # replay a saved plan
amber --overwrite <url> # replace the latest snapshot, keep no history
amber -o ~/somewhere <url> # choose the output directory
amber --keep-js <url> # force keep-js mode (see Living pages below)
amber --no-keep-js <url> # never keep JS, even if the plan recommends it
amber --at 2009-06 <url> # the page as it WAS, via the Wayback Machine (see below)
amber --at latest <url> # the most recent Wayback capture — for dead sites
amber https://web.archive.org/web/20090615/http://example.com/ # same thing, from a Wayback URL
amber agent <url> # Claude cleans interactively — for pages the pipeline gets wrong
amber serve <slug-or-path> # view an archive over localhost (folder-layout keep-js archives)
amber doctor # check the environment: key, Playwright, esbuild, yt-dlp, ffmpegWhen an archive comes out wrong
The cleanup step uses Claude's judgement by defult, and sometimes Claude gets things wrong. Amber has a few options for handling this, each trading more time and cost for more care:
- Read the judgement.
plan.jsonin the archive folder is the complete plan that was applied: what was removed, what was kept, which embeds were treated as real media. - Edit it and replay. Delete the selector that ate your sidebar — or add
one for the popup that survived — then re-run with
amber --plan plan.json <url>. Deterministic and free; no model involved. - Escalate to the agent.
amber agent <url>swaps the single planning call for Claude working the page interactively: outline the DOM, inspect anything ambiguous, remove junk step by step, download media, finalize. Slower and costlier (many model calls instead of one), but it handles pages the one-shot plan mangles. NeedsANTHROPIC_API_KEY(no heuristic fallback) and Playwright; best on a machine you own, where it has direct network egress and your browser's cookies for yt-dlp. Details inagent/README.md.
Living pages (keep-js)
Most pages read perfectly as static HTML, and stripping their scripts is what makes an archive private and permanent. But for some pages the experience is the JavaScript — a WebGL hero, scroll-driven film choreography, a generative art piece.
For these, amber has keep-js mode. By default Claude decides per page: the
plan carries a preserveRuntime judgement, and when it's true the pipeline re-captures in keep-js mode
automatically. --keep-js forces it on; --no-keep-js forbids it.
What it does:
- Keeps the page's own code, drops the surveillance. Analytics/consent/ad scripts are still removed; the app bundle survives.
- Flattens module scripts into one classic script (esbuild), because
browsers refuse to load ES modules from a double-clicked
file://page. - Records the browsing session during the render and replays it offline through a small shim that
patches
fetch/XMLHttpRequestand remaps runtime-constructed asset URLs. Randomness is seeded identically at capture and replay, so pages that randomise at boot make the same choices. Frame sequences (f_001.webp,f_002.webp, …) are completed by fetching the gaps the render didn't hit. - Collapses to a single
index.htmlwith every asset inlined as adata:URI. This is what makes canvas and WebGL work from a double-clicked file —data:resources are same-origin, so nothing hits the browser'sfile://taint rules. Archives whose assets exceed ~200MB keep the folder layout instead and get a double-clickableView archive.commandthat serves them on localhost (or useamber serve).
The contract: the recorded session works offline; behaviour beyond it is
best-effort. Data behind interactions the capture never performed isn't in
the archive. Keep-js needs ANTHROPIC_API_KEY (for the judgement — or force
it with --keep-js), Playwright, and esbuild (npm i -g esbuild).
Historical versions (Wayback Machine)
Amber can archive a page as it was, from the Internet Archive's Wayback Machine, and file it as if you'd captured it then:
amber --at 2009-06 http://example.com/ # nearest capture to June 2009
amber --at latest http://gone.example.com/ # a dead site's last capture
amber https://web.archive.org/web/20090615/http://example.com/Any web.archive.org URL works as the target — paste one from your browser, or
hit the extension's button while looking at a Wayback page — and --at takes
a year, month, day, or a 14-digit Wayback stamp. Wayback snaps to the nearest
capture it has (which can be months away for a quiet URL; the run says which
capture it actually served).
What makes this clean is Wayback's raw mode: amber fetches the capture's original bytes — no toolbar, no injected scripts, no rewritten URLs — and routes every asset download through the archive at the same timestamp. The result is an ordinary amber archive of the 2009 page: filed under the original URL's slug, cleaned by the same plan, tagged, thumbnailed.
The timeline is the point. A Wayback capture's effective date is the snapshot's
date (snapshotAt in the manifest; capturedAt stays the real run time), so
archiving today's page and then --at 2009 of the same URL gives one slug
whose root is the newest version and whose versions/ holds 2009. The library
index has one row per slug, dated by its newest version and counting the rest;
a page whose newest capture came from Wayback (a dead site) shows that
snapshot's date with a "wayback" mark. Old pages also tend to be Latin-1,
Shift_JIS or EUC-JP; amber decodes by the declared charset (or a sensible
default) and writes the archive as UTF-8.
Wayback captures are static by default. Add --keep-js (or let Claude's plan
decide) and amber instead renders the historical page in Chromium at its
original URL with every request the browser makes answered from the
archive — so the era's JavaScript runs against the era's assets, and a 2010
carousel spins in the archive the way it did in 2010. Re-running a historical
capture with --overwrite replaces that version in place (never the root).
archive.org is fetched politely throughout — sequentially, spaced out, with
backoff — so a keep-js render of an asset-heavy old page takes a few minutes.
What you get
~/Documents/Archives/example.com-some-post/
├── index.html # cleaned, faithful to the original — always the newest capture
├── assets/
│ ├── images/ # every image, favicon, srcset entry
│ ├── static/ # css, fonts
│ └── media/ # videos/audio — self-hosted files + yt-dlp downloads
├── thumbnail.jpg # viewport screenshot, for the library index
├── plan.json # the cleanup judgement that was applied (auditable, replayable)
├── manifest.json # source URL, capture time, topical tags, asset list, errors, what was removed
└── versions/ # older snapshots (after you re-archive, or backfill via Wayback), each a full archive
└── 20260102T090000Z/ # … named by when the snapshot is FROM, with its own index.html + assets + manifestHistory over time
Re-archiving a URL keeps the old copy. The newest capture stays at <slug>/, and
the previous one rotates into <slug>/versions/<timestamp>/ — each version is a
complete, self-contained archive you can open on its own. The folders are the
history; there's no index to maintain.
An identical re-capture is detected (by a content hash that ignores timestamps)
and skipped, so a page that hasn't changed doesn't pile up duplicate snapshots.
Pass --overwrite to replace the latest in place and keep no history. Wayback
captures slot into the same timeline by their snapshot date: one older than the
current latest is filed into versions/ and the root is left alone.
How it works
- Capture — render the page: static fetch, or headless Chromium when it's client-rendered.
- Plan —
claude-sonnet-4-6reads the raw page and returns a structured plan: main-content selector, junk selectors, embedded media to download, topical tags, and apreserveRuntimejudgement (does the presentation need its JS? → keep-js mode). A heuristic fallback runs with--no-llmor if the API call fails. - Clean & localise — remove junk by selector and unconditionally strip
anything a static copy must never keep (scripts, preload/prefetch/connection
hints) before downloading, so bytes referenced only by junk are never
fetched. Then walk the surviving DOM and rewrite every loaded reference —
<link>,<img src/srcset>,<source>,<video>/<audio>, inline styles, andurl(...)inside<style>blocks and CSS files (recursively, so web-fonts and background images come too) — to a local path, and finally swap embedded media for locally downloaded files. - Package — write
index.html,plan.json, andmanifest.json.
Development
git clone https://github.com/chasemccoy/amber && cd amber
pnpm install
pnpm exec playwright install chromium # one-time, for the headless-render backend
pnpm archive <url> # run the CLI from source (tsx, no build step)
pnpm test # deterministic unit tests — no key, no network, no browser
pnpm typecheck # tsc --noEmit
pnpm evals # judgement suite (see evals/README.md)
pnpm build # tsup → dist/ (what npm installs; bin/amber.js wraps dist/cli.js)Limitations
- Media downloads need a real network. yt-dlp on a datacenter IP hits YouTube's anti-bot wall, and TLS-intercepting proxies break cert verification. Opt into a trusted proxy with
--insecure-tls/AMBER_INSECURE_TLS=1. - No ffmpeg → no stream muxing. Without it, set
AMBER_MEDIA_FORMATto a progressive single-file format. - Interactive
<iframe>embeds can't be made offline — CAD viewers, live web apps, and the like stay pointed at the original. - The plan is advisory, not infallible — which is exactly why it's written to disk: read it, edit it, re-run with
--plan. - Some SPA routers refuse
file://. An app that matcheslocation.pathnameagainst its routes sees a filesystem path from a double-clicked file, and may hard-navigate away. Those archives work over a localhost origin instead —View archive.commandoramber serve. (Worker-based WebGL engines — Draco/Basis decoding, WASM physics — do work: the shim translates URL spaces and replays recorded responses to them.) - Keep-js preserves the recorded session, not the live service. Content behind interactions the capture never performed is absent; apps that render from per-user server data will replay the captured session's data. Bundles using top-level
awaitor truly dynamicimport(expr)can't be flattened and fall back to a static archive (noted in the manifest). - Single-file archives trade open-time for portability — a ~190MB
index.htmltakes a few seconds to parse on open. The launcher fallback needs Node on the machine (any static server works too:python3 -m http.server).
Credits
Inspired by Alex Chan's A personal archive of the web, which describes doing this by hand. Amber automates the mechanical parts and hands the judgement to Claude.
