documentation

promptkit docs

promptkit keeps your LLM prompts as immutable versions behind a GraphQL API. Your app asks for a prompt by slug and gets whichever version is active at that moment. You publish a new version, or roll back to an old one, from the dashboard — the app itself changes nothing and never redeploys.

Everything below is documented against the running schema at POST /api/graphql.

where to point your app

promptkit runs at https://prommpttkitt.space — this site. Create an account, add a prompt, generate a key, and that origin is the one your app talks to. It is free and open, and it is a young project rather than a company: no SLA, no uptime commitment, no support rotation. Read limits before you put it in the path of something that matters.

You can also run your own copy — clone the repository and supply your own Supabase project. Every example below reads its origin from a PROMPTKIT_URL environment variable, so the only thing that changes is that value: https://prommpttkitt.space for the instance above, http://localhost:3000 under next dev, or your own domain once you have deployed it.

01

Quickstart

Four steps from an empty account to a prompt your app can read. The whole integration is one HTTP request.

  1. 01

    Create an account and a prompt

    Sign up on this instance and confirm your email — your organization is created on that first sign-in — then create your first prompt from the dashboard. You give it a slug — say welcome-email, lowercase letters, digits and hyphens — and the text of version 1. The slug is the stable handle your code refers to; it does not change when the content behind it does.

  2. 02

    Generate an API key

    Open Settings and generate a key. It looks like pk_ followed by 48 hex characters, and it is displayed exactly once — copy it before you leave the page.

  3. 03

    Put the key in your environment

    Add the key and the instance URL — https://prommpttkitt.space, unless you are running your own copy — to your consuming app's .env.local in development, and to your hosting provider's environment variables in production. These two values are the only promptkit-specific things your codebase ever holds.

  4. 04

    Fetch the active version

    Query prompt(slug:) and read activeVersion.template. That is the file below, in full — no SDK, no dependencies.

.env.local
# The promptkit instance you are pointing at. No trailing slash.
PROMPTKIT_URL=https://prommpttkitt.space

# From Settings → Generate key. Shown once, never recoverable.
PROMPTKIT_API_KEY=pk_a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718
lib/promptkit.ts
// lib/promptkit.ts
const PROMPTKIT_URL = `${process.env.PROMPTKIT_URL}/api/graphql`;

const ACTIVE_VERSION = `
  query ActiveVersion($slug: String!) {
    prompt(slug: $slug) {
      id
      slug
      name
      activeVersion {
        id
        versionNumber
        template
        variables
      }
    }
  }
`;

export async function getActivePrompt(slug: string) {
  const res = await fetch(PROMPTKIT_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": process.env.PROMPTKIT_API_KEY!,
    },
    body: JSON.stringify({ query: ACTIVE_VERSION, variables: { slug } }),
    // Opt out of caching so a publish takes effect on the next request.
    cache: "no-store",
  });

  // Auth failures are a 401 whose body still carries the GraphQL error, so
  // read the body before looking at the status — otherwise you lose the message.
  const body = await res.json().catch(() => null);

  if (body?.errors?.length) throw new Error(`promptkit: ${body.errors[0].message}`);
  if (!res.ok) throw new Error(`promptkit: HTTP ${res.status}`);
  if (!body?.data?.prompt) throw new Error(`promptkit: no prompt with slug "${slug}"`);

  return body.data.prompt.activeVersion as {
    id: string;
    versionNumber: number;
    template: string;
    variables: string[];
  };
}

What comes back:

response
{
  "data": {
    "prompt": {
      "id": "3d9a7f14-6c2b-4f81-9a0e-51c7b2e8d403",
      "slug": "welcome-email",
      "name": "Welcome email",
      "activeVersion": {
        "id": "8f3b1c22-0d4e-47a6-b1f9-2ac6e05d7b18",
        "versionNumber": 4,
        "template": "Hey {{name}}, welcome to {{product}}.",
        "variables": ["name", "product"]
      }
    }
  }
}

And from anywhere in your app:

usage
const version = await getActivePrompt("welcome-email");

console.log(version.versionNumber); // 4
console.log(version.template);      // "Hey {{name}}, welcome to {{product}}."
console.log(version.variables);     // ["name", "product"]
on caching

The example sets cache: "no-store" so every call reflects the currently active version. Without it, a framework-level fetch cache can keep serving an old template long after you have published a new one.

If a network round trip per call is too much, cache the result yourself with a short TTL — a minute or two. That is a direct trade: the longer you cache, the longer a rollback takes to reach production.

02

Authentication

Every request needs an x-api-key header. Keys are org-scoped: a key resolves to exactly one organization, and every query and mutation is filtered to that organization's rows. There is no way to name another org's prompt id and reach it — ownership is re-checked on every id that arrives from a client, and a miss returns Prompt not found rather than a permission error, so the API never confirms that someone else's UUID exists.

curl
curl -s "$PROMPTKIT_URL/api/graphql" \
  -H "Content-Type: application/json" \
  -H "x-api-key: $PROMPTKIT_API_KEY" \
  -d '{"query":"{ prompt(slug: \"welcome-email\") { activeVersion { template } } }"}'

Origins and methods

The endpoint answers POST, GET and OPTIONS, and it returns permissive CORS headers — any Origin is reflected back as allowed. That is a convenience for tooling, not permission to call it from a browser: the key you would have to attach is a full-access org credential, so keep every call server-side regardless of what CORS allows.

How keys are stored

Only a SHA-256 hash of the key is written to the database. The raw value is returned once, at creation, and is not recoverable afterwards — not by you, not from the dashboard, not from support. The key list shows a masked label derived from the hash, which is enough to tell two keys apart and useless as a credential.

If you lose a key, you do not recover it; you generate a new one and revoke the old one. An org can hold as many keys as it needs, which is what makes rotation safe.

Keys are server-side credentials

never expose a key to a browser

A promptkit key is a full-access org credential. It authenticates the same context a signed-in dashboard user gets, which means it can read every prompt in the org and also call the mutations — including createVersion, activateVersion and revokeApiKey. Treat it like a database password, not a public client token.

In a Next.js app, do not name it NEXT_PUBLIC_PROMPTKIT_API_KEY. Any variable with that prefix is inlined into the client bundle at build time and shipped to every visitor — the key would be readable in devtools by anyone who loads your site. Read the key only in server components, route handlers, server actions, or your own backend.

Rotating a key

Revoking is immediate and there is no grace period, so order matters. Rotate in three steps, and never revoke first:

  1. 01

    Generate the new key

    Create a second key in Settings and copy it. The old key keeps working — both are valid at once.

  2. 02

    Deploy it

    Update PROMPTKIT_API_KEY in your environment and let the deploy finish, so every running instance is using the new value.

  3. 03

    Revoke the old key

    Now delete the previous key from Settings. Because the new one was already live, there is no window where your app has no working credential.

What an invalid or revoked key returns

Revoking deletes the stored hash, so a revoked key is indistinguishable from one that never existed. Both produce an HTTP 401 whose body is a GraphQL error carrying extensions.code: "UNAUTHENTICATED":

401 — invalid or revoked key
{
  "errors": [
    {
      "message": "Invalid API key",
      "extensions": { "code": "UNAUTHENTICATED" }
    }
  ]
}

Sending no x-api-key header at all, with no dashboard session cookie either, returns the same shape with the message Unauthorized — send an x-api-key header or sign in. Because these arrive as GraphQL errors, check the errors array in the response body rather than relying on res.ok alone.

03

API reference

One endpoint, POST /api/graphql, for everything. The two badges below tell you what a typical consuming app actually uses: runtime operations are the ones your application calls in production, and dashboard operations are the ones the promptkit UI calls on your behalf. Nothing stops you from calling a dashboard operation with an API key — the authorization is identical — but in practice most apps only ever send prompt and, if they are running an experiment, assignedVersion.

explore it in the browser

Opening /api/graphql while signed in serves GraphiQL, with the schema, docs pane and autocomplete. Queries you run there authenticate with your dashboard session cookie, so you can try anything on this page against your own org without minting a key first.

Types

The full object graph, as defined in the schema:

schema.graphql
scalar JSON

type PromptVersion {
  id: ID!
  promptId: ID!
  versionNumber: Int!
  template: String!
  variables: [String!]!
  modelConfig: JSON
  createdAt: String!
}

type Prompt {
  id: ID!
  slug: String!
  name: String!
  activeVersionId: ID
  createdAt: String!
  activeVersion: PromptVersion
  versions: [PromptVersion!]!
}

type AssignedVersion {
  version: PromptVersion!
  experimentId: ID
  variant: String
}

type Experiment {
  id: ID!
  promptId: ID!
  variantAVersionId: ID!
  variantBVersionId: ID!
  splitRatio: Float!
  status: String!
  createdAt: String!
}

type PromptSummary {
  id: ID!
  slug: String!
  name: String!
  activeVersionId: ID
  activeVersionNumber: Int
  versionCount: Int!
  updatedAt: String!
}

type Org {
  id: ID!
  name: String!
  createdAt: String!
}

type ApiKey {
  id: ID!
  masked: String!
  createdAt: String!
}

type CreatedApiKey {
  raw: String!
  key: ApiKey!
}

Queries

prompt(slug: String!): Promptruntime

The main runtime call. Resolves a slug within your org and returns the prompt with its currently active version and, if you ask for it, its full version history.

slugString!
The prompt's stable identifier, e.g. welcome-email.

activeVersion is nullable — a prompt created with createPrompt has no versions yet and returns null until one is activated. versions returns every version, newest version number first.

A slug that does not exist in your org is an error, not a null result: the response carries an errors array and no data.prompt. Check that array rather than assuming a missing prompt comes back as null.

query
query Prompt($slug: String!) {
  prompt(slug: $slug) {
    id
    name
    activeVersionId
    activeVersion { versionNumber template variables }
    versions { id versionNumber template createdAt }
  }
}
promptVersions(promptId: ID!): [PromptVersion!]!dashboard

Every version of one prompt, ordered by versionNumber descending. The same data as prompt.versions, addressed by id instead of slug — useful once you already hold the prompt id.

promptIdID!
The prompt's UUID. Must belong to your org, or the call returns Prompt not found.
query
query Versions($promptId: ID!) {
  promptVersions(promptId: $promptId) {
    id
    versionNumber
    template
    variables
    createdAt
  }
}
assignedVersion(promptId: ID!, subjectId: String!): AssignedVersion!runtime

Returns the version a specific subject should see. If an experiment is running on the prompt, this is the A/B assignment; if none is, it falls back to the prompt's active version. That fallback is what makes it safe to call unconditionally — your app does not need to know whether a test is live.

promptIdID!
The prompt's UUID — note that this one takes an id, not a slug.
subjectIdString!
The stable identifier you are splitting on, typically a user id. See A/B testing below.

variant is "a" or "b" while an experiment is running and null otherwise; experimentId follows the same pattern. If the prompt has neither a running experiment nor an active version, the call errors rather than returning an empty result.

query
query Assigned($promptId: ID!, $subjectId: String!) {
  assignedVersion(promptId: $promptId, subjectId: $subjectId) {
    experimentId
    variant
    version { id versionNumber template }
  }
}
response
{
  "data": {
    "assignedVersion": {
      "experimentId": "b71e4c08-9f52-4d3a-86b0-1e7d5f9c2a44",
      "variant": "b",
      "version": {
        "id": "c4a2e910-77bd-4e6f-9c31-08b5da3e17f2",
        "versionNumber": 5,
        "template": "Welcome aboard, {{name}}."
      }
    }
  }
}
prompts: [PromptSummary!]!dashboard

Every prompt in your org, with the active version number, total version count and last-updated timestamp precomputed. This backs the dashboard list; it takes no arguments because the org comes from your credential.

query
query Prompts {
  prompts {
    id
    slug
    name
    activeVersionNumber
    versionCount
    updatedAt
  }
}
experiments(promptId: ID!): [Experiment!]!dashboard

All experiments ever created for a prompt, newest first, including stopped ones. Check status to find the running one.

promptIdID!
The prompt's UUID.
query
query Experiments($promptId: ID!) {
  experiments(promptId: $promptId) {
    id
    variantAVersionId
    variantBVersionId
    splitRatio
    status
    createdAt
  }
}
org: Orgdashboard

The organization your credential belongs to — id, name and creation date.

apiKeys: [ApiKey!]!dashboard

The keys in your org, newest first. Each entry carries only a masked label derived from the stored hash — this query cannot return a usable key, by design.

query
query Settings {
  org { id name }
  apiKeys { id masked createdAt }
}

Mutations

createPromptWithVersion(slug, name, template): Prompt!dashboard

The one-step path, and the one you usually want: creates the prompt, creates version 1 from the template, and activates it. The returned prompt already has a non-null activeVersionId.

slugString!
The stable identifier your code will query by. Lowercase letters, digits and hyphens only — anything else is rejected before it reaches the database.
nameString!
Human-readable label for the dashboard.
templateString!
The prompt text for version 1. Variables are extracted automatically.
mutation
mutation NewPrompt($slug: String!, $name: String!, $template: String!) {
  createPromptWithVersion(slug: $slug, name: $name, template: $template) {
    id
    slug
    activeVersionId
    activeVersion { versionNumber template variables }
  }
}
createPrompt(slug: String!, name: String!): Prompt!dashboard

Creates an empty prompt with no versions. activeVersionId comes back null, and prompt(slug:) will return a null activeVersion until you add a version and activate it. Prefer createPromptWithVersion unless you specifically want that empty state.

slugString!
The stable identifier. Lowercase letters, digits and hyphens only.
nameString!
Human-readable label.
mutation
mutation NewPrompt($slug: String!, $name: String!) {
  createPrompt(slug: $slug, name: $name) {
    id
    slug
    activeVersionId   # null — no versions exist yet
  }
}
createVersion(promptId, template, variables, modelConfig): PromptVersion!dashboard

Adds a new immutable version to a prompt. The version number is assigned server-side as the current maximum plus one.

promptIdID!
The prompt to add a version to.
templateString!
The new prompt text.
variables[String!]
Optional. Omit it and the server extracts {{name}} placeholders from the template for you; pass it to override that list explicitly.
modelConfigJSON
Optional arbitrary JSON stored alongside the version — model name, temperature, whatever your app reads. Defaults to an empty object.
creating is not publishing

createVersion does not change what your app serves. The new version exists in history but the prompt still points at whatever was active before. Call activateVersion to publish it.

mutation
mutation NewVersion($promptId: ID!, $template: String!) {
  createVersion(promptId: $promptId, template: $template) {
    id
    versionNumber
    variables
  }
}
with modelConfig
# modelConfig is a JSON scalar and only accepts variable input,
# never an inline object literal.
mutation NewVersion($promptId: ID!, $template: String!, $modelConfig: JSON) {
  createVersion(promptId: $promptId, template: $template, modelConfig: $modelConfig) {
    id
    versionNumber
    modelConfig
  }
}

# variables: { "promptId": "...", "template": "...",
#              "modelConfig": { "model": "claude-opus-5", "temperature": 0.2 } }
activateVersion(promptId: ID!, versionId: ID!): Prompt!dashboard

Moves the prompt's activeVersionId pointer to a given version. This is the single operation behind both publishing and rolling back — the only difference is whether the version you name is newer or older than the current one. Takes effect on the next request; no deploy involved.

promptIdID!
The prompt whose pointer moves.
versionIdID!
The version to serve. It must belong to this prompt — activating another prompt’s version is rejected.
mutation
mutation Activate($promptId: ID!, $versionId: ID!) {
  activateVersion(promptId: $promptId, versionId: $versionId) {
    id
    activeVersionId
    activeVersion { versionNumber template }
  }
}
createExperiment(promptId, variantAVersionId, variantBVersionId, splitRatio): Experiment!dashboard

Starts a split test between two versions of the same prompt. The new experiment is created with status running, and assignedVersion starts routing subjects immediately.

promptIdID!
The prompt being tested.
variantAVersionIdID!
The control version — what subjects get when they fall outside the split.
variantBVersionIdID!
The challenger version.
splitRatioFloat
Optional, defaults to 0.5, and must be between 0 and 1. This is the fraction of subjects assigned to variant B, so 0.1 means a 10% canary on B and 90% left on A.
mutation
mutation StartTest(
  $promptId: ID!
  $variantAVersionId: ID!
  $variantBVersionId: ID!
  $splitRatio: Float
) {
  createExperiment(
    promptId: $promptId
    variantAVersionId: $variantAVersionId
    variantBVersionId: $variantBVersionId
    splitRatio: $splitRatio
  ) {
    id
    status
    splitRatio
  }
}

# variables: { "promptId": "...", "variantAVersionId": "...",
#              "variantBVersionId": "...", "splitRatio": 0.2 }
# → 20% of subjects get variant B, 80% stay on variant A.
stopExperiment(experimentId: ID!): Experiment!dashboard

Sets the experiment's status to stopped. From the next request, assignedVersion stops splitting and falls back to the prompt's active version for everyone. Stopping does not activate the winner — if B won, call activateVersion on it.

experimentIdID!
The experiment to stop.
mutation
mutation Stop($experimentId: ID!) {
  stopExperiment(experimentId: $experimentId) {
    id
    status   # "stopped"
  }
}
createApiKey: CreatedApiKey!dashboard

Mints a new key for your org and returns the raw value in raw. Only its SHA-256 hash is stored, so this response is the one and only time the usable key exists anywhere outside your environment.

mutation
mutation NewKey {
  createApiKey {
    raw            # the only time you will ever see this
    key { id masked createdAt }
  }
}
revokeApiKey(id: ID!): Boolean!dashboard

Deletes the key's stored hash. Effective immediately and not reversible — anything still sending that key starts getting 401 Invalid API key on its next request. The delete is scoped to your org, so one org cannot revoke another's key.

idID!
The key's id, from apiKeys.
mutation
mutation Revoke($id: ID!) {
  revokeApiKey(id: $id)
}
renameOrg(name: String!): Org!dashboard

Renames your organization. Display only — ids and keys are unaffected.

mutation
mutation Rename($name: String!) {
  renameOrg(name: $name) { id name }
}
04

Versioning and rollback

This is the whole idea, so it is worth being precise about it. A prompt is not a piece of text — it is a slug with a history of versions and a pointer to one of them.

  • Every edit creates a new version. Versions are append-only: createVersion inserts a row with the next version number and never touches the previous one. Nothing is overwritten and nothing is deleted, so the text that was live last Tuesday is still readable today, exactly as it was.
  • The prompt holds an activeVersionId pointer. That single column is the entire definition of "live". A version is not special because it is the newest; it is special because the pointer names it.
  • Publishing and rolling back are the same operation. Both are activateVersion, moving the pointer. Publishing points it at a version you just wrote; rolling back points it at one from last week. There is no separate revert path to get wrong under pressure, and rolling back is not destructive — the version you rolled away from stays in history, ready to be activated again if you were wrong about being wrong.
  • Changes land on the next request. Your app resolves the active version per request rather than reading a value baked in at build time. Moving the pointer changes what the next call returns — no build, no deploy, no restart.

A rollback, end to end:

rollback
# 1. v5 turned out worse than v4. Look up the version you want back.
query History($slug: String!) {
  prompt(slug: $slug) {
    id
    activeVersionId
    versions { id versionNumber createdAt }
  }
}

# 2. Point the prompt back at v4. Nothing is deleted — v5 stays in history.
mutation Rollback($promptId: ID!, $versionId: ID!) {
  activateVersion(promptId: $promptId, versionId: $versionId) {
    activeVersion { versionNumber template }
  }
}
version numbers only go up

Rolling back to v4 does not renumber anything or create a v6. The prompt simply points at v4 again, and versionNumber in your app's response drops from 5 back to 4 — which makes it a useful thing to log alongside model output when you are trying to explain a change in quality.

05

A/B testing

An experiment splits traffic between two versions of one prompt. You create it with createExperiment, naming a control version (A), a challenger (B) and the fraction of subjects who should get B. Then your app calls assignedVersion instead of prompt and serves whatever comes back.

lib/promptkit.ts
// lib/promptkit.ts (continued)

/** Small POST helper so the two calls below don't repeat themselves. */
async function gql<T>(query: string, variables: Record<string, unknown>): Promise<T> {
  const res = await fetch(PROMPTKIT_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": process.env.PROMPTKIT_API_KEY!,
    },
    body: JSON.stringify({ query, variables }),
    cache: "no-store",
  });

  const { data, errors } = await res.json();
  if (errors?.length) throw new Error(`promptkit: ${errors[0].message}`);
  return data as T;
}

// assignedVersion takes the prompt's UUID, not its slug. Slugs never change
// their id, so resolve it once and memoize rather than paying for a lookup
// on every request.
const promptIds = new Map<string, Promise<string>>();

function promptIdFor(slug: string): Promise<string> {
  let pending = promptIds.get(slug);
  if (!pending) {
    pending = gql<{ prompt: { id: string } | null }>(
      `query PromptId($slug: String!) { prompt(slug: $slug) { id } }`,
      { slug }
    ).then(({ prompt }) => {
      if (!prompt) throw new Error(`promptkit: no prompt with slug "${slug}"`);
      return prompt.id;
    });
    // don't cache a rejection — a transient failure shouldn't poison the slug
    pending.catch(() => promptIds.delete(slug));
    promptIds.set(slug, pending);
  }
  return pending;
}

const ASSIGNED = `
  query Assigned($promptId: ID!, $subjectId: String!) {
    assignedVersion(promptId: $promptId, subjectId: $subjectId) {
      experimentId
      variant
      version { id versionNumber template }
    }
  }
`;

/** Returns the version this subject should see, experiment running or not. */
export async function getPromptFor(slug: string, subjectId: string) {
  const promptId = await promptIdFor(slug);

  const { assignedVersion } = await gql<{
    assignedVersion: {
      experimentId: string | null;
      variant: "a" | "b" | null;
      version: { id: string; versionNumber: number; template: string };
    };
  }>(ASSIGNED, { promptId, subjectId });

  return assignedVersion;
}

// variant is "a" | "b" while an experiment runs, and null otherwise.
const { variant, version } = await getPromptFor("welcome-email", user.id);

Assignment is deterministic

No assignment rows are stored. The variant is computed on the fly by hashing the experiment id together with the subject id:

how the bucket is computed
bucket  = sha256(`${experimentId}:${subjectId}`)      // hex digest
       -> parseInt(digest.slice(0, 8), 16) / 0xffffffff  // first 32 bits -> [0, 1)

variant = bucket < splitRatio ? "b" : "a"

Because the input is just those two strings, the same subjectId always resolves to the same variant for a given experiment — on every request, from every server instance, for as long as the experiment runs. That property is the point, not an optimization:

  • A user does not see the prompt flip between requests. If assignment were random per call, someone refreshing a page would get variant A, then B, then A — which is both a strange experience and useless data, since no subject would have a single consistent treatment to measure.
  • Nothing needs to be written or read back per user, so there is no assignment table to grow, no cache to warm, and no consistency problem across instances.
  • Because the experiment id is part of the hash, starting a second experiment reshuffles everyone rather than reusing the buckets from the first — so an unlucky split in one test does not carry over into the next.

Choosing a subjectId

The subjectId must be stable for whatever you consider one subject of the experiment. A user id, account id, tenant id, or repo id all work. What matters is that the same real-world entity produces the same string every time.

  • Good: user.id, account.id, org.id, repo.id — anything already persistent in your own database.
  • Bad: crypto.randomUUID(), a request id, a timestamp, or a session id that rotates. A fresh value per request re-rolls the dice every time and gives you the flip-flopping behaviour determinism exists to prevent.
  • For signed-out traffic, use whatever durable anonymous id you already set in a cookie. If you truly have nothing stable, an experiment is not going to give you meaningful numbers.

Scope and lifecycle

  • One experiment applies at a time. assignedVersion picks the most recently created experiment whose status is running; older running experiments on the same prompt are ignored, so stop one before starting another to keep things unambiguous.
  • With no running experiment, assignedVersion returns the prompt's active version with variant: null and experimentId: null. You can leave the call in place permanently and only create experiments when you want one.
  • Stopping an experiment does not pick a winner. Call stopExperiment, then activateVersion on whichever version you want everyone to get.
  • promptkit does not collect outcome metrics — see limits. Log the returned variant and version.versionNumber with your own analytics event and measure there.
06

Template variables

Templates mark their placeholders with double braces — {{name}}. When you save a version, promptkit scans the template, pulls out those placeholder names, and stores them on the version as variables. Surrounding whitespace is allowed ({{ name }} reads the same), names are word characters only, and the list is deduplicated in order of first appearance.

So a template of Hey {{name}}, welcome to {{product}}. stores ["name", "product"]. That list is metadata: it is what the dashboard shows so you can see at a glance what a version expects, and what your own code can check against before rendering.

promptkit does not render your template

Interpolation is entirely the consuming app's job. The API stores and serves the template string with the braces intact — there is no server-side substitution, no values argument to pass, and no endpoint that returns a filled-in prompt. What you send is exactly what you get back.

This is deliberate: your variable values are your data, and sending them to a prompt store just to have strings concatenated would be a needless round trip for the values and a needless place for them to be logged.

Rendering is a few lines. This regex matches the one the extractor uses:

lib/render.ts
/** promptkit serves the template; rendering it is yours. */
export function render(template: string, values: Record<string, string>) {
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) =>
    key in values ? values[key] : match
  );
}

const version = await getActivePrompt("welcome-email");

render(version.template, { name: "Ayan", product: "promptkit" });
// "Hey Ayan, welcome to promptkit."

If you would rather use a real template engine, nothing here stops you — the stored template is just a string, and you can adopt any syntax your renderer understands. Only {{variable}} is what the automatic extraction recognizes, so other syntaxes will simply produce an empty variables list. You can also pass variables explicitly to createVersion to record the list yourself.

07

Limits and current status

promptkit was built to demonstrate an architecture — versioned prompts behind an org-scoped API, with publish and rollback as a pointer move. The instance at https://prommpttkitt.space is live and you can sign up and integrate against it today. It is not a commercial service, though: it is one developer's project on a hobby-tier deployment, with no SLA, no uptime commitment, no backups you can restore from and no support rotation. If you need guarantees, run your own copy from the repository, on infrastructure you control. Either way, read the list below before you put it in the path of something that matters.

What works today

  • Email signup and login, with an organization created for you on first sign-in.
  • Creating prompts, adding versions, and activating any version — publish and rollback both, from the dashboard or the API.
  • Immutable, append-only version history with server-assigned version numbers.
  • API keys: create, list as masked labels, and revoke. Only SHA-256 hashes are stored.
  • Org scoping enforced on every query, mutation and id, so a key cannot reach another org’s data.
  • A/B experiments with a configurable split and deterministic, hash-based assignment.
  • Automatic {{variable}} extraction stored per version, plus arbitrary JSON modelConfig.
  • Live dashboard updates over Supabase Realtime — activating a version updates an open prompt page without a refresh. The subscription watches the prompt row, so it is the pointer move that pushes, not the writing of a new version.

What does not exist yet

These are absent, not planned-and-partial. If a feature is not listed in the previous section, assume it is not there.

  • No plans, billing or quotas. The hosted instance is free and unmetered, which also means nothing is promised: data may be reset, and there is no export. Self-hosting is the alternative, and it means bringing your own Supabase project and, if you want transactional email, your own Resend key.
  • No SDK package. There is no npm install promptkit. The integration is the fetch call in the quickstart, which is why that example is written to be copied wholesale rather than imported.
  • No team invites. A user belongs to exactly one org, created at signup, and there is no way to add a second person to it from the UI. Sharing access today means sharing an account or an API key.
  • No usage analytics. Requests are not logged or counted. There is no per-key usage view, no request history, and no experiment results dashboard — measuring an experiment's outcome is on your side.
  • No rate limiting. Nothing throttles requests per key, so treat a leaked key as a real problem and revoke it rather than waiting for a limit to contain it.
  • No scoped or expiring keys. Every key is full-access for its org and lives until revoked. There are no read-only keys, no expiry dates, and no last-used timestamps.
  • No environments. There is one active version per prompt, not one per stage. Separating staging from production means separate prompts, or separate accounts.
  • No deletion or archiving. There is no mutation to delete a prompt or a version. Append-only is a deliberate property of the model, but it does mean an experiment you regret stays visible in history.
  • No edge cache. Every call hits the API and the database. If you need lower latency than that, cache on your side and accept the delay it adds to rollbacks.

Ready to try it?

Create an account, add a prompt and generate a key — then paste the quickstart file into your app and set two environment variables. That is the entire integration. Prefer to run your own copy? The source is one clone away.