Skip to Content
Product GuideAgents › Prompts

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.name and morse.prompt.version.
  • Langfuse: the attributes langfuse.observation.prompt.name and langfuse.observation.prompt.version are 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 production version (or the latest, if production is 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. production and any custom label (lowercase letters, digits and hyphens, up to 32 characters) can be moved to another version or, for custom labels, removed. latest is derived from the newest version and cannot be changed. Moving production needs 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).

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:

  1. Call the API directly on every use. This is the default and the simplest, and it is fine for low-volume or offline jobs.
  2. Use your own cache, as in the requests example 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.
  3. Pass cache_ttl to 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 tests
const 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.

Last updated on