perma.cc
Archives a URL to a permanent, court-citable snapshot. The whole skill exists
because one HTTP 400 means three different things, and the message names
only the least likely one.
HTTP 400 "You've reached your usage limit."
│
├── folder sent as ?query= → ignored, billed to PERSONAL quota
├── no folder at all → billed to PERSONAL quota
└── genuinely unsponsored → the only case the message describes
CLI
PC="${CLAUDE_SKILL_DIR}/scripts/permacc.py"
uv run --script "$PC" status # who the key is, and whether a sponsored folder exists
uv run --script "$PC" folders # every folder, with the sponsored one marked
uv run --script "$PC" archive URL... --auto-folder
uv run --script "$PC" archive --from-json inventory.json --out archives.json --auto-folder
uv run --script "$PC" verify --out archives.json # re-check captures on an existing record
uv run --script "$PC" delete --out archives.json --failed --private-if-undeletable
--auto-folder resolves the sponsored folder itself, which is the option to
reach for; --folder <id> pins one when an account has several. --out makes
the run resumable — already-archived URLs are skipped, so a rate limit or a
network drop costs nothing on the retry.
The key is read from --api-key, then PERMACC_API_KEY, then
PERMACC_API_KEY_FILE (the agenix convention — the environment carries a
path to the decrypted secret, not the secret).
Facts
folder must be a JSON BODY field, not a query parameter. ?folder=376861
is accepted, silently ignored, billed against the personal quota, and 400s
once that quota is spent. Verified side by side on one URL against a
sponsored account: query param → 400, body field → 201. Nothing in the
response distinguishes "ignored your folder" from "you are out of links",
so this reads as a plan problem and sends people to the pricing page.
Sponsorship hangs off the FOLDER, not the organization. GET /v1/organizations/ returned registrar: None for an account that was
already sponsored; the affiliation lives on a folder in GET /v1/user/
carrying registrar: 16 / registrar_name: "University of Virginia School of Law Arthur J. Morris Law Library". Diagnosing a cap from the
organizations endpoint concludes "not sponsored" about a sponsored account.
The folder list is top_level_folders, not folders. A wrong key
returns [], which this skill's own first draft reported as "no sponsored
folder — ask a registrar to add you." The failure mode of guessing a
response key here is a confident false negative, not an error.
The auth scheme is ApiKey, not Bearer. A Bearer header authenticates
as anonymous and fails later with a permission error rather than a 401, so
the traceback points at the wrong thing.
The free personal tier is 10 links/month. A cite-check script with no
folder therefore works for the first ten footnotes and dies on the
eleventh — the shape of bug that looks like a flaky API.
HTTP 201 means a link was MINTED, not that the page was fetched. The two
come apart on any site that blocks crawlers. SSRN is behind Cloudflare, so
every SSRN archive returns 201 and then captures the challenge page:
captures[role=primary].status == "failed", title set to ssrn.com
instead of a paper title. Measured on one manuscript: 22 of 28 captured,
and all 6 failures were SSRN. A perma link resolving to an interstitial is
worse than the live URL — it looks archived and is not, and nothing in the
create response says so. archive now polls the capture (capture is async,
so the status right after 201 is pending and a single check would call a
good archive bad); verify re-checks an existing record.
Some sites cannot be perma'd at all, and that is the right answer. For
SSRN, cite the live URL: an abstract_id is a permanent identifier that
SSRN does not recycle, so the rot perma exists to prevent barely applies.
Law reviews routinely print SSRN URLs unarchived for working papers.
A perma link is deletable for 24 HOURS and permanent after that. Past
the window DELETE returns 403, and the remedy is is_private: true,
which unpublishes it without removing it. So a cleanup that assumes DELETE
works half-succeeds on any set spanning more than a day — the normal shape
of a manuscript's archive set. Observed: of 6 bad links, the 4 made that day
deleted (204) and the 2 from six months earlier did not.
private_reason is a closed enum the API will not enumerate. A wrong
value 400s with a message that lists no alternatives, so it has to be
guessed. user is the one meaning "I no longer want this public".
Archiving is not idempotent. Two POSTs for one URL make two perma links.
Pass --out and let the tool skip what it already has, rather than
re-running a loop and quietly doubling a manuscript's archive set.
Red flags — STOP
| Action |
Why wrong |
Do instead |
| About to report "your perma account is out of links" from a 400 |
Three causes share that message; only one is a real cap |
Run folders, then retry with --auto-folder |
About to check sponsorship with /v1/organizations/ |
It reports registrar: None for sponsored accounts |
Read top_level_folders from /v1/user/ |
About to pass the folder as ?folder= |
Silently ignored, billed to the personal quota |
Send it in the JSON body (the script does) |
About to loop requests.post over a URL list |
No resume, no dedupe; a mid-run failure double-archives on retry |
archive --from-json … --out … |
| About to tell a user to buy a plan |
A law library registrar gives faculty unlimited links free |
Have them added to the registrar's org first |
| About to record a 201 as "archived" |
201 mints a link; the capture can still fail, and SSRN always does |
Let archive verify, or run verify --out |
About to script DELETE over a set of links |
Only links under 24h old delete; older ones 403 and need is_private |
delete --failed --private-if-undeletable |
About to paste the key into a script or .env |
It is a long-lived credential |
agenix (see below), read via PERMACC_API_KEY_FILE |
Storing the key
~/nix/add-api-keys.sh permacc-api-key # encrypts to ~/nix-secrets, prints the wiring
Then in modules/shared/home-secrets.nix: an age.secrets entry, a
PERMACC_API_KEY_FILE session variable, and a get-permacc-api-key alias.
nix-secrets is a flake input pinned by revision, so the secret must be
pushed and the lock bumped (nix flake update nix-secrets) before a rebuild
can see it.
Getting a sponsored account
Perma registrars are institutions — mostly law libraries and courts. A faculty
member or student at one gets unlimited links at no cost by asking to be added
to the institution's perma.cc organization. That is the fix for a cap, and it
is usually a single email. Until it lands, folders prints [personal ] for
every row and every archive spends the 10/month allowance.
1---2name: permacc3description: ALWAYS use when URLs in a manuscript need permanent archived copies — 'perma these links', 'archive the URLs in my footnotes', 'add perma links', 'the journal wants perma.cc cites', 'make sure these links don't rot', 'archive this site before it changes', 'snapshot these URLs', 'perma.cc', or when cite-checking a draft with web sources. ALSO use when perma returns 'you've reached your usage limit' despite an institutional account, or when asking which perma folder an account may archive into.4---56# perma.cc78Archives a URL to a permanent, court-citable snapshot. The whole skill exists9because **one HTTP 400 means three different things**, and the message names10only the least likely one.1112```13HTTP 400 "You've reached your usage limit."14 │15 ├── folder sent as ?query= → ignored, billed to PERSONAL quota16 ├── no folder at all → billed to PERSONAL quota17 └── genuinely unsponsored → the only case the message describes18```1920## CLI2122```bash23PC="${CLAUDE_SKILL_DIR}/scripts/permacc.py"2425uv run --script "$PC" status # who the key is, and whether a sponsored folder exists26uv run --script "$PC" folders # every folder, with the sponsored one marked27uv run --script "$PC" archive URL... --auto-folder28uv run --script "$PC" archive --from-json inventory.json --out archives.json --auto-folder29uv run --script "$PC" verify --out archives.json # re-check captures on an existing record30uv run --script "$PC" delete --out archives.json --failed --private-if-undeletable31```3233`--auto-folder` resolves the sponsored folder itself, which is the option to34reach for; `--folder <id>` pins one when an account has several. `--out` makes35the run resumable — already-archived URLs are skipped, so a rate limit or a36network drop costs nothing on the retry.3738The key is read from `--api-key`, then `PERMACC_API_KEY`, then39`PERMACC_API_KEY_FILE` (the agenix convention — the environment carries a40*path* to the decrypted secret, not the secret).4142## Facts4344- **`folder` must be a JSON BODY field, not a query parameter.** `?folder=376861`45 is accepted, silently ignored, billed against the personal quota, and 400s46 once that quota is spent. Verified side by side on one URL against a47 sponsored account: query param → 400, body field → **201**. Nothing in the48 response distinguishes "ignored your folder" from "you are out of links",49 so this reads as a plan problem and sends people to the pricing page.5051- **Sponsorship hangs off the FOLDER, not the organization.** `GET52 /v1/organizations/` returned `registrar: None` for an account that was53 already sponsored; the affiliation lives on a folder in `GET /v1/user/`54 carrying `registrar: 16` / `registrar_name: "University of Virginia School55 of Law Arthur J. Morris Law Library"`. Diagnosing a cap from the56 organizations endpoint concludes "not sponsored" about a sponsored account.5758- **The folder list is `top_level_folders`, not `folders`.** A wrong key59 returns `[]`, which this skill's own first draft reported as "no sponsored60 folder — ask a registrar to add you." The failure mode of guessing a61 response key here is a confident false negative, not an error.6263- **The auth scheme is `ApiKey`, not `Bearer`.** A Bearer header authenticates64 as anonymous and fails later with a permission error rather than a 401, so65 the traceback points at the wrong thing.6667- **The free personal tier is 10 links/month.** A cite-check script with no68 folder therefore works for the first ten footnotes and dies on the69 eleventh — the shape of bug that looks like a flaky API.7071- **HTTP 201 means a link was MINTED, not that the page was fetched.** The two72 come apart on any site that blocks crawlers. SSRN is behind Cloudflare, so73 every SSRN archive returns 201 and then captures the challenge page:74 `captures[role=primary].status == "failed"`, `title` set to `ssrn.com`75 instead of a paper title. Measured on one manuscript: **22 of 28 captured,76 and all 6 failures were SSRN.** A perma link resolving to an interstitial is77 worse than the live URL — it looks archived and is not, and nothing in the78 create response says so. `archive` now polls the capture (capture is async,79 so the status right after 201 is `pending` and a single check would call a80 good archive bad); `verify` re-checks an existing record.8182- **Some sites cannot be perma'd at all, and that is the right answer.** For83 SSRN, cite the live URL: an `abstract_id` is a permanent identifier that84 SSRN does not recycle, so the rot perma exists to prevent barely applies.85 Law reviews routinely print SSRN URLs unarchived for working papers.8687- **A perma link is deletable for 24 HOURS and permanent after that.** Past88 the window `DELETE` returns **403**, and the remedy is `is_private: true`,89 which unpublishes it without removing it. So a cleanup that assumes DELETE90 works half-succeeds on any set spanning more than a day — the normal shape91 of a manuscript's archive set. Observed: of 6 bad links, the 4 made that day92 deleted (204) and the 2 from six months earlier did not.9394- **`private_reason` is a closed enum the API will not enumerate.** A wrong95 value 400s with a message that lists no alternatives, so it has to be96 guessed. `user` is the one meaning "I no longer want this public".9798- **Archiving is not idempotent.** Two POSTs for one URL make two perma links.99 Pass `--out` and let the tool skip what it already has, rather than100 re-running a loop and quietly doubling a manuscript's archive set.101102## Red flags — STOP103104| Action | Why wrong | Do instead |105|---|---|---|106| About to report "your perma account is out of links" from a 400 | Three causes share that message; only one is a real cap | Run `folders`, then retry with `--auto-folder` |107| About to check sponsorship with `/v1/organizations/` | It reports `registrar: None` for sponsored accounts | Read `top_level_folders` from `/v1/user/` |108| About to pass the folder as `?folder=` | Silently ignored, billed to the personal quota | Send it in the JSON body (the script does) |109| About to loop `requests.post` over a URL list | No resume, no dedupe; a mid-run failure double-archives on retry | `archive --from-json … --out …` |110| About to tell a user to buy a plan | A law library registrar gives faculty unlimited links free | Have them added to the registrar's org first |111| About to record a 201 as "archived" | 201 mints a link; the capture can still fail, and SSRN always does | Let `archive` verify, or run `verify --out` |112| About to script `DELETE` over a set of links | Only links under 24h old delete; older ones 403 and need `is_private` | `delete --failed --private-if-undeletable` |113| About to paste the key into a script or `.env` | It is a long-lived credential | agenix (see below), read via `PERMACC_API_KEY_FILE` |114115## Storing the key116117```bash118~/nix/add-api-keys.sh permacc-api-key # encrypts to ~/nix-secrets, prints the wiring119```120121Then in `modules/shared/home-secrets.nix`: an `age.secrets` entry, a122`PERMACC_API_KEY_FILE` session variable, and a `get-permacc-api-key` alias.123`nix-secrets` is a flake input **pinned by revision**, so the secret must be124pushed *and* the lock bumped (`nix flake update nix-secrets`) before a rebuild125can see it.126127## Getting a sponsored account128129Perma registrars are institutions — mostly law libraries and courts. A faculty130member or student at one gets unlimited links at no cost by asking to be added131to the institution's perma.cc organization. That is the fix for a cap, and it132is usually a single email. Until it lands, `folders` prints `[personal ]` for133every row and every archive spends the 10/month allowance.