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.

No script of yours runs. POST /v1/run runs pipelines and actions, but a request that could reach a script you wrote would be a way to run any shell on the Mac with one header — and the API can write actions. So it runs the scripts PasteDaemon ships (upper, json and the rest of the standard library, which are the app's own fixed code) and answers 403 at any other, however it is reached: named directly, inside a pipeline action, or written under a shipped keyword. Nothing fires a trigger either. 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 or formatted text on the clipboard
POST /v1/runRuns a pipeline or an action on text and hands back the result, changing nothing
GET, PUT /v1/recordingWhether copies are being kept
GET, PATCH, DELETE /v1/historyThe history, searched and filtered; tagging everything a search finds; clearing it
GET, PATCH, DELETE /v1/history/{id}One entry: reading it, changing its value, pin, tags or description, dropping it
GET /v1/history/{id}/versionsWhat that entry used to hold
GET /v1/tagsEvery tag in the history and how many entries carry it, most used first
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 with or 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. An entry's description, the name of the app it was copied in, and an image's type and size (900x600), are searched too, and the entries whose contents match are listed before those only that matches.

Formatted text on the clipboard

PUT /v1/clipboard takes html, rtf or markdown in place of text, and puts formatted text on the clipboard — what a script set to write HTML, or md-rich, does:

curl -X PUT -H "x-api-key: $PASTEDAEMON_KEY" -H "content-type: application/json" \
  "http://127.0.0.1:<port>/v1/clipboard" -d '{"markdown":"Meet at **noon** — [agenda](https://example.com)"}'

Markdown is rendered by md-rich itself. The plain text that goes beside the formatting is the text the markup reads as, so it is not sent separately. The answer says format — html or rtf — when formatting went on, and warning when the markup was too large to keep and only its text did.

Running a pipeline

POST /v1/run runs a pipeline, or one action, and hands back what it made:

curl -X POST -H "x-api-key: $PASTEDAEMON_KEY" -H "content-type: application/json" \
  "http://127.0.0.1:<port>/v1/run" -d '{"pipeline":"trim | slug","text":"  Hello World  "}'

With text, pipeline is the filters to run on it. Without, it is a whole command as the search bar reads it — $1 | trim — and reads what it names. Send action instead to run one action by its keyword, with arguments for it; with no text it reads the newest clipboard item.

It changes nothing. The answer is text — with list, hash and keys when the result has a shape, and html when a step like md-rich made some — and it goes nowhere. A > $name, tag, tag-all, describe or forget in the command is listed under notApplied rather than done; to keep the result, send it on with PUT /v1/clipboard or PUT /v1/variables/{name}.

A command ending in a picture — $1 | resize 50%, qrcode — is described rather than made, since images are described rather than served: the answer carries image, with from (the entry it would be made from, absent for a qrcode), type, the pixelWidth and pixelHeight it would have after every step, and maxBytes under a fit, when that size is the most it can be. qr, data-uri and colors answer with text as any filter does.

Only the scripts PasteDaemon ships run — see above. A command naming a secret is refused too, and a command that fails says why.

The Apple Intelligence filters — proofread, summarize, rewrite, ask, headline, extract, classify, tabulate, tasks, points, reply and when — and translate run on this Mac and take seconds; the request waits for the answer. Run one as an action — {"action":"rewrite","arguments":["concise"],"text":"…"} — or in a pipeline. On a Mac without Apple Intelligence they say why. In GET /v1/actions they carry longRunning: true, and available says whether this Mac can run them. pipelineOnly is true only for is, matches, from and the logic operators, which answer true or false and run only in a pipeline.

copy, select and paste are refused in any run through the API, with a 422: they press keys in the app in front, which a caller holding a key never gets to do.

Any action can be marked longRunning when it is added or changed. Run by hand — the overlay, a shortcut, the menu bar — a long-running action closes the overlay at once and the HUD says when the result is on the clipboard. It changes nothing about how the API runs it.

worksWithoutInput marks an action that has an answer when handed nothing, as uuid does, and can be set when one is added or changed. A command it starts — | uuid — then runs with an empty history or a picture on the clipboard, and text there is still handed to it. Read back, it is also true for now, tag-all, untag-all, copy and select, which never read anything.

Tags and descriptions

Every entry carries tags — first the ones worked out from what it holds (url, email, color, path, json, uuid, image, files), then the ones it was given — and, when it has one, a description. tag=work lists what carries a tag, and tag=url,github what carries both; the # is optional. tag=text is text that is not a link, as #text is in the overlay's ⌘F.

PATCH /v1/history/{id} takes addTags and removeTags, or tags to replace every tag the entry was given, and description to describe it (empty takes the description away). The tags worked out from what an entry holds cannot be taken off: nobody gave them.

PATCH /v1/history is tag-all and untag-all: it takes the same query as GET /v1/history, and tags exactly what that would list — so run the GET first.

curl -X PATCH -H "x-api-key: $PASTEDAEMON_KEY" -H "content-type: application/json" \
  "http://127.0.0.1:<port>/v1/history?q=invoice" -d '{"addTags":["tax"]}'

removeTags takes tags off everything matched. Taking every tag off is harder to undo, so it has a field of its own: {"clearAllTags": true} clears the tags on the whole history, and is refused unless it is the whole request — no query, and nothing else in the body. To take tags off only what a search finds, name them in removeTags. ids narrows to entries picked by hand. GET /v1/tags lists every tag with how many entries carry it.

Lists and hashes

A variable holding a list carries list, its elements as text. One holding a hash — %name — carries hash, its keys and what each holds as a JSON object, and keys, the order they were written in, since the object's keys come back sorted like every other object in the API. A list with lists or hashes inside it carries items too, each element in its own shape. value is always the text: the elements one to a line, or the key: value lines.

PUT /v1/variables/{name} takes value, list or hash:

curl -X PUT -H "x-api-key: $PASTEDAEMON_KEY" -H "content-type: application/json" \
  "http://127.0.0.1:<port>/v1/variables/contact" \
  -d '{"hash":{"name":"Ada","address":{"city":"London"}}}'

The keys keep the order they were sent in, and arrays and objects inside are kept as lists and hashes, so %contact{address}{city} reaches into what was sent. A number, true or false is stored as the text it was written as, and null as empty text — the way json-hash reads JSON. The name can be written with its sigil: %contact (or %25contact) reaches the same variable as contact.

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 — a regex condition has to compile and a pipeline one has to parse, with the pipeline written as its source — 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. A script action's scriptOutput — text, html or rtf — and discardsOutput are the pane's Writes and Run for what it does settings. 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.