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 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. 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.

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 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: read and edit the same catalog from the shell, including the versions and rollback verbs this surface does not carry.
  • stackbone.ai: the model client compile(...) feeds into.
  • stackbone.database: the Postgres that owns the prompts and prompt_versions tables this surface reads and writes.
  • stackbone.secrets and stackbone.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.
BUILT WITH ❤️ FROM CANADA AND SPAIN