# Paperplane — full reference > Push notifications for everything you run. Paperplane sends notifications from your servers, scripts, pipelines and AI agents straight to your phone. Create a channel, POST to its URL — that is the whole integration. Notifications can carry buttons, so you can re-run a job or approve a deploy from the message itself, and an agent can wait for your answer before it continues. Closed source, hosted service, pre-launch. No self-hosting option. The MCP connector, paperplane-mcp, is being open-sourced and published to npm — also pre-launch. Pricing: 1 channel free (up to 1,000 events/day), $5/month for unlimited channels. ## Sending an event POST https:///events// Content-Type: application/json Authorization: Bearer pplt_... // or X-Paperplane-Endpoint-Key { "title": "Deploy failed", // required, ≤200 chars "message": "api-service · main @ abc123", // required, ≤4000 chars "priority": "Crit", // free-form, shown on the card "nature": "error", // free-form, shown on the card "dataJson": "{\"run\":\"4821\"}", // ≤64000 chars "callback": [ { "label": "Re-run pipeline", "webhook": "https://ci.example.com/hooks/rerun" } ], "ask": { "options": [ { "id": "approve", "label": "Approve" } ], "timeoutSec": 300 } } Only `title` and `message` are required. An event becomes an alert when `nature` is error/failure/failed/critical/pending, or `priority` is high/crit/critical/urgent. `ask` blocks the sending agent until a signed-in human answers or it times out — the sending token cannot answer its own ask. ## MCP tools (paperplane-mcp) - `paperplane_ask` — send a question with buttons, block until answered or timed out - `paperplane_check_ask` — recover an answer after the client's own tool-call timeout - `paperplane_send_event` — push a notification, with priority and buttons - `paperplane_list_channels` — see which channels exist - `paperplane_list_messages` — read recent events and replies, with alert status - `paperplane_set_alert_state` — acknowledge or resolve an alert - `paperplane_reply` — leave context in the thread - `paperplane_list_tickets` — check what is already tracked - `paperplane_create_ticket` — file a follow-up, linked back to the alert ## FAQ ### What is Paperplane? Paperplane is a hosted inbox for machine-generated events, built for a human to stay in the loop on what their systems and AI agents are doing. Create a channel per source — CI, cron, monitoring, an agent — and it gets its own HTTPS endpoint. Anything that can POST JSON to that URL shows up on your phone as a message you can read, reply to, act on, or defer onto a ticket. ### What makes it different for AI agents specifically? An agent can send a question with up to eight buttons and block on paperplane_ask until you tap one, on a real timeout. Only a signed-in human can answer — the agent’s own token cannot resolve the question it just asked, even if it holds every scope. That is the property that makes it safe to let an agent page you before a deploy, not just after one. ### How is it different from email or Slack alerts? Email buries alerts and team chat drowns them. Paperplane is built for the actual shape of the problem: a stream of short, timestamped, often actionable events, with one channel per source and buttons on the messages that need a decision. ### What does it take to integrate? One HTTP request. Create a channel, copy its endpoint URL, and POST JSON with a title and a message. Priority, nature, arbitrary context data and action buttons are all optional fields on the same payload. There is no SDK to install. ### What are actionable callbacks? A callback is a labelled webhook attached to an event. It renders as a button on the message, so you can approve a deploy or re-run a pipeline straight from the notification. Only http(s) endpoints are called, requests time out, each endpoint has a cooldown, and every trigger is recorded with the response it got back. ### How do alerts work? An event becomes an alert when its nature is error, failure, failed, critical or pending, or its priority is high, crit, critical or urgent. Alerts sit in one of four states — open, acked, snoozed, or resolved. Acknowledge and resolve sync to every device and to everyone in the channel; snooze stays private to your device. ### Can I share a channel with other people? Yes. A channel is personal by default but you can invite others by username or by sharing a link or QR code, with join requests the admin approves. Ack and resolve are visible to everyone in the channel and attributed to whoever handled it; snooze stays private to the device that set it. ### What does it cost? One channel is free, with up to 1,000 events a day. $5 a month removes the channel limit. There is no separate charge for the MCP connector or for asks. ### Is my data private? Local data is stored encrypted on-device, and ticket text — titles, notes, checklist items, tag labels — is encrypted at rest on the server. That protects a stolen device or a raw database read; it is not end-to-end encryption; Paperplane’s backend can decrypt what it stores. Endpoints can be locked with a per-channel endpoint key so only your systems can post to them. ### Is Paperplane available yet? Paperplane is pre-launch — iOS and Android store releases are on the roadmap. It is a hosted service; there is no self-hosting option. The MCP connector, paperplane-mcp, is open source and published on npm, so it can be used with the hosted API today. ## Agents ### Your agent can't approve its own deploy https://paprpla.in/agents/cant-approve-its-own-deploy/ A blocking approval only means something if the thing being approved can't approve itself. So the rule on Paperplane's backend is simple: a personal access token can send an `ask`, poll it, and read its result — but it can never be the thing that answers it. ## Why a token isn't enough The token an agent holds to send an ask is, cryptographically, the same kind of credential that could be used to answer one. If sending and answering used the same check, an agent — or anything holding its token — could ask itself a question and approve it, and you'd never know the "approval" wasn't a person. So answering is deliberately **session-only**: it requires a signed-in human, tapping a button in the app, on a device that authenticated with a password or an OAuth sign-in. A `pplt_…` token, however many scopes it carries, is rejected on that route. ## What this actually stops - An agent can't grant itself permission by racing its own question. - A leaked or overly-broad token can page you, but can't act on your behalf when you're the thing being asked. - The audit trail (`answeredBy` on every resolved ask) always names a person, never a service. ## What it doesn't stop It's not a defense against a compromised phone, or against you tapping Approve on something you didn't read. The guarantee is narrower and more useful than that: whatever asked the question isn't the same thing that gets to answer it. See [make your agent wait](/agents/make-your-agent-wait/) for the mechanics, and [scoped agent tokens](/agents/scoped-agent-tokens/) for what a token can and can't do more broadly. --- ### Make your agent wait for you https://paprpla.in/agents/make-your-agent-wait/ An agent that can act without asking is fast and occasionally wrong in ways you find out about later. `paperplane_ask` is the other option: the agent sends a question, your phone shows buttons, and the tool call doesn't return until you tap one — or the timeout fires. ## The call ```json { "title": "Deploy staging to production?", "message": "12 files changed, all checks green.", "options": [ { "id": "approve", "label": "Approve", "tone": "positive" }, { "id": "deny", "label": "Deny", "tone": "destructive" } ], "timeoutSec": 300 } ``` Omit `options` and you get a default Approve/Deny pair. `timeoutSec` runs 5 seconds to 24 hours; without `defaultOptionId`, an expired ask resolves as `timeout` with no option chosen — treat that as a denial, not a green light. ## What happens while it waits The agent polls in the background — starting at 2 seconds, backing off ×1.5, capped at 15 seconds — so a fast answer costs almost nothing and a slow one doesn't hammer the server. You see a normal notification with buttons; tapping one ends the poll on the next check. ## What comes back ```json { "status": "answered", "optionId": "approve", "optionLabel": "Approve", "answeredBy": "", "elapsedSec": 41 } ``` `answeredBy` is who tapped it, which matters the moment more than one person can answer a channel's asks. See [scoped agent tokens](/agents/scoped-agent-tokens/) for why the agent can't answer its own question, and [tool-call timeout recovery](/agents/tool-call-timeout-recovery/) for what to do if your MCP client gives up before the person does. --- ### Get a push notification when your coding agent finishes https://paprpla.in/agents/notify-when-agent-finishes/ Not every notification needs an answer. When you just want to know a job finished — a long test run, a build, a migration — `paperplane_send_event` is the plain version of paging you: no buttons, no wait, just a push. ## The call ```json { "title": "Tests finished", "message": "482 passed, 0 failed, 3 skipped — 14m runtime.", "nature": "critical" } ``` Set `nature` to `error`, `failure`, `failed`, `critical` or `pending` — or `priority` to `high`, `crit`, `critical` or `urgent` — and it's promoted from a plain notification to an alert you can acknowledge or resolve, badged until you do. Anything else is a message you read and move past. ## Setup Add `paperplane-mcp` to your MCP client config once: ```json { "mcpServers": { "paperplane": { "command": "npx", "args": ["-y", "paperplane-mcp"], "env": { "PAPERPLANE_TOKEN": "pplt_..." } } } } ``` From there, any agent that speaks MCP — Claude Code, Claude Desktop, Cursor — can page you without you asking it to check in. ## When to reach for `paperplane_ask` instead If the next step actually depends on your answer — approve, pick a path, confirm something destructive — use [`paperplane_ask`](/agents/make-your-agent-wait/) instead. It's the same channel, but it blocks the agent until you've actually decided, rather than notifying you and moving on regardless. --- ### A revocable, scoped token for your agent https://paprpla.in/agents/scoped-agent-tokens/ Handing an agent your Paperplane sign-in would mean handing it everything: your account, every channel, every other token. A personal access token is narrower on purpose. ## The token Minted in the app under **Settings → Developer tokens**, shown once as `pplt_` followed by 64 hex characters. Paperplane stores only its SHA-256 hash — a lost token can't be recovered, only replaced. ```json { "mcpServers": { "paperplane": { "command": "npx", "args": ["-y", "paperplane-mcp"], "env": { "PAPERPLANE_TOKEN": "pplt_...", "PAPERPLANE_DEFAULT_CHANNEL": "Builds" } } } } ``` ## Six scopes `events:write`, `channels:read`, `messages:read`, `messages:write`, `tickets:read`, `tickets:write`. A token minted in the app carries all six; nothing stops you from asking for a narrower one for a script that should only ever send events. ## What no token can do Regardless of scope, a personal access token can never create or delete a channel, rotate an endpoint key, manage membership, run message retention, or mint or revoke another token. Those stay session-only — a signed-in human, in the app. The same rule is what makes [an agent unable to approve its own deploy](/agents/cant-approve-its-own-deploy/): answering an `ask` is session-only too. ## Housekeeping Up to 20 active tokens per account, an optional expiry from 1 to 3,650 days, and `lastUsedAt` visible in the app so a stale token is easy to spot. Revoking one is immediate — the next request with it gets a 401, and the MCP connector fails fast on that with a clear stderr message rather than a confusing mid-conversation error. --- ### When your agent's tool call times out but you haven't answered https://paprpla.in/agents/tool-call-timeout-recovery/ `paperplane_ask` can legitimately take minutes — that's the point, it's waiting on you. But most MCP clients impose their own tool-call timeout, shorter than the `timeoutSec` you set. If the client's clock runs out first, you'll see a client-side timeout error, not the ask's actual result. ## The ask doesn't know the client gave up Nothing about that timeout reaches Paperplane's backend. The ask keeps waiting exactly as configured, on the server, independent of whatever the MCP client decided to do. If you tap Approve two minutes after the client stopped waiting, the ask still resolves to `answered` — the client just never heard about it. ## Recovering it ```json // paperplane_check_ask { "channel": "Builds", "messageId": "msg_8a2f..." } ``` ```json // → non-blocking read, any time after { "status": "answered", "optionId": "approve", "answeredBy": "" } ``` `paperplane_check_ask` is a plain read — no polling, no wait. Call it with the `messageId` from the original `paperplane_ask` call (visible in your own conversation, or found via `paperplane_list_messages`) and you get whatever the ask has resolved to right now: `pending`, `answered`, or `timeout`. ## The practical rule Set `timeoutSec` shorter than you'd like to wait, not as long as you can stand — a client-side timeout is worse than a slightly early `timeout` status, because a client timeout gives you no result to act on at all. If you don't control the client's own limit, keep `paperplane_check_ask` as your fallback path rather than re-asking, which would send a second notification for the same decision. Paperplane currently sends no MCP progress notifications on a pending ask, so a client that extends its own timeout on progress updates gets no help here — plan around the hard timeout, not around progress. ## Compare ### ntfy alternative https://paprpla.in/compare/ntfy-alternative/ ntfy gets more right than most alternatives on this list. It's free, it's open source, you can self-host it in one container or use the public instance, and — this is the one worth being upfront about — it already has action buttons: an `http` action fires a webhook when you tap the notification, the same shape as a Paperplane callback. So the honest comparison isn't "Paperplane has buttons and ntfy doesn't." It's narrower: ntfy's action doesn't carry a cooldown or a logged trigger history the way a Paperplane callback does, and there's nothing past that single tap — no lifecycle, no blocking question an agent can wait on, no shared board for the follow-up. If a topic needs an account-free, self-hosted pub/sub channel with a webhook button, ntfy already does that, for free. What it doesn't do is the part built for agents: `paperplane_ask` blocking a tool call until a human taps an option, with a token that can page you but can't answer its own question. If that's what you're after, that gap is the whole reason to look at Paperplane instead. See [make your agent wait](/agents/make-your-agent-wait/) for how the blocking ask works. --- ### Pushbullet alternative https://paprpla.in/compare/pushbullet-alternative/ Pushbullet's core product is mirroring — notifications, links, files, copy-paste, moving between your own phone and laptop. Its API for sending a custom push is a secondary feature bolted onto that, which is why people using it for machine alerts tend to hit the free tier's push cap and then face a subscription meant for a different use case. There's no per-source channel model: a push from your CI pipeline and a push from your monitoring rule land the same way a shared link does, with no thread, no lifecycle, and no button that fires a webhook back at your infrastructure. It's also a single-user tool — sharing means syncing your own devices, not inviting a teammate into a channel. If what you actually want is "mirror my phone's notifications to my laptop," Pushbullet does that well and Paperplane doesn't try to. If you want a channel per system with buttons that do something and an agent that can wait on your answer, that's a different tool. See [the API contract](/docs/) for how a Paperplane channel works. --- ### Pushcut alternative https://paprpla.in/compare/pushcut-alternative/ Pushcut's strength is specific: a notification can trigger an Apple Shortcuts automation directly on your phone, no server round-trip required. If your workflow already lives in Shortcuts and you're iOS-only, that's a real capability Paperplane doesn't have at all — Paperplane's callbacks fire a webhook you host, not a Shortcut on your device. What Pushcut doesn't have is a model for *systems*. It's built around notification templates you configure once, not a channel per source that reads like a thread of everything that system has said. There's no ack/resolve lifecycle, no ticket board to defer something onto, and — since it's a personal automation tool rather than a developer inbox — no concept of an AI agent blocking on your approval before it continues. If you want deep on-device automation and don't need Android, Pushcut is the better fit. If you want a cross-platform inbox organized by source, with an agent that can genuinely wait for you, that's the gap Paperplane fills. See [the API contract](/docs/) for how a channel's endpoint works, or [make your agent wait](/agents/make-your-agent-wait/) for the blocking ask. --- ### Pushover alternative https://paprpla.in/compare/pushover-alternative/ Pushover does one thing and has done it for over a decade: a token, a user key, and a POST turns into a push notification. It's cheap, it's everywhere, and it isn't going to disappear. Where it stops is where Paperplane starts. Pushover has no notion of an agent that blocks on your answer — it can *repeat* an emergency-priority alert until you acknowledge it via a separate API call, which is close to a lifecycle but isn't buttons on the notification. It has no way to attach a labelled webhook that fires when you tap it. And it has nothing for AI agents specifically — no MCP connector, no concept of a token that can page you but can't answer its own question. If your need is "get a reliable ping from a script," that gap doesn't matter and Pushover wins on price and maturity alone. If you want an agent to ask you something and actually wait for a human, or a notification with real buttons wired to your infrastructure, that's the part Pushover doesn't have. See [make your agent wait](/agents/make-your-agent-wait/) for the blocking-ask mechanics, or the [API contract](/docs/) for the full payload. ## Recipes ### Get notified when a cron job or systemd service fails https://paprpla.in/recipes/cron-systemd/ Cron's default is to email every run's output to a local mail spool nobody reads. The fix is the same either way: check the exit code, and only notify on failure. ## Cron Wrap the job in a small script rather than editing the crontab line itself — easier to test and to reuse: ```bash #!/usr/bin/env bash set -euo pipefail if ! /usr/local/bin/nightly-backup.sh; then curl -sS -X POST "https:///events//Backups" \ -H "Content-Type: application/json" \ -d '{ "title": "Nightly backup failed", "message": "'"$(hostname)"' — see /var/log/backup.log", "priority": "High", "nature": "error" }' exit 1 fi ``` Point cron at the wrapper, not the underlying command: ``` 0 3 * * * /usr/local/bin/backup-with-alert.sh ``` ## systemd A oneshot service can use `OnFailure=` to trigger a second unit only when the first one fails — no wrapper script needed: ```ini # /etc/systemd/system/backup.service [Unit] Description=Nightly backup OnFailure=notify-paperplane@%n.service [Service] Type=oneshot ExecStart=/usr/local/bin/nightly-backup.sh ``` ```ini # /etc/systemd/system/notify-paperplane@.service [Unit] Description=Notify Paperplane that %i failed [Service] Type=oneshot ExecStart=/usr/bin/curl -sS -X POST "https:///events//Backups" \ -H "Content-Type: application/json" \ -d '{"title":"systemd unit failed","message":"%i","priority":"High","nature":"error"}' ``` `%i` and `%n` expand to the failed unit's name, so one `notify-paperplane@` template covers every service you attach it to. --- ### Get a phone notification when a GitHub Actions run fails https://paprpla.in/recipes/github-actions/ GitHub's own notification settings bury a CI failure in an inbox with everything else. A `curl` step at the end of the job sends it straight to a channel of its own. ## The step ```yaml - name: Notify Paperplane if: failure() run: | curl -sS -X POST "${{ secrets.PAPERPLANE_ENDPOINT }}" \ -H "Content-Type: application/json" \ -H "X-Paperplane-Endpoint-Key: ${{ secrets.PAPERPLANE_KEY }}" \ -d '{ "title": "CI failed on main", "message": "'"${{ github.workflow }}"' · ${{ github.sha }}", "priority": "High", "nature": "error" }' ``` `PAPERPLANE_ENDPOINT` is the full channel URL from **Channel → Details**; `PAPERPLANE_KEY` is that channel's endpoint key, if you set one. `priority: High` and `nature: error` promote this from a message you scroll past to an alert you have to acknowledge or resolve. ## Only on success, too Drop `if: failure()` for `if: always()` and branch on `${{ job.status }}` in the message if you also want a quiet confirmation when a deploy job succeeds — useful for anything long enough that you've stopped watching the terminal. ## Add a button A Paperplane callback fires a plain, unauthenticated `GET`/`POST` — GitHub's re-run API needs a bearer token, so you can't point a callback straight at `api.github.com`. Put a small relay in between (a Cloudflare Worker route or similar) that holds the GitHub token and calls the re-run API when hit: ```json "callback": [ { "label": "Re-run", "webhook": "https://your-relay.example.com/rerun/RUN_ID" } ] ``` See the [API contract](/docs/) for the full callback shape and its cooldown. --- ### Route Grafana alerts to your phone https://paprpla.in/recipes/grafana-alerting/ Grafana's alerting webhook payload carries a full alert group — labels, annotations, state, a `commonLabels` block — not a flat `title`/`message`. Rather than fight Grafana's templating for an exact match, point it at a small relay that reshapes the payload and forwards it. ## The relay A few lines is enough — this one runs on Cloudflare Workers, but any small HTTP handler works the same way: ```js export default { async fetch(request, env) { const alert = await request.json(); const first = alert.alerts?.[0]; const firing = alert.status === 'firing'; await fetch(`https:///events//Grafana`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: `${firing ? 'Alert' : 'Resolved'}: ${alert.commonLabels?.alertname ?? 'Grafana'}`, message: first?.annotations?.summary ?? alert.message ?? 'No summary provided', priority: firing ? 'High' : 'Low', nature: firing ? 'error' : undefined, dataJson: JSON.stringify(alert.commonLabels ?? {}), }), }); return new Response('ok'); }, }; ``` ## Wiring it up 1. **Alerting → Contact points → Add contact point**, type **Webhook**. 2. Set the URL to your relay, not directly to Paperplane. 3. Attach the contact point to a notification policy scoped to whichever alert rules should reach your phone — not everything Grafana tracks needs to page you. ## Why a relay instead of Grafana's own templating Grafana can template the webhook body directly in newer versions, which avoids the extra hop if you're comfortable with its templating syntax. The relay is the more durable recipe here: it doesn't depend on which Grafana version you're running, and it's the same pattern you'd reuse for any tool — like Stripe — whose webhook payload doesn't already match Paperplane's shape. --- ### Notify your phone from any shell script https://paprpla.in/recipes/shell-script/ If a script can run `curl`, it can reach your phone. This is the whole integration — everything else in the recipes section is this same request, reshaped for a specific tool's webhook format. ## The one-liner ```bash curl -X POST "https:///events//" \ -H "Content-Type: application/json" \ -d '{"title":"Job finished","message":"'"$(hostname)"' at '"$(date -Is)"'"}' ``` `title` and `message` are the only required fields. Everything else — `priority`, `nature`, `dataJson`, `callback` — is optional on the same payload; see the [API contract](/docs/) for the full list. ## A reusable function Drop this in `.bashrc` or a shared script library and every job just calls `notify`: ```bash notify() { local title="$1" message="$2" nature="${3:-}" curl -sS -X POST "https:///events//Scripts" \ -H "Content-Type: application/json" \ -d "$(printf '{"title":%s,"message":%s%s}' \ "$(jq -Rn --arg t "$title" '$t')" \ "$(jq -Rn --arg m "$message" '$m')" \ "${nature:+,\"nature\":\"$nature\"}")" } notify "Backup done" "$(hostname) — $(date)" notify "Disk almost full" "/var at 94%" "critical" ``` `jq -Rn --arg` handles the JSON-escaping for you, so a message with quotes or newlines in it doesn't break the payload — worth the one extra dependency the moment your messages aren't hand-typed constants. --- ### Get notified when a Stripe payment fails https://paprpla.in/recipes/stripe-webhooks/ Stripe webhooks POST Stripe's own event shape — `{ "type": "invoice.payment_failed", "data": { "object": {...} } }` — not something you can point at a Paperplane endpoint directly. The honest recipe is a small relay: receive the Stripe event, verify its signature, forward a Paperplane-shaped payload. ## The relay ```js import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); export default async function handler(req, res) { const signature = req.headers['stripe-signature']; let event; try { event = stripe.webhooks.constructEvent(req.rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET); } catch { return res.status(400).send('Invalid signature'); } if (event.type === 'invoice.payment_failed') { const invoice = event.data.object; await fetch('https:///events//Billing', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Payment failed', message: `${invoice.customer_email ?? invoice.customer} — $${(invoice.amount_due / 100).toFixed(2)}`, priority: 'High', nature: 'error', dataJson: JSON.stringify({ invoiceId: invoice.id }), }), }); } res.status(200).send('ok'); } ``` Verifying `stripe-signature` matters here specifically: this endpoint is publicly reachable, and skipping verification means anyone who finds the URL can send you a fake "payment failed" page. ## Add a resolve button If you refund or retry the charge from a dashboard you host, attach a `callback` so the notification can trigger that directly — see [the API contract](/docs/) for the shape, and [cron/systemd](/recipes/cron-systemd/) for a simpler starting point if webhook signing feels like overkill for now. --- ### Send Uptime Kuma alerts to your phone https://paprpla.in/recipes/uptime-kuma/ Uptime Kuma's default webhook payload is shaped for its own dashboard, not for Paperplane's `title`/`message` fields — so the notification needs a custom body template, which Uptime Kuma supports natively. ## Set up the notification 1. **Settings → Notifications → Setup Notification**, type **Webhook**. 2. Set the URL to your channel's endpoint: `https:///events//Uptime`. 3. Turn on **Custom Body**, and set the request body to match Paperplane's payload, using Uptime Kuma's template variables: ```json { "title": "{{ monitorJSON.name }} is {{ heartbeatJSON.status }}", "message": "{{ msg }}", "priority": "High", "nature": "pending" } ``` 4. If the channel has an endpoint key, add it as a custom header field if your Uptime Kuma version exposes one for webhook notifications — otherwise lock the channel down by IP or leave it open behind Uptime Kuma's own network. 5. Attach the notification to each monitor you want on your phone, or set it as a default for new monitors. ## Why `nature: pending` An Uptime Kuma "down" event isn't necessarily an error you can fix yet — it might resolve itself. `pending` still promotes it to an alert you have to acknowledge, without asserting it's already a confirmed failure. Switch to `error` if you'd rather every down event page you as a hard failure from the start. Uptime Kuma sends a second notification when the monitor recovers — that one lands as a plain message unless you also set `nature`/`priority` in a second template for the "up" case. ## Links - Home: https://paprpla.in/ - Documentation: /docs/ - MCP server: https://www.npmjs.com/package/paperplane-mcp - Source (connector): https://github.com/ParikshithV/paperplane-mcp