---
name: publish-plan
description: Apply when user requests to publish a plan.
---

# Publishing plans from an agent

Every request below uses `-H "Authorization: Bearer $PLAN_API_KEY"`.

## Key scopes

A key can do only what its scopes allow, and the owner picks them at
https://plan.undeleted.sh/my/keys. Ask for the smallest key that does the job.

- `read`: list, search, and fetch plans, revisions, sources, assets, and
  tags, and export.
- `publish`: create, update, change, restore, and delete plans and assets,
  and import.
- `share`: create, list, and revoke share links.

A `403` that names a scope means this key does not have it. Ask the user for
a key that does; retrying will not help. A key can also be limited to one
project. It then sees only plans in that project, files new plans there when
you send no `X-Plan-Project`, and gets a `404` for any other plan. Such a
key cannot import.

## Publish

    curl https://plan.undeleted.sh/publish \
      -H "Authorization: Bearer $PLAN_API_KEY" \
      -H "Content-Type: text/markdown" \
      --data-binary @plan.md

Response: `{"id":"...","url":"https://plan.undeleted.sh/p/..."}`.

Optional headers: `X-Plan-Visibility: public|private` (default private),
`X-Plan-Title: ...`, `X-Plan-Expires: 1h|24h|7d|30d|<ISO date>`,
`X-Plan-Project: <project id>`, `X-Plan-Message: <one line>`,
`X-Plan-Tags: design,q3` (see Tags).

Content types: `text/markdown` (default), `text/html`.

## Get

    curl https://plan.undeleted.sh/publish/{id} \
      -H "Authorization: Bearer $PLAN_API_KEY"

A 404 means the plan is missing, deleted, expired, or removed by an
administrator. The API does not say which, and a removed plan cannot be
updated or restored. Its owner sees the reason at https://plan.undeleted.sh/my.

## Update

    curl -X PUT https://plan.undeleted.sh/publish/{id} \
      -H "Authorization: Bearer $PLAN_API_KEY" \
      -H "Content-Type: text/markdown" \
      --data-binary @plan.md

Every save is a new revision. Say why in `X-Plan-Message: <one line>`
(up to 500 characters, no line breaks). Readers see it in the revision
history, so keep private notes out of it. The response's `revision` object
echoes it.

`X-Plan-Tags` on an update replaces the plan's tags. Send it blank to clear
them, or leave it out to keep them.

## Revisions

    curl "https://plan.undeleted.sh/publish/{id}/revisions?limit=50" \
      -H "Authorization: Bearer $PLAN_API_KEY"

Response: `{"current_revision":7,"revisions":[{"n":7,"created_at":"...","message":"...","via":"api","bytes":2048,"content_type":"markdown"}],"next_cursor":"7"}`,
newest first. `via` is where the revision came from: `api`, `mcp`,
`editor`, `revert`, or `import`. It is null, like `message`, for
revisions saved before origins were recorded. Pass `next_cursor` back as
`&cursor=...` until it is null. `GET /publish/{id}` carries the current
revision in the same shape as `revision`.

To put an old revision back, restore it. That copies its source into a new
revision with `via: "revert"` and the message "Restored from revision N".
Nothing is deleted. Assets are not versioned, so the plan keeps the files it
has now:

    curl -X POST https://plan.undeleted.sh/publish/{id}/revisions/{n}/restore \
      -H "Authorization: Bearer $PLAN_API_KEY"

Restoring the current revision is a 409. A pruned revision is a 404.

To see what changed between any two revisions, open
`https://plan.undeleted.sh/p/{id}/revisions?from={a}&to={b}`.

## Read and poll

    curl -si https://plan.undeleted.sh/p/{id}/raw \
      -H "Authorization: Bearer $PLAN_API_KEY"

Returns the current source. Markdown comes back with LF line endings. Revision
`n` is always at `https://plan.undeleted.sh/p/{id}/rev/{n}/raw`, and its bytes never change.

Every source response has a strong `ETag`. To check for a new revision, send
it back as `If-None-Match`:

    curl -si https://plan.undeleted.sh/p/{id}/raw \
      -H "Authorization: Bearer $PLAN_API_KEY" \
      -H 'If-None-Match: "<etag from the last response>"'

`304` means no new revision, with no body. `200` carries the new source and a
new ETag. `404` means the plan is gone or you can no longer read it.

## List

    curl "https://plan.undeleted.sh/publish?limit=50&sort=updated" \
      -H "Authorization: Bearer $PLAN_API_KEY"

Response: `{"plans":[...],"next_cursor":"..."}`, in the order `sort`
names. Each plan has `pinned`, but pins do not change the order. While
`next_cursor` is not null, pass it back as `&cursor=...` with the same
sort for the next page.

Query parameters, all optional: `limit` (1 to 100, default 50),
`sort=updated|created|title|relevance` (default updated), `q` (search
text), `tag` (repeat for plans that carry every one),
`visibility=public|private`, `project=<project id>` or
`project=none` for unfiled plans.

## Search

    curl "https://plan.undeleted.sh/publish?q=deploy+checklist" \
      -H "Authorization: Bearer $PLAN_API_KEY"

`q` matches words in the title, id, or the current text of your plans; the
last word also matches as a prefix. Operators and quotes are plain words.
Each result has `snippet`: the matched text as HTML, escaped except for
`<mark>` around matches, or null when only the title or id matched. Add
`sort=relevance` for best match first (one page, no cursor). A search
covers the same plans as the plain list: expired plans included, deleted
ones not. A plan saved a moment ago may not match its new text yet. A
search response also has `index_pending`: how many of your plans the index
has not reached yet (up to 1000). Their text cannot match until it does,
only their title or id.

## Tags

Tags are private labels: 1 to 32 characters of a-z, 0-9, hyphen, or
underscore, at most 20 a plan. They never show on the public page. Set them
with `X-Plan-Tags` or `PATCH {"tags":[...]}`, filter with
`?tag=design&tag=infra`, and list yours with counts:

    curl https://plan.undeleted.sh/publish/tags \
      -H "Authorization: Bearer $PLAN_API_KEY"

Response: `{"tags":[{"tag":"design","count":12}]}`.

## Change metadata

    curl -X PATCH https://plan.undeleted.sh/publish/{id} \
      -H "Authorization: Bearer $PLAN_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"pinned":true}'

Fields: `title`, `visibility`, `expires_at`, `project_id`,
`pinned` (true or false; puts the plan at the top of your console list),
and `tags` (an array that replaces the plan's tags; `[]` clears them).

## Assets

    curl -X PUT https://plan.undeleted.sh/publish/{id}/assets/diagram.png \
      -H "Authorization: Bearer $PLAN_API_KEY" \
      -H "Content-Type: image/png" \
      --data-binary @diagram.png

    curl https://plan.undeleted.sh/publish/{id}/assets \
      -H "Authorization: Bearer $PLAN_API_KEY"

Reference from markdown as `![d](assets/diagram.png)`.

## Share a private plan

    curl -X POST https://plan.undeleted.sh/publish/{id}/share \
      -H "Authorization: Bearer $PLAN_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"label":"Design review","expires":"7d"}'

Response: `{"id":"sl_...","url":"https://plan.undeleted.sh/p/{id}?share=...","label":"Design review",...}`.
The URL is shown once. Hand it to the reader; it cannot be fetched again.

`label` is optional, up to 60 characters. `expires` takes `1h`, `12h`,
`24h`, `7d`, `30d`, or an ISO date, and defaults to `7d`.

List links with their label, expiry, and open count. Revoke one by id, or
all of them:

    curl https://plan.undeleted.sh/publish/{id}/share \
      -H "Authorization: Bearer $PLAN_API_KEY"

    curl -X DELETE https://plan.undeleted.sh/publish/{id}/share/{link-id} \
      -H "Authorization: Bearer $PLAN_API_KEY"

    curl -X DELETE https://plan.undeleted.sh/publish/{id}/share \
      -H "Authorization: Bearer $PLAN_API_KEY"

## Embed on another site

Only a public Markdown plan can be embedded. Put it in an iframe:

    <iframe src="https://plan.undeleted.sh/e/{id}" title="..." style="width:100%;height:480px;border:0"></iframe>

`https://plan.undeleted.sh/e/{id}/rev/{n}` pins one revision. Add `?theme=light` or
`?theme=dark` (default follows the reader's OS) and `?title=0` to hide the
title. For a snippet that also grows the frame to fit, point the user to the
Embed panel on https://plan.undeleted.sh/account/plan/{id}. A private, HTML, or expired plan
answers 404 here, the same as a missing one.

## Export and import

    curl -OJ https://plan.undeleted.sh/publish/export \
      -H "Authorization: Bearer $PLAN_API_KEY"

Downloads a ZIP: `manifest.json`, every stored revision at
`plans/{id}/rev/{n}/source.md` (or `.html`), and each plan's assets at
`plans/{id}/assets/{filename}`. Assets are not versioned, so an old revision
gets today's files. The manifest keeps each revision's message and date,
and each plan's tags. Plans in the trash and plans an administrator removed
stay out.

One ZIP holds at most 50 plans and 64 MB. If the response has an
`X-Plan-Export-Next` header, there is more: call again with
`?cursor=<that value>` until the header is gone. `?project=<id>` or
`?project=none` narrows the export.

    curl https://plan.undeleted.sh/publish/import \
      -H "Authorization: Bearer $PLAN_API_KEY" \
      -H "Content-Type: application/zip" \
      --data-binary @plan-export-part1.zip

Imports one part, up to 69 MB. Response:
`{"created":[...],"skipped":[...],"remapped":[{"from":"...","to":"..."}],"expired":[...],"errors":[...]}`.
Running it twice is safe, because plans already there are skipped. Every
plan the import adds gets a new id, listed in `remapped`; use the new id from
then on. A plan whose expiry had passed comes in expired and is listed in
`expired`: extend it before the grace period runs out, or it is deleted. A
plan an administrator removed is refused, even after you delete it. If
`errors` has entries that start with "not imported yet", import the same file
again to add the rest.

## MCP

If your client speaks MCP, use tools instead of curl. The endpoint is
`https://plan.undeleted.sh/mcp` (Streamable HTTP, JSON responses, no sessions) and takes the
same key:

    claude mcp add --transport http plan https://plan.undeleted.sh/mcp \
      --header "Authorization: Bearer $PLAN_API_KEY"

Tools: `publish_plan`, `get_plan`, `update_plan`, `list_revisions`,
`restore_revision`, `list_plans`, `list_projects`, `list_tags`,
`patch_plan`, `delete_plan`, `create_share_link`. They follow the curl
API's rules, and `tools/list` shows only the tools your key's scopes allow.

- `publish_plan` and `update_plan` take a one-line `message`, the same
  as `X-Plan-Message`. `publish_plan` and `patch_plan` take `tags`.
- `get_plan` leaves out a source over 1 MiB (`content: null`,
  `content_omitted: true`) and returns `raw_url` instead; fetch that with
  the same key.
- `list_revisions` and `restore_revision` match the Revisions calls above.
- `list_plans` takes the List parameters above (`project_id` for the
  project, `null` for unfiled, `tags` as an array) and returns
  `next_cursor` and search snippets the same way.
- `create_share_link` takes `label` and `expires_in`. `delete_plan`
  moves a plan to the trash.

Export and import have no tools; use the curl calls above.

## Webhooks

The account owner can add up to 5 webhooks at https://plan.undeleted.sh/my/webhooks.
Each one gets a POST when a plan is created or imported (`plan.created`), gets a new
revision (`plan.updated`), is deleted (`plan.deleted`), or comes back from
the trash (`plan.restored`). A metadata change without a new revision sends nothing.
The body is JSON and never includes the plan's content:

    {"event":"plan.updated","occurred_at":"2026-09-26T14:02:11.000Z",
     "plan":{"id":"...","url":"https://plan.undeleted.sh/p/...","title":"...",
             "visibility":"private","content_type":"markdown","revision":3,
             "project":{"id":"...","name":"..."}}}

`project` is `null` for an unfiled plan. Fetch the content with
`GET /publish/{id}` or `/p/{id}/raw` if you need it.

The Send test button in the console posts a `ping` event, and its `plan`
is `null`. Answer it with a 2xx like any other event, and check `plan`
before reading from it:

    {"event":"ping","occurred_at":"2026-09-26T14:02:11.000Z","plan":null}

Every request carries `X-Plan-Event`, `X-Plan-Delivery` (the same id on
every retry, so dedupe on it), and `X-Plan-Signature: t=<unix seconds>,v1=<hex>`.
`v1` is HMAC-SHA256 of the timestamp, a dot, and the raw body, keyed with the
webhook's secret. Check it against the raw bytes before parsing, and refuse a
timestamp more than 5 minutes old:

    import { createHmac, timingSafeEqual } from 'node:crypto'

    function verifyPlanWebhook(secret, header, rawBody) {
      const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
      if (!/^\d+$/.test(parts.t ?? '') || !/^[0-9a-f]{64}$/.test(parts.v1 ?? '')) return false
      if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
      const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest()
      return timingSafeEqual(expected, Buffer.from(parts.v1, 'hex'))
    }

Answer with any 2xx within 5 seconds. Anything else is a failed attempt,
including a redirect, which is never followed. A failed event is tried again
after 1 minute, 10 minutes, 1 hour, and 6 hours, five attempts in all. Those
waits are the earliest a retry runs; a check every 10 minutes, or the
account's next plan write, sends it. Ten failed attempts in a row turn the webhook off
until the owner turns it back on.

## Markdown extras

Task lists (`- [ ]`, `- [x]`), tables, and fenced code work as on GitHub.
So do GitHub alerts: put `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`,
`[!WARNING]`, or `[!CAUTION]` alone on the first line of a blockquote.

    > [!WARNING]
    > Run the migration before you deploy.

A `mermaid` fence becomes a diagram readers can zoom and pan. Raw HTML inside
Markdown shows as text; publish `text/html` if you need your own markup.
