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's Integrations half holds the local API, and the switch for the Shortcuts app, which has a page of its own. Its other half, Statistics, counts what the history holds. Integrations 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.

What the API adds waits for you to approve it. An action, trigger or abbreviation made with POST /v1/actions, POST /v1/triggers or POST /v1/abbreviations can't read your secrets or run your scripts until you press Approve above it in Preferences → Actions, Triggers or Abbreviations — wherever it runs, and whatever it calls, including your own actions. A shield beside its name marks it, and awaitingApproval says the same over the API. onlyOnThisMac says whether one is kept on this Mac rather than synced, and POST takes it too. Until then, reaching for a secret or a script is an error naming the rule: a trigger leaves the copy as it was, and an abbreviation leaves what you typed. Editing the rule doesn't approve it. Nothing in a request can approve one: the mark is set after the body is read. The same goes for writing a file: a rule added here that ends in > ~/path is refused until you approve it.

No file is written. Nothing the API does puts a file on your Mac's disk: a > ~/path in a command sent to POST /v1/run is listed under notApplied, like a > $name. GET /v1/history/{id}/file hands over what Save as File would write, for the caller to save where it likes.

Images are described rather than served in every answer. A history of screenshots handed back as base64 would make every response enormous; image.file names the file in the images folder, and GET /v1/history/{id}/file hands over one picture's bytes when asked for by id.

Objects are described too. An entry that kept an app's own data — see Objects — carries objects: the types each pasteboard item offered, in order, their byteCount, and onThisMac, false where the row synced from another Mac. kind=object lists the copies that were nothing but an object; their value is empty, and changing it is a 409. POST /v1/run says the same of a run that brought a copy back whole — $1 | undo — beside the html or rtf it was dressed in.

The calls

PathWhat it is for
GET /v1/statusWhat the app is and what it holds, aiFilters — who answers the AI filters and whether the text leaves this Mac — and agentGuide, where /llms.txt is. The cheapest way to know the key works. ?statistics=true adds statistics: what Statistics counts, without the pasting, which stays on the Mac
GET, PUT /v1/clipboardThe newest entry, any sequence in flight and lastList — the list @@ reads; putting text or formatted text on the clipboard, or with position lower in the history, or items as an entry each
DELETE /v1/clipboard/sequenceStops the list being dealt out, as stop does; what is on the clipboard stays, and @@ carries on from where it got to
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, and how a pause ends — skipCopies left or pausedUntil. Send {"enabled": false, "skipCopies": 3} or {"enabled": false, "minutes": 15} for a pause that ends on its own. A pause with no end clears the history too, keeping the pins, when Clear history when recording is paused is on in Copy/Paste
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 into the trash
GET, DELETE /v1/trashCopies and variables deleted in the last day, most recently first — each with its id, kind, deletedAt and expiresAt around the entry or variable; q searches it and kind=entry or kind=variable narrows it. DELETE empties it. Deleted secrets are never listed
POST /v1/trash/{id}/restore, DELETE /v1/trash/{id}Put one back where it was (409 if a copy was made again since, or if another variable has taken the name — that one stays in the trash), or delete it for good
GET /v1/history/{id}/versionsWhat that entry used to hold
GET /v1/history/{id}/fileThe entry as a file: the bytes Save as File would write
GET /v1/tagsEvery tag in the history and how many entries carry it, most used first
GET /v1/searches, GET, PUT, DELETE /v1/searches/{name}The searches saved in the overlay with ⌘S, each with how many entries it finds; saving one with {"query": "#tax receipt"}, and forgetting one (which ⌘Z in the overlay puts back until the app quits)
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. Deleting moves it to the trash
GET, DELETE /v1/variables/{name}/historyIts past values, and dropping them
GET /v1/variables/{name}/fileThe variable as a file, named for it
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, and files copied in Finder by their paths and by what was read in them — each such entry carries that text as fileContents, beside files, once it has been read. It is a plain substring by default, ignoring case and accents both ways, which is what a script wants; fuzzy=true switches to the overlay's own matcher — where cafe finds café but café does not find cafe — for a caller building something a person types into: every word found, in any order (entries holding them in the order given come first), "an exact phrase" as one term, /a regex/ between slashes, -word, -"a phrase" or -/a regex/ to leave out what holds it (a pattern that isn't a regex is a 400), and OR in capitals between alternatives — /v1/history?fuzzy=true&q=invoice%20-draft. matchCase=true holds q to the case it is written in, with or without fuzzy — /v1/history?matchCase=true&q=UserID finds UserID and not userid — while accents are still ignored and a -word still leaves out in any case. The overlay's Match case setting doesn't apply to the API. 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.

ask finds entries by what they mean instead — /v1/history?ask=the%20wifi%20password — as a ? question in the overlay does, best answer first. The other parameters narrow what it looks among, and its order replaces sort's. It doesn't ask Apple Intelligence to pick one, as the overlay does: a script or an assistant can judge the results itself. A sealed entry is never found by it. PATCH /v1/history doesn't take it, since a question is no way to choose what a bulk change changes, and it's a 400 while Ask with ? is off in Search settings.

ask=like: and the start of an entry's id — /v1/history?ask=like:3f2a9c1b, eight characters is plenty, or the whole id — lists the entries most like that one instead, leaving it out. Words after it rank too (ask=like:3f2a9c1b%20invoice). It's a 404 when no entry's id starts with it, and a 422 when that entry is sealed or has no words. A search like this can't be saved with PUT /v1/searches/{name}, since the entry it names may go.

around and an entry's whole id shows that entry where it sits in the history, as ⌘J does in the overlay: the page is centred on it, so the entries listed with it are the ones copied just before and after — /v1/history?around=…&limit=11 is the entry and five either side. The other filters narrow the history it sits in (tag=url lists the links copied around it), and nextOffset carries on from there as usual. A search can't go with it — q, ask and saved list what matched, not what was copied beside it — nor can offset; each is a 400, as is around on PATCH /v1/history. It's a 404 for an id no entry has, and a 422 when the other filters leave that entry out.

is narrows by the overlay's states: pinned, edited (changed since copied), triggered (rewritten by a trigger), action (changed by an action or command run by hand), api (changed or put on the clipboard by a program — over this API, from an assistant, or from the Shortcuts app), untagged, formatted, symbol (holds a symbol such as ⌘, → or €), emoji, missing, secret (kept sealed, so its value reads empty), large (a picture or object of 1 MB or more, or 10 KB of text) local (kept on this Mac, never synced), offloaded (its picture was taken out of iCloud to stay within the limit) and guessed (carries a guessed tag nobody has kept or taken off). is=edited,-triggered is what was edited by anything but a trigger.

lang narrows to code the app described as written in a language, as the overlay's lang: does: swift, python, javascript, typescript, sql, shell, html, css, go, rust, ruby or java, or a short name such as js, py or bash. A snippet you described in your own words is found by those words rather than by lang.

sort lists the history in one of the overlay's orders: used (what was copied or pasted last first — the default, whatever order the overlay is set to), newest and oldest (by when each was first copied), changed, pasted (most pasted first), largest or smallest. With q, the entries holding the words in the order given still come first, and sort orders each group. Every entry says how often it was pasted in pasteCount — from the overlay, or with ⌘V in any app; ⌘C from the overlay doesn't count.

In tag, app, window, is and lang, a - in front of a name leaves it out, as it does in the overlay: tag=url,-work, app=-slack, window=-test, is=-api, lang=-sql. Several names without one are all of them for tag, window and is, and any of them for app and lang.

Every entry says triggered, and every version in /v1/history/{id}/versions says what made it in origin: trigger, action (run by hand), api or user (typed over in the overlay). A value copied has none, and neither does one made before 2.5.

saved=taxes narrows to what one of your saved searches finds, read exactly as the overlay reads it — following your search settings, as the overlay does; the other parameters narrow what it found. GET /v1/status lists their names.

app narrows to what was copied in one app: its bundle identifier (com.apple.Safari), or its name or the start of it (safari, chrome), as the overlay's app: takes one.

window narrows to what was copied in a window whose title holds it — window=stripe, window=Pull%20request — as the overlay's window: does. Each entry's app carries that title as window, absent where none was read (it needs the Accessibility permission) and for a sealed entry.

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.

Lower in the history

position puts the text in the history at that $ position instead, and leaves the clipboard alone — what "text" > $2 does in the search bar. What was there moves down one:

curl -X PUT -H "x-api-key: $PASTEDAEMON_KEY" -H "content-type: application/json" \
  "http://127.0.0.1:<port>/v1/clipboard" -d '{"text":"call the bank","position":2}'

1 is the clipboard, the same as leaving it out. It goes at most one past the end of the history, and can't be sent with "record": false; either is a 422. It takes html, rtf or markdown as well.

items in place of text makes an entry of each, the first at position (or $1 when there is none) and the rest below it in order — what $1 | split > $2 does in the search bar:

curl -X PUT -H "x-api-key: $PASTEDAEMON_KEY" -H "content-type: application/json" \
  "http://127.0.0.1:<port>/v1/clipboard" -d '{"items":["milk","eggs","bread"],"position":2}'

Blank items are left out and a repeat goes in once. At 1 the first goes on the clipboard. The answer's entries lists what they became, in order. Unlike the search bar, the API doesn't ask first however many there are.

An entry as a file

GET /v1/history/{id}/file and GET /v1/variables/{name}/file answer with the file Save as File would write — the bytes themselves, not JSON — with Content-Type saying what they are and Content-Disposition giving the name:

curl -OJ -H "x-api-key: $PASTEDAEMON_KEY" "http://127.0.0.1:<port>/v1/history/<id>/file"

A picture comes as the image file it is kept as. Text comes as .json when it is JSON, as the language's own extension when it reads as code, as .rtf when it was copied formatted and as .txt otherwise, named for its first line; a variable is named for itself. A secret is a 404, as it is everywhere here. A copy of files from Finder is files already and answers 409 — their paths are the entry's files.

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. arguments sent with a pipeline are what its @ARGS reads, so a pipeline action can be tried before you save it: {"pipeline": "@csr | map fill %<domain: @ARGS[0]>", "arguments": ["payments"]}.

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, > ~/file, tag, tag-all, describe, forget or seal 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}. An action whose pipeline ends > @name hands back empty text, with writing @name under notApplied: the result would have gone into the variable instead.

The one thing a run does is make a sound: speak reads the text aloud on the Mac and stop-speak stops it, so a script can say a result out loud. Neither stores anything.

open opens a value on the Mac, as ⌘O would: {"pipeline": "open", "text": "https://example.com"}, or open Firefox for another app. Over the API it opens links and documents only:

  • links whose scheme is http, https or mailto — Apple Maps links are https — and no other: a shortcuts:// link runs a shortcut;
  • files that are documents, never ones that run: apps, plug-ins, scripts (.sh, .command, .py, AppleScript), Automator workflows, Shortcuts files, installers, .webloc and other location files, aliases, or anything marked executable. Text that reads as a script — and so would be written to a .sh or .py to be opened — is refused for the same reason;
  • in an app named after it only if macOS lists it as opening that kind, since Terminal handed a text file runs it.

Anything else is a 422 saying why, before anything opens. A trigger or action added over the API opens nothing until it is approved. The MCP tools can't run open at all: an assistant reads copied text, and that text could ask it to.

A list holding a picture, a file list or an object from the history carries it in items as {"copy": {"entry": "<id>", "kind": "image", "text": ""}} — the entry it is, not its bytes. A join of one cannot put the pictures anywhere but the clipboard, so its text is the plain text and notApplied says what the paste would have held. A map of an image filter over pictures — @shots | map resize 50% — makes nothing either: each item's copy carries an edit saying what it would become (from, type, pixelWidth, pixelHeight), and notApplied says making 2 images in the list.

A test with a step after it — is-kind image | ocr — stops the pipeline when it answers false. That is still a 200: the answer carries stoppedBy, naming the test (my-action › is when it was inside one of your actions), and text is empty.

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. When a model has been chosen in Advanced, every one but translate asks it instead, and the text goes to its server: aiFilters in GET /v1/status says which, and leavesThisMac whether it leaves the Mac. describe-image runs only then, since Apple Intelligence reads only text: {"pipeline":"$1 | describe-image \"what error is this?\""} sends the picture in the newest entry to that server and answers with what the model says of it. 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 — and copypaste, which is paste --copy — 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.

When something was copied

Every entry carries date, when its current value was set, and firstCopied, when it first turned up. Copying the same text again moves date and leaves firstCopied where it was.

copiedOn, copiedSince and copiedBefore narrow GET /v1/history (and the PATCH that tags what it lists) by firstCopied. They take a date the way the overlay's on:, since: and before: do — yesterday, monday, last week, september, 2 weeks ago, 3d, 2026-09-28 — or an ISO 8601 moment. copiedOn is the whole day, week, month or year the date names; copiedSince and copiedBefore start and stop at the beginning of it, or exactly at a moment. copiedSince=monday&tag=url is the links copied this week. A date that reads as none is refused with a 400.

since and until are the older pair, and ask about date instead.

Tags and descriptions

Every entry carries tags — first the ones worked out from what it holds (url, email, color, path, json, uuid, place, phone, image, files, pdf, video, folder, event, object), 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.

A guessed tag is listed in tags like any other, and again in guessedTags, which is absent when there are none. is=guessed lists the entries with a guess nobody has settled. addTags naming a guessed tag keeps it. removeTags takes it off as wrong, and it won't be guessed for that entry again. tags leaves the guesses alone.

Sealed entries

An entry a seal kept carries "sealed": true. Its value is empty and its preview is ••••••••: the text is opened only in the app, after Touch ID, and never over the API — a /v1/run that reads one, or an action run with no text while the newest item is sealed, answers 403. A search never finds one.

PATCH /v1/history/{id} with {"sealed": true} seals an entry, as seal does — its value is encrypted and its past values go. It is one way: {"sealed": false} on a sealed entry answers 409, and so does sealing an image or a file list.

PATCH /v1/history/{id} with {"sync": "thisMac"} keeps an entry on this Mac, as Keep on This Mac does in its row menu: it never syncs, and if it had synced it is removed from your other Macs. {"sync": "always"} syncs it whatever this Mac's Sync settings would keep here, as Allow Sync does, and {"sync": "settings"} has it follow them again. Every entry says which in sync, and is=local lists the ones kept.

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. A list can have pictures, files or objects in it as {"copy": …} items, and so can a hash's values. In a history entry they point at rows; in a variable kept is true, because the variable keeps them for itself, and a picture's image is beside it. An item kept formatted is a copy of kind text, with its text and formatted: true; POST /v1/run with {"pipeline": "@people[2]"} gives its html and rtf. value is always the text: the elements one to a line, or the key: value lines.

A variable can hold a copy whole — a picture, files, an app's object, or text with its formatting — and says so the way an entry does: kind is image, files or object, with image, files or objects beside it, and formatted is true for text kept with its formatting.

PUT /v1/variables/{name} takes value, list, hash, or entry — the id of a history entry to keep whole, the way $3 > $name does in the app. A sealed entry is refused with 403. It takes formatted text as PUT /v1/clipboard does, as html, rtf or markdown: the variable's value is the text the markup reads as, and warning says when it was too much formatting to keep. value is plain text, so sending one to a formatted variable takes the formatting off, even when the text is the same. That is what the editor for formatted text writes:

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.

curl -X PUT -H "x-api-key: $PASTEDAEMON_KEY" -H "content-type: application/json" \
  "http://127.0.0.1:<port>/v1/variables/logo" \
  -d '{"entry":"6F9619FF-8B86-D011-B42D-00C04FC964FF"}'

at writes one item of a list that is already there — 0 the first, -1 the last — and leaves the rest as they are. Send it with value for plain text, or with html, rtf or markdown to keep the item formatted, as the editor's button beside each item does. An index past the end is a 422, and a list that doesn't exist a 404:

curl -X PUT -H "x-api-key: $PASTEDAEMON_KEY" -H "content-type: application/json" \
  "http://127.0.0.1:<port>/v1/variables/people" \
  -d '{"at":2,"markdown":"**Grace Hopper**"}'

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 something to run — actions that exist, steps of its own in pipeline that parse, or both — 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 and newerThan: one that could take a q is one somebody will run with the wrong q. newerThan is a number of minutes, 1 to 1440, and does what Clear Recent does: it drops what was copied in that window without going to the trash, takes the trash's copies from those minutes with it, and empties the clipboard if the newest copy went. Neither combines with the other or with pins=true.

curl -X DELETE -H "x-api-key: $PASTEDAEMON_KEY" "http://127.0.0.1:28787/v1/history?newerThan=5"

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.

Adding a trigger

A trigger's pipelines are what it runs on the copy, in order, each written the way the search bar reads it and each on what the one before left. One that fails is skipped and the next still runs. Running an action is a pipeline naming it, and a step that needs arguments says them in place:

curl -X POST -H "x-api-key: $PASTEDAEMON_KEY" -H "content-type: application/json" \
  "http://127.0.0.1:<port>/v1/triggers" \
  -d '{"name":"Hub links","conditions":[{"kind":"regexMatch","source":"^https://hub\\.example\\.com/"}],
       "pipelines":["remove-url-tracking","tag #hub | describe $(| host)"]}'

Every keyword in them, inside a $( … ) too, must name an action, or the trigger is refused. One with no pipelines is taken only with "stopsEvaluation": true, as a guard that keeps the triggers below it off the copies it matches. The older actionNames and pipeline are still read — each keyword becoming a pipeline of its own — but not alongside pipelines, and are no longer returned.

Like anything else the API adds, it waits for you to approve it.

Every trigger the API returns says as takesPictures whether a copied picture is handed to it — see Triggers on pictures. One whose condition is | is-kind image and whose first pipeline starts with tag does:

  -d '{"name":"Screenshots","conditions":[{"kind":"pipeline","source":"| is-kind image"}],
       "pipelines":["tag #shots"]}'

The documentation

GET /docs is an interactive explorer and GET /openapi.json is the schema it renders. GET /openapi.json?tag=Triggers is one section of it — the operations under that tag and the schemas they use — and GET /llms.txt is a short guide for an assistant about to write an action, a trigger or an abbreviation, or to work with lists and templates: the bodies, an example of each, and what approval means. /docs, the schema and the MCP run_pipeline tool all point to it. All of them 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.