Skip to content

Prompts

Tracepad stores prompts as versioned, labelled artefacts. Versions are append-only — an audit trail, never edited in place — and labels move between them, so promoting a prompt to production and rolling it back are the same one-line operation, with no redeploy and no new version.

Like scores, this is a plain JSON API that works with curl; authentication is the same as for ingest, and no Content-Type is required.

Endpoints

Method Path Purpose A key needs
GET /api/v1/prompts List names, with where their labels point read
GET /api/v1/prompts/{name} Fetch one version, by label, by version, or the latest any scope
POST /api/v1/prompts/{name}/versions Append a version write
GET /api/v1/prompts/{name}/versions List a name's versions read
PUT /api/v1/prompts/{name}/labels/{label} Create or move a label write
DELETE /api/v1/prompts/{name}/labels/{label} Remove a label write
DELETE /api/v1/prompts/{name} Delete a name with every version and label write

The last column is the scope a key must hold. Fetching one prompt takes any key, because an application fetches its prompts at run time with the only key it has — an ingest key — and it ships the text it fetches anyway. Listing the names, a name's versions and the diff between two take read; every change takes write.

Prompt and label names match ^[A-Za-z0-9][A-Za-z0-9._-]*$ and are at most 200 characters — one URL path segment, no escaping. latest is reserved as a label name (see Labels).

Creating a version

curl -H "Authorization: Bearer tp-sk-…" \
  http://localhost:4318/api/v1/prompts/summarize/versions -d '{
  "type": "chat",
  "prompt": [{"role": "system", "content": "You are terse."}],
  "config": {"model": "claude", "temperature": 0.2},
  "commit_message": "first cut",
  "labels": ["production"]
}'
{
  "name": "summarize",
  "version": 1,
  "type": "chat",
  "prompt": [{"role": "system", "content": "You are terse."}],
  "config": {"model": "claude", "temperature": 0.2},
  "commit_message": "first cut",
  "labels": ["production"],
  "created_at": "2026-08-27T09:58:11Z"
}
Field Required Notes
type on the first version text or chat. Fixed for the name from then on.
prompt yes A string for text; an array of {role, content} messages for chat. Every message needs a non-empty role and content — a version cannot be edited afterwards, so an empty one would be permanent.
config no A JSON object — model, temperature, whatever your runtime reads.
commit_message no Why this version exists.
labels no Labels to point at this version as it is created.
expect_version no The version you believe this name is at — 0 for a name you believe is new. See below.

The version number is current max + 1, assigned inside the write transaction. Concurrent creates for one name therefore produce 1..N with no gaps and no duplicates, however many writers race.

A name has one shape for its whole life: posting a text body to a chat name (or declaring a type the name does not use) is a 400. A name that changed shape between versions would break every client that fetches it by label.

Nothing is interpreted inside prompt — {{variables}} and any other template syntax are stored and returned verbatim. Interpolation belongs to your framework, not to the store.

Appending to the version you meant

There is no create-only endpoint: a first version and a seventh are the same POST, so a client that means to create summarize and finds it taken would quietly extend somebody else's prompt — and, with labels, move their production while doing it. expect_version is how a client says what it believed:

# "this name is new"
curl … /api/v1/prompts/summarize/versions -d '{"type":"text","prompt":"…","expect_version":0}'

# "add to the version I was looking at"
curl … /api/v1/prompts/summarize/versions -d '{"prompt":"…","expect_version":7}'

If the name is not where you said, nothing is written and the answer is a 409 naming where it actually is:

{"error": "prompt \"summarize\" is at version 9, not 7: it changed while this one was being written", "version": 9}

The check runs inside the same transaction that assigns the next number, so asking first and posting second — which is a race — is never necessary. The field is optional: leave it out and the append is unconditional, which is what every client did before it existed. tracepad prompts push --expect N sends it; the web interface always does.

Fetching a prompt

curl -H "Authorization: Bearer tp-sk-…" \
  "http://localhost:4318/api/v1/prompts/summarize?label=production"
Query Result
(none) The highest version
?version=3 Exactly version 3
?label=production Whatever production currently points at
?label=latest The highest version — latest is computed, never stored
?version=…&label=… 400: pick one
?label= (no value) 400, never "the latest": an unset shell variable must not silently ship an unreleased prompt

There is no implicit production default: an unqualified fetch means "newest", the way every other versioned-artefact tool works.

Responses carry Cache-Control: private, max-age=60, which bounds how long a label move takes to reach a client that caches, with Vary: Authorization, Cookie, X-Tracepad-Project. private keeps a proxy or CDN in front of the server from handing one project's prompt to another caller. If you pin a version, you may cache it for as long as you like — a version never changes.

Labels

A label is a single row per (prompt, label), so "which version is production" has exactly one answer. Promote by moving it forward, roll back by moving it back:

# promote
curl -X PUT -H "Authorization: Bearer tp-sk-…" \
  http://localhost:4318/api/v1/prompts/summarize/labels/production -d '{"version": 4}'

# roll back
curl -X PUT -H "Authorization: Bearer tp-sk-…" \
  http://localhost:4318/api/v1/prompts/summarize/labels/production -d '{"version": 3}'

# retire the label
curl -X DELETE -H "Authorization: Bearer tp-sk-…" \
  http://localhost:4318/api/v1/prompts/summarize/labels/production

Both answer 200 {"label": "production", "version": 3}; DELETE reports the version the label pointed at. Pointing a label at a version that does not exist is a 404.

latest cannot be created, moved or deleted: it always names the highest version, computed at read time, so it can never drift from the truth it summarizes.

Listing

curl -H "Authorization: Bearer tp-sk-…" http://localhost:4318/api/v1/prompts
{
  "prompts": [
    {
      "name": "summarize",
      "type": "chat",
      "latest_version": 4,
      "labels": {"production": 3},
      "updated_at": "2026-08-27T09:58:11Z"
    }
  ],
  "next_cursor": null,
  "prev_cursor": null
}

Names come back alphabetically; updated_at is when the name's newest version was created.

GET /api/v1/prompts/{name}/versions lists a name's versions newest first, with their commit messages and labels but without the bodies — version lists are for picking and diffing; bodies come from the single-prompt fetch. It also answers with "labels": every label of the name and the version it points at, so "where is production" is answered on any page of a long history.

Both listings take limit (1–500, default 50), a cursor and a direction (next or prev), and answer with next_cursor and prev_cursor. Keep passing next_cursor until it comes back null; with no cursor at all, direction=prev is the far end — the last name alphabetically, the oldest version of a name — which is what makes "jump to the end" cost one page.

Deleting a prompt

A name that was a mistake, or a rename done by re-creating it under the right name, is removed whole. There is no way to delete one version: versions are the audit trail, and a hole in it would leave a label pointing at nothing.

Like every destructive endpoint here, it is a dry run until ?confirm= echoes the name (see admin.md):

# what would go
curl -X DELETE -H "Authorization: Bearer tp-sk-…" \
  http://localhost:4318/api/v1/prompts/summarize
{
  "dry_run": true,
  "name": "summarize",
  "would_delete": {"versions": 4, "labels": 1},
  "confirm": "summarize",
  "note": "traces that ran this prompt keep the name and version they recorded; the trace filter goes on answering for it"
}
# and for real
curl -X DELETE -H "Authorization: Bearer tp-sk-…" \
  "http://localhost:4318/api/v1/prompts/summarize?confirm=summarize"

The confirmed call answers with the same shape plus "deleted": true, and the versions and the labels go in one transaction. A confirm that does not match is a 400 that changes nothing; an unknown name is a 404 either way.

Traces are untouched. An observation's prompt is a name and a version the client said it ran, recorded as sent and resolved against no registry (see api.md) — not a reference into this table. So ?prompt=summarize on the trace listing goes on finding the runs of a prompt whose definition is gone, which is the honest answer: they did run it.

Using a prompt from an application

Fetch by label at start-up (or per request, behind the 60-second cache), read config for your model parameters, and interpolate the body yourself — replacing {name} and nothing else:

import os
import re

import httpx

def fill(template, variables):
    def one(match):
        if match.group(1) is None:
            return match.group(0)[0]  # {{ and }} are the braces themselves
        return str(variables[match.group(1)])  # a name, never a format spec
    return re.sub(r"\{\{|\}\}|\{([^{}]*)\}", one, template)

prompt = httpx.get(
    "http://localhost:4318/api/v1/prompts/summarize",
    params={"label": "production"},
    headers={"Authorization": f"Bearer {os.environ['TRACEPAD_API_KEY']}"},
).json()

messages = [
    {"role": m["role"], "content": fill(m["content"], variables)}
    for m in prompt["prompt"]
]
response = client.messages.create(model=prompt["config"]["model"], messages=messages)

Not m["content"].format(**variables): str.format reads more than names — {q:>50000000} pads to a width, {user.email} walks an attribute — and the body is written by whoever may change prompts — an editor, or a key with write — not by the application. The SDKs substitute names the same way.

Deploying a new prompt is then a label move, not a release.

From the web interface

Everything on this page has a screen: Prompts in the sidebar is the listing, a name opens its versions with the body of the one you are reading, ?diff=A..B shows the patch the endpoint above returns, and the editor appends a version from the one on screen — an edit is a new version, so there is no other kind. The label control is the deploy path: attaching a label that points nowhere is immediate, and moving or removing one asks first and names the move (production: v6 → v7). Delete is the endpoint above, dry run and all.

The interface does nothing the API does not, and nothing the CLI cannot: every button here is one of the requests on this page. See ui.md.

Responses

Status Meaning
201 The version is committed and fsynced to disk.
200 The read, the label move, the label removal, or the deletion — or its dry run — succeeded.
400 Validation: a type mismatch, a malformed name, an unknown field or parameter, latest used as a label, a confirm that does not echo the name.
401 Unknown credentials.
403 A key without the scope the route needs (api.md).
404 No such prompt, version or label in this project.
409 expect_version disagrees with where the name actually is; nothing was written, and the body says where that is.
413 The body is over TRACEPAD_MAX_BODY_BYTES.
429 The write queue is saturated; retry after the Retry-After delay.