Prompts
Prompts shows which prompt version is paying off. Every prompt your agents use is in one list, whether its text lives in your code or in Morse. Each version gets the same evidence: traces, success rate, average cost, “95% of responses finished within” a time, and cost per confirmed outcome.
There are two kinds of prompt, and one prompt can be both:
- Prompts from your code. Your runs say which prompt and version they used (see Capturing prompts from code). Morse never edits these. You change them in your repo, and Morse shows how each version performed.
- Morse Managed prompts. The text is stored in Morse, you edit it on the Manage screen, and your app fetches it at runtime. These carry a Morse Managed label. Hosting is optional and only available when it is enabled for your organization.
The verdict is computed the same way for both kinds. Where a prompt is stored never changes how its versions are judged.
The prompts list
Each row shows the prompt’s name and its verdict first. After those come the Morse Managed label or a source chip (“From code” or “Detected”), the latest version, where it is deployed or running, the runs in the last 30 days, and when it was last seen.
Filter by category (Morse Managed or Customer Managed), source, label, environment or agent. Versions Morse detects from your system prompt are hidden until you turn on the Detected filter.
The verdict line
At the top of a prompt is one sentence comparing the latest version with the previous one, for example “v3 costs 25% more per confirmed outcome than v2”. It also shows how many confirmed outcomes each version has. Lower cost per confirmed outcome is better, and a difference inside roughly 10% reads as “about the same”.
The verdict only appears when both versions have enough confirmed outcomes to compute a cost per confirmed outcome. Otherwise the line leads with the conclusion and says what evidence is missing — for example “We can’t make a useful comparison yet: v3 has 8 confirmed outcomes and v2 has 31, and each needs 20” — followed by Next: and the step to collect it, such as “Keep both versions in use and confirm their outcomes: v3 needs 12 more.” A verdict is never derived from success rate or cost alone, and it says nothing about why a version changed.
A prompt’s versions
Open a prompt to see its versions, newest first. Each version shows:
- Where it came from: Morse Managed, declared by your runs, detected, or fetched by your app.
- Its labels and environments, and its evidence.
- A word-level diff against the version before it, when Morse knows both texts.
When a Morse Managed version also shows up in your runs, Morse says whether the two match:
- Matches: the text is the same, or your runs fetched that version from Morse.
- Not verified: your runs declare the version number, but Morse doesn’t have their text.
- Text differs: your code sent text for that version number that doesn’t match the Morse Managed text. Morse never merges the two. Evidence always uses what the run declared.
Deployed vs ran
For a Morse Managed prompt, each label shows the version it points at next to the versions your
runs actually used in the last 7 days. For example, production → v4 while 82% of runs still used
v3 means part of your fleet hasn’t picked up the new version yet.
Capturing prompts from code
Requires SDK 0.7.0 (not yet released; 0.6.0 does not have this). set_prompt() and get_prompt() below are 0.7.0 APIs. On 0.6.0 use the OTLP span attributes instead.
Prompts from your code appear once your traces carry a prompt name and version. Any one of these works:
- SDK: call
set_prompt("triage", version=7)inside the run. See Record keys and versions. - OTLP: set the span attributes
morse.prompt.nameandmorse.prompt.version. - Langfuse: the attributes
langfuse.observation.prompt.nameandlangfuse.observation.prompt.versionare read as-is.
Cost per confirmed outcome also needs confirmed outcomes recorded for those runs. See Record outcomes.
Detected versions
If an agent never declares a prompt, Morse can still tell its versions apart by a hash of its
system prompt. These appear as <agent name> (system prompt) with the Detected source, and they’re
hidden until you turn on the Detected filter. They have no text, because Morse stores only the
hash. To get diffs and named versions, declare the prompt with set_prompt().
Morse Managed prompts (optional)
Declaring prompts in code stays the primary path. If you would rather keep a prompt’s text in Morse and fetch it at runtime, create a Morse Managed prompt with New prompt. A prompt from your code can also become Morse Managed: open it and choose Make this Morse Managed. Morse copies each version whose text it knows, keeping its number, so the history lines up. This is only available when hosted prompts are enabled for your organization.
Versions, labels and archive
Open a Morse Managed prompt and choose Manage.
- New version. The editor starts with the text of the
productionversion (or the latest, ifproductionis not set). Add an optional note and save. Versions are numbered in order and never edited in place. - Labels. A label points at one version.
productionand any custom label (lowercase letters, digits and hyphens, up to 32 characters) can be moved to another version or, for custom labels, removed.latestis derived from the newest version and cannot be changed. Movingproductionneeds an owner or admin. - Archive. An owner or admin can archive a Morse Managed prompt. It leaves the list and fetches
return
404, but its versions and history are kept. Unarchive brings it back. If your runs still declare that name, it stays in the list as a prompt from your code.
The prompts:read key scope
Fetching uses an environment API key that carries the prompts:read scope. When you generate or
rotate a key in Settings, tick “Can fetch hosted prompts (prompts:read)”. Existing keys keep
working for ingest only and cannot fetch prompts until you rotate them with the scope.
Fetch a prompt at runtime
Fetch by label, or pin an exact version with ?version=N:
curl -i "https://api.morsehq.dev/api/v1/prompts/triage?label=production" \
-H "Authorization: Bearer $MORSE_API_KEY"The response is {"name", "version", "template_text", "labels"} with an ETag header and
Cache-Control: private, max-age=30. Send the ETag back to skip the body when nothing changed:
curl -i "https://api.morsehq.dev/api/v1/prompts/triage?label=production" \
-H "Authorization: Bearer $MORSE_API_KEY" \
-H 'If-None-Match: "<etag from the previous response>"'An unchanged prompt returns 304 Not Modified.
With the Morse SDK (morse-ai for Python, @morsehq-dev/sdk for TypeScript) it is one call. The key
is the one you already passed to init() and it needs the prompts:read scope. get_prompt is a
plain fetch with no retries and no fallback. Caching is optional and off by default; see
Caching.
import morse_ai
prompt = morse_ai.get_prompt("triage") # label="production" by default
print(prompt.version, prompt.template_text)
# Pin a version, or revalidate with a previous etag:
morse_ai.get_prompt("triage", version=3)
again = morse_ai.get_prompt("triage", if_none_match=prompt.etag)
if again.not_modified:
... # keep using `prompt`import * as morse from "@morsehq-dev/sdk";
const prompt = await morse.getPrompt("triage"); // label "production" by default
if (!prompt.notModified) console.log(prompt.version, prompt.templateText);
await morse.getPrompt("triage", { version: 3 });
const again = await morse.getPrompt("triage", { ifNoneMatch: prompt.etag });Errors are typed: PromptAccessError (403, the key lacks prompts:read), PromptNotFoundError
(404) and PromptRateLimitError (429, with retry_after / retryAfter in seconds). Network
failures raise the HTTP client’s own error. Passing both a label and a version is a ValueError
(Python) or TypeError (TypeScript).
Fetched prompts link to the run
When you call get_prompt() inside a run, the SDK records the fetched name and version on that run,
so the version’s evidence fills in without a separate set_prompt() call. Outside a run nothing
is recorded. A 304 links the version the SDK already has cached. To turn this off for a call, pass
link=False (Python) or { link: false } (TypeScript). The SDK logs one line the first time it
links a run.
The same in Python with plain requests:
import os
import requests
_cache = {"etag": None, "text": "You are a helpful assistant."} # bundled default
def get_prompt(name: str = "triage", label: str = "production") -> str:
headers = {"Authorization": f"Bearer {os.environ['MORSE_API_KEY']}"}
if _cache["etag"]:
headers["If-None-Match"] = _cache["etag"]
try:
resp = requests.get(
f"https://api.morsehq.dev/api/v1/prompts/{name}",
params={"label": label},
headers=headers,
timeout=2,
)
if resp.status_code == 200:
_cache["etag"] = resp.headers.get("ETag")
_cache["text"] = resp.json()["template_text"]
# 304: keep the cached text. 429 or any other status: keep serving the cache.
except requests.RequestException:
pass
return _cache["text"]Cache on the client, with a bundled fallback
We recommend caching in some form. Fetching on every model call adds latency and counts against your rate limit. You have three options:
- Call the API directly on every use. This is the default and the simplest, and it is fine for low-volume or offline jobs.
- Use your own cache, as in the
requestsexample above. Ship a default copy of the text with your code, treat a fetch as a refresh rather than a dependency of a request, and keep serving the last good text if Morse is slow or returns an error. - Pass
cache_ttlto the SDK for a small in-memory cache:
prompt = morse_ai.get_prompt("triage", cache_ttl=60) # seconds; None or 0 = no cache (default)
morse_ai.configure_prompt_cache(max_entries=256) # optional; 256 is the default
morse_ai.clear_prompt_cache() # empty it, e.g. in testsconst prompt = await morse.getPrompt("triage", { cacheTtlSeconds: 60 });
morse.configurePromptCache({ maxEntries: 256 }); // optional
morse.clearPromptCache();Within the TTL the SDK answers without a request. After it, the next call revalidates with
If-None-Match: a 304 refreshes the entry and a 200 replaces it. The cache is per process,
least-recently-used, and keyed by your API key, prompt name and label or version. Errors (including
429) are raised as usual and are never hidden behind a stale entry, so keep your own fallback if
you want one. Passing your own if_none_match / ifNoneMatch bypasses the cache.
Cache lifetime and rate limit
Responses are cacheable for 30 seconds (max-age=30), so a label move can take up to about 30
seconds to reach a client that honors the header. Fetches are limited to 600 per minute per key. When you exceed
the limit the API returns 429 with a Retry-After header; wait that many seconds before the next
fetch and keep serving your cached copy in the meantime.