Advanced

API server

Other programs on this Mac can read and change what PasteDaemon holds, over HTTP on the loopback address. Off until you switch it on, and guarded by a key even then.

The Advanced pane holds the local API and nothing else. It is a pane of its own rather than a section of General because of what it switches on: everything in General is about how the app behaves for the person using it, and this opens a door in it.

  • Allow other programs to use PasteDaemon, off until you switch it on. Starting the server generates a key if there is not one already, since a server listening with nothing that can reach it would look broken from every side.
  • Port, 28787 by default and anything from 1024 to 49151. The default is up where nothing else lives: the 8000s are the obvious place for a small local server and are crowded accordingly. Below 1024 macOS refuses an unprivileged process outright, and above 49151 is the range macOS hands out to outgoing connections — a port there would be free on one launch and taken on the next. Applying a port restarts the listener, and the old socket is released rather than held for a minute, so changing it works at once.
  • A status line saying whether it is actually listening, and why not when it is not. The pane waits to be told the listener is up rather than assuming it.
  • The API key, shown masked with Show, Copy, Generate and Revoke beside it.
  • A curl line to copy, an mcp add line beside it, and a link to the documentation. Both copy with the key already in them.

Copying the key puts it on the pasteboard directly rather than through the monitor: a key is the one thing that must not land in the clipboard history, where the API it opens would then hand it to anyone who asked for the history. Revealing it is a decision rather than the default, and the reveal is dropped when the pane goes away — a preferences window left open on a desk should not have a working credential on it.

Where it listens

http://127.0.0.1:<port>, bound to the loopback address and nowhere else. There is no setting that changes this, and the binding is what enforces it rather than a check on the way in: a clipboard history is the most personal thing the app holds, and an API over it that could be pointed at 0.0.0.0 by a line in a config file is one that will be, on somebody's café Wi-Fi, once.

Enough HTTP is implemented to be a local API and no more — no chunked encoding, no content negotiation, one request per connection — because what is on the other end is curl, a script, or the explorer page the app serves. A request head is capped at 64 KB and a body at 8 MB, both refused with a 413 rather than buffered.

The key

Every request under /v1 carries x-api-key. Authorization: Bearer <key> is accepted as well, for the tools that send only that.

curl -H "x-api-key: …" http://127.0.0.1:28787/v1/history?limit=5

Being on loopback is not authentication. Every process running as you can reach the port, and so can any page open in your browser. The key is what actually guards the history, so it is checked before the path is looked at, and the answer carries Access-Control-Allow-Origin: null — a page that got hold of the port without the key still cannot read the reply. The key is compared in constant time.

There is one key at a time, kept in the login keychain rather than in config.json, which is plain text in a folder every process running as you can read. Generating another revokes the one before it from the next request onward. With no key generated at all the API answers 503 and says where to make one, rather than refusing quietly.

What it will not do

Secrets are not here. A variable whose name begins with ! is not listed, not readable, not writable and not deletable through the API, and a request for one is answered exactly as a request for a name that does not exist — "there is no such variable" and "there is a secret by that name" are two different things to tell somebody holding a key they should not have. Opening a secret needs Touch ID or the login password, which is somebody at the Mac saying so; an HTTP request has nobody behind it. Writing to a ! name is refused out loud, because the caller chose the name and what they need to be told is where to set it instead.

Nothing runs. No call evaluates a pipeline, fires a trigger or executes a script action. A request that could run $1 | script would be a way to run arbitrary shell on the Mac with one header. Changing the rules through the API changes what runs when you copy something, which is a decision that stays with the keyboard.

Images are described but not served. A history of screenshots handed back as base64 would make every response enormous; image.file names the file in the images folder for a caller who genuinely wants the bytes.

The calls

PathWhat it is for
GET /v1/statusWhat the app is and what it holds. The cheapest way to know the key works
GET, PUT /v1/clipboardThe newest entry and any sequence in flight; putting text on the clipboard
GET, PUT /v1/recordingWhether copies are being kept
GET, DELETE /v1/historyThe history, searched and filtered; clearing it
GET, PATCH, DELETE /v1/history/{id}One entry: reading it, changing its value or pin, dropping it
GET /v1/history/{id}/versionsWhat that entry used to hold
GET, POST /v1/pins, DELETE /v1/pins/{id}The pins, and pinning or unpinning
GET /v1/variablesEvery variable but the secrets
GET, PUT, DELETE /v1/variables/{name}One variable, by name without its sigil
GET, DELETE /v1/variables/{name}/historyIts past values, and dropping them
POST /v1/variables/{name}/sequenceDeals the list out to the clipboard, one element per paste
GET, POST /v1/actions, GET, DELETE /v1/actions/{keyword}The rules engine's actions. Also reachable as /v1/rules
GET, POST /v1/triggers, GET, DELETE /v1/triggers/{id}What fires on a copy
GET, POST /v1/abbreviations, GET, DELETE /v1/abbreviations/{id}What expands as it is typed

HEAD is answered as the GET with its body thrown away, and a path that exists answered with a method it does not take is a 405 carrying Allow — a caller that cannot tell that from a 404 spends a long time checking their spelling.

Searching the history matches what the overlay matches on, so an image is found by what Vision read in it. It is a plain case-insensitive substring by default, which is what a script wants; fuzzy=true switches to the overlay's own matcher, for a caller building something a person types into.

What is added is checked before it is stored, since each of these is a rule that would otherwise fail quietly for as long as it stayed: a substitute action needs a pattern and the pattern has to compile, a trigger needs a condition and an action that exists, an abbreviation needs an expansion and cannot hold a line break, and a keyword already taken by one of your own actions is a 409 rather than a second definition that silently shadows the first. Actions that ship with the app answer 403 to a delete — the standard library is rebuilt at every launch, so removing one would last exactly until the next.

A clear keeps the pins unless pins=true says otherwise, the way the app's own Clear History does. Nothing narrows a bulk delete but olderThan: one that could take a q is one somebody will run with the wrong q.

Pagination

Every list is paginated, including the ones that are usually short, so that one piece of client code reads them all. limit defaults to 50 and is clamped to 200 rather than refused; offset counts from 0.

Every list answers with items beside a pagination envelope of total, offset, limit, count, hasMore and nextOffset — which is absent at the end, so a loop can run until it is gone. total counts what matched after filtering, which is what makes paging through a search work.

When something goes wrong

Failures are JSON with error, message and status at the top level, so jq -r .message always says something worth reading and a logged body stands alone.

The documentation

GET /docs is an interactive explorer and GET /openapi.json is the schema it renders. Both are readable without a key — the key is not to hand until somebody has read the page saying where to find it, and neither holds any of your data.

The page carries nothing with it. No script, no stylesheet and no font is fetched from anywhere: the app is meant to work with no network at all, and a documentation page that pulled a script off a CDN would be a clipboard history handing an outside script the key you just typed into it.

It renders the schema rather than repeating it, so the words in the explorer and the words in the JSON cannot drift apart. Each operation opens to its parameters, the shape of what it answers with, a form to send one, and the equivalent curl line; the key typed into the header is kept in the browser's local storage and never travels anywhere but back to this app.

The schema is written by hand rather than generated from the handlers. A generated document says what the code does; this one says what the API promises, which is the thing a caller is entitled to rely on and the thing that has to stay still when the code moves.