--- title: 'stackbone.prompts' description: "Versioned prompt catalog with Mustache-style compile, read+write over the agent's own Postgres." position: 13 --- # stackbone.prompts > **`stackbone.prompts` reads and writes a versioned prompt catalog owned > by your workflows and agents** straight out of the agent's own Postgres > (`stackbone_platform.prompts` / `prompt_versions`), over the same pool > as `stackbone.database`. There is no control-plane round-trip: the > agent owns its data, like `stackbone.secrets` and > `stackbone.config`. You reach it through the ambient client > (`import { stackbone } from '@stackbone/sdk'`), the same handle you use > from any [agent tool](/docs/sdk/agents/overview) or > [durable workflow](/docs/sdk/workflows/overview) step. You address each prompt by its **owner** plus a **`key`** (`summariser`, `welcome_email`, `tool_describe_orders`); the prompt also carries a human-friendly `name`. The owner is one workflow or one deep agent, by name, so your sign-up workflow and your reactivation workflow can each have a `welcome_email`. They are two different letters, with their own text and their own history. ```ts const OWNER = { kind: 'workflow', name: 'sign-up' } as const; ``` There is no workspace-wide prompt and nothing is defaulted: a prompt written under a guessed owner is a prompt no read ever finds. There are two ways in, and the difference is who names the owner. `use(key)` is the one you write in an agent or a workflow: it names only the key, and the owner is whatever is running. Everything else (`get`, `compile`, `list`, and the writes) takes the owner as an explicit argument, because a seed script or an operator tool edits a prompt it is not running inside of. Each `update(...)` appends a new immutable version under that owner's key. Writing one does not put it in service: the **published** version is what `use(...)`, `get(...)` and `compile(...)` read by default. Pin a specific version with the `{ version }` option. ## Where it fits Every prompt your workspace runs on lives here, the agent's own persona included. A deep agent's instruction is not a string in its `index.ts`: the agent declares its `name`, and the runtime reads the prompt owned by that agent when it builds the graph. See [Agents](/docs/sdk/agents/overview) for the authoring model. The same catalogue serves everything your code reaches for at runtime: the body of a notification, a summarisation template, a per-tool instruction your agent renders on the fly. All of it is operator-editable and versioned, so changing the wording is an edit and a publish, not a redeploy. It is agent-local and peer-free: it reads the agent's own database and pulls in no agent-authoring or workflow dependency, so a workflow-only project can import it without dragging the deep-agent runtime along. ## Why it exists Prompts change more often than anything else in an agent's behaviour, and you are least likely to version them. Without a platform surface you hardcode them, ship JSON files in the project, or invent your own A/B routing. This surface makes them reviewable, diffable, and publishable one version at a time without rebuilding the container. ## No capability gate Like `stackbone.secrets` and `stackbone.config`, this surface is **agent-local**: it reads and writes the agent's own database tables directly, so the contract handshake does not gate it. The only prerequisite is an applied prompts schema. Your project's migrations create those tables, and they run when you boot with `stackbone dev` and again when the deployed runtime starts. A not-yet-migrated agent surfaces `prompts_not_configured` until then. Every method returns the standard `Result` envelope (`{ data, error: null } | { data: null, error: SdkError }`), with one deliberate exception: `use(...)` returns the compiled text and never throws. Import the ambient client and call the accessor: the snippets below show the call site inside an agent tool, but the same calls work verbatim inside a durable workflow `"use step"`. ## Writing and publishing are two steps Editing a prompt does not change what the agent reads. `update` records a new version that sits there as a draft; `publish` is what points the prompt at a version and makes it live. So a bad edit cannot reach production by being saved, and going back to an earlier wording republishes that version — v3 stays v3 rather than being copied into a v7 whose number matches nothing in the history. Two consequences worth knowing before you write code against this: - A prompt can have **nothing published**. `get` still returns it, with `template: null` and `version: null`; `compile` refuses with `prompts_not_published` rather than rendering an empty string into a model. - **What is published cannot be deleted** — neither the version (`prompts_version_published`) nor the prompt holding it (`prompts_still_published`). Call `unpublish` first. `create` is the one exception: its version 1 arrives published, because it is the only version there is and an empty new prompt helps nobody. ## API ### `use(key, vars?)` The one you reach for from your own code. It returns the **compiled text** of the published prompt, already rendered against `vars`. No envelope, no owner argument: the prompt belongs to the workflow or the agent whose code is running, so the same line in two workflows reads two different texts. ```ts const instruction = await stackbone.prompts.use('welcome_email', { name: 'Jane' }); await stackbone.ai.chat.completions.create({ model: 'openai/gpt-4o-mini', messages: [ { role: 'system', content: instruction }, { role: 'user', content: 'Draft it.' }, ], }); ``` `use(...)` never throws. A key nobody wrote, a prompt with nothing published, a deleted one, a `{{var}}` you did not pass: every one of them comes back as the **empty string**, and the run's trace records which prompt was asked for and why it came back empty. Your code keeps running with no instruction rather than failing mid-turn, and you find out on the run's timeline in Studio. Reach for `get` or `compile` instead when you want the version number, an envelope you can branch on, or a prompt owned by something other than the code you are in. ### `get(owner, key, options?)` Returns the stored prompt (template + version metadata). The PUBLISHED version by default; pin any version via `{ version }`. ```ts import { tool } from '@langchain/core/tools'; import { stackbone, z } from '@stackbone/sdk'; const fetchSummariserTemplate = tool( async () => { const { data, error } = await stackbone.prompts.get(OWNER, 'summariser'); if (error) throw error; return JSON.stringify({ template: data.template, // published template, or null if nothing is published version: data.version, // the published version number, or null }); }, { name: 'fetch_summariser_template', description: 'Fetch the summariser template.', schema: z.object({}), }, ); ``` Pinning a version: ```ts const { data } = await stackbone.prompts.get(OWNER, 'summariser', { version: 3 }); ``` ### `compile(owner, key, vars, options?)` Fetches the prompt and runs the Mustache-subset compile engine against `vars`. Returns `{ output, version }`: the rendered string and the version it ran against. ```ts const { data: rendered, error } = await stackbone.prompts.compile(OWNER, 'summariser', { article: 'Stackbone makes agents shippable...', }); ``` Pinning a version (e.g. for an A/B split): ```ts await stackbone.prompts.compile(OWNER, 'summariser', { article }, { version: 3 }); ``` #### Feeding a compiled prompt to a model The point of compiling is to send the rendered string to an LLM. Pair `compile(...)` with [`stackbone.ai`](/docs/sdk/platform/ai), which routes the call to a model through [OpenRouter](https://openrouter.ai/docs): ```ts async function summarise(article: string) { 'use step'; const { data, error } = await stackbone.prompts.compile(OWNER, 'summariser', { article }); if (error) throw error; const completion = await stackbone.ai.chat.completions.create({ model: 'anthropic/claude-haiku-4.5', messages: [{ role: 'user', content: data.output }], }); if (completion.error) throw new Error(completion.error.code); return { summary: completion.data.choices[0]?.message?.content ?? '' }; } ``` The compiled string is plain text: feed it to `stackbone.ai`, to the [Vercel AI SDK](https://ai-sdk.dev/docs/ai-sdk-core/generating-text) (`generateText` / `streamText`), or anywhere else you call a model. ### `list(options?)` Lists the published version of every (non-deleted) prompt, ordered by owner then `key` and capped by `limit` (1–100, default 50). Pass `owner` to see only one workflow's or agent's prompts. A prompt with nothing published is listed with a `null` `version` and `template`. ```ts const { data } = await stackbone.prompts.list({ owner: OWNER, limit: 50 }); for (const prompt of data.items) { console.log(prompt.owner.kind, prompt.owner.name, prompt.key, '@v' + prompt.version); } ``` ### `create(request)` Registers a new prompt at version 1, published. `owner`, `key` and `name` are required; the same `key` under the same owner errors with `prompts_already_exists`. A different owner may hold that key freely. ```ts await stackbone.prompts.create({ owner: OWNER, key: 'welcome_email', name: 'Welcome email', template: 'Hello {{name}}, welcome to {{product}}.', }); ``` ### `update(owner, key, options)` Pass a new `template` to append a new immutable version. **That version does not run**: the agent keeps reading the published one until you call `publish`. The result carries both numbers. `latestVersion` is what you just wrote, and `version` is what is still live. You can update `name`, `description` and `metadata` in place without writing a version at all. History is append-only: past versions stay for audit and rollback. The surface serializes concurrent updates to the same prompt, so two parallel writers never collide on the next version number. Two owners editing their own same-named prompt never queue behind each other. The owner is not patchable. Moving a prompt to another owner is a create over there and a delete here, so neither history claims edits it did not receive. ```ts await stackbone.prompts.update(OWNER, 'welcome_email', { template: 'Hi {{name}}, welcome to {{product}}.', }); ``` ### `publish(owner, key, version)` Makes an existing version the one the agent runs. It points at that version (nothing is copied and no version is created), so the number you pass is the number every later read reports. A version that was never written errors with `prompts_version_not_found`. ```ts await stackbone.prompts.publish(OWNER, 'welcome_email', 2); // …and back again, without minting a v3 holding a copy of v1's text: await stackbone.prompts.publish(OWNER, 'welcome_email', 1); ``` ### `unpublish(owner, key)` Takes the prompt out of service. Every version stays; what goes is the pointer. The prompt then reads with `template: null`, `compile` refuses it, and its versions (and the prompt itself) become deletable. ### `delete(owner, key, options?)` Without `{ version }`, soft-deletes the prompt: every version under that owner's `key` stops resolving but the rows stay for audit. It is refused with `prompts_still_published` while the prompt still publishes a version, so call `unpublish` first. With `{ version }`, hard-deletes that ONE version from the history. Refused with `prompts_version_published` when it is the version running. Returns `{ owner, key, version, deleted }`, where `version` is the version removed (or `null` for the whole prompt) and `deleted` is `1` when something was removed and `0` when it was already gone. ```ts await stackbone.prompts.delete(OWNER, 'welcome_email', { version: 4 }); // discard a draft await stackbone.prompts.unpublish(OWNER, 'welcome_email'); await stackbone.prompts.delete(OWNER, 'welcome_email'); // now allowed ``` ## The compile engine `compile(...)` runs a **Mustache-subset**. The only template syntax is `{{var}}` where `var` matches `[a-zA-Z_][a-zA-Z0-9_]*`. There are no conditionals, no loops, no helpers, no comments. | Construct | Behaviour | | ---------------------- | --------------------------------------- | | `{{var}}` | Substituted with `String(vars.var)`. | | `{{#if x}}…{{/if}}` | Treated as a literal: **not** parsed. | | `{{!comment}}` | Treated as a literal. | | `{{var \| filter}}` | Treated as a literal. | | Missing `vars.var` | Errors with code `prompts_missing_var`. | | Extra fields in `vars` | Silently ignored. | The syntax is frozen: any expansion (conditionals, helpers) requires a documented contract change. ## Errors | Code | Meaning | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `prompts_invalid_request` | A required field was missing or empty (`owner`, `key`, `name`, `template`), an `owner.kind` other than `agent`/`workflow`, or `compile()` hit a template that failed to compile for a reason other than a missing variable (reserved: unreachable with today's engine). | | `prompts_not_found` | The prompt does not exist, was soft-deleted, or the pinned `{ version }` does not exist. `meta.key`. | | `prompts_already_exists` | `create()` was called with a `key` that owner already holds. `meta.ownerKind` / `meta.ownerName` / `meta.key`. | | `prompts_missing_var` | `compile()` was called without a value for a `{{var}}`. `meta.name`. | | `prompts_not_published` | `compile()` was called on a prompt with no published version. Publish one, or pin a `{ version }`. | | `prompts_version_not_found` | `publish()` (or a per-version `delete()`) named a version that was never written. `meta.version`. | | `prompts_version_published` | `delete(owner, key, { version })` targeted the version the agent runs. `unpublish` first, or publish another. `meta.version`. | | `prompts_still_published` | `delete(owner, key)` was called while the prompt still publishes a version. `unpublish` first. `meta.version`. | | `prompts_not_configured` | The prompts schema is not applied. Run `stackbone db migrate up`, or boot with `stackbone dev`, which runs the migrations. | | `prompts_unavailable` | The agent database handle was unavailable or a read/write failed for another reason. | | `database_not_configured` | The agent has no Postgres pool (`STACKBONE_POSTGRES_URL` not injected). | ## Where to go next - **[`stackbone prompts`](/docs/cli/reference/prompts)**: read and edit the same catalog from the shell, including the `versions` and `rollback` verbs this surface does not carry. - **[`stackbone.ai`](/docs/sdk/platform/ai)**: the model client `compile(...)` feeds into. - **[`stackbone.database`](/docs/sdk/data/database)**: the Postgres that owns the `prompts` and `prompt_versions` tables this surface reads and writes. - **[`stackbone.secrets`](/docs/sdk/platform/secrets)** and **[`stackbone.config`](/docs/sdk/platform/config)**: the other agent-local surfaces that read straight from the agent's own database. - **[Agents](/docs/sdk/agents/overview)**: the deep-agent authoring model, and where an agent's own instruction comes from.