stackbone.prompts
stackbone.promptsreads 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 asstackbone.database. There is no control-plane round-trip: the agent owns its data, likestackbone.secretsandstackbone.config. You reach it through the ambient client (import { stackbone } from '@stackbone/sdk'), the same handle you use from any agent tool or durable workflow 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.
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 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<T> 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.
getstill returns it, withtemplate: nullandversion: null;compilerefuses withprompts_not_publishedrather 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). Callunpublishfirst.
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.
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 }.
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:
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.
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):
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, which routes the call
to a model through OpenRouter:
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
(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.
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.
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.
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.
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.
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 allowedThe 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: read and edit the same catalog from the shell, including theversionsandrollbackverbs this surface does not carry.stackbone.ai: the model clientcompile(...)feeds into.stackbone.database: the Postgres that owns thepromptsandprompt_versionstables this surface reads and writes.stackbone.secretsandstackbone.config: the other agent-local surfaces that read straight from the agent's own database.- Agents: the deep-agent authoring model, and where an agent's own instruction comes from.