Using your own data in prompts

Managed prompts in odnoga are templates with {{variable}} placeholders. You fetch data from your own database/system, pass it as variables, and the server renders the final messages before dispatching to the model. Your template never leaves the odnoga registry — only the rendered messages go to the upstream provider.

Template syntax

You are a billing assistant for {{tenant_name}}.

The user is {{user_full_name}} ({{user_email}}).
Their plan is {{plan}} and renews on {{renews_at}}.

Recent invoices:
{{invoices_context}}

Rules (enforced server-side in _shared/prompts.ts):

RuleDetail
Placeholder{{name}} — must start with letter/underscore, then [A-Za-z0-9_]
Missing variableRenders as empty string
Required variableDeclared in the prompt editor → 400 invalid_prompt if omitted
Max length8 KB per variable by default400 if exceeded. A prompt's variable declaration can raise its own cap with max_bytes (up to 256 KB per variable, 1 MB total across all values)
SanitisationASCII control chars (except \n/\t) are stripped
WhitespaceVariables are inserted as-is — no escaping, no markdown rendering

End-to-end pattern: DB → prompt → model

A Supabase Edge Function that pulls customer data and asks odnoga to render a billing-aware reply:

// supabase/functions/billing-reply/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';
import { callodnoga } from '../_shared/airouter.ts';

Deno.serve(async (req) => {
  const { user_id, question } = await req.json();

  const sb = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  );

  // 1. Pull whatever your system knows about this user
  const { data: u } = await sb
    .from('profiles')
    .select('full_name, email, plan, renews_at, tenant:tenants(name)')
    .eq('id', user_id).single();

  const { data: invoices } = await sb
    .from('invoices')
    .select('number, amount_usd, status, issued_at')
    .eq('user_id', user_id)
    .order('issued_at', { ascending: false })
    .limit(5);

  const invoices_context = (invoices ?? [])
    .map(i => `- #${i.number}  $${i.amount_usd}  ${i.status}  ${i.issued_at}`)
    .join('\n');

  // 2. Hand the data to odnoga as variables
  const { data, meta } = await callodnoga<{ choices: Array<{ message: { content: string } }> }>(
    '/v1/chat/completions',
    {
      model: 'gpt-4o-mini',
      prompt: {
        slug: 'billing-assistant',
        label: 'production',
        variables: {
          tenant_name:     u.tenant.name,
          user_full_name:  u.full_name,
          user_email:      u.email,
          plan:            u.plan,
          renews_at:       u.renews_at,
          invoices_context,        // pre-rendered block
        },
      },
      messages: [{ role: 'user', content: question }],
    },
    { endUser: user_id },
  );

  return Response.json({
    answer:     data.choices[0].message.content,
    request_id: meta.requestId,
    cost_usd:   meta.costUsd,
  });
});

Notes:

  • prompt.slug resolves to the active version in the workspace registry (server-side).
  • messages here is the user's question; the template's system/user messages live in the registry and get rendered around it. If you also send messages, your messages win over the rendered ones — useful for appending the actual user turn.
  • endUser becomes x-airouter-end-user so per-user budgets, A/B stickiness, and audit attribution all work.

RAG / large context

If you're stuffing retrieved chunks into the prompt:

  • Concatenate into one {{context}} variable when total size fits the variable's cap (8 KB by default).
  • Above the cap, either declare max_bytes on that variable in the prompt's declaration (e.g. context: { required: true, max_bytes: 65536 }, up to 256 KB — a registry edit, no deploy) or split into named variables ({{context_1}}, {{context_2}}, …) — the cap is per-variable, not per-request.
  • Keep the chunk separator visible (\n---\n) so the model can tell pieces apart.
const chunks = await retrieve(question, { k: 8 });
const context = chunks.map((c, i) => `### Chunk ${i + 1}\n${c.text}`).join('\n\n');
// ...pass as { variables: { context } }

Precedence & pinning

When a prompt has multiple versions / labels / experiments, odnoga resolves in this order:

  1. prompt.version_id (explicit pin) — forbidden for browser keys and end-user JWTs (returns 403).
  2. prompt.label (e.g. "production", "staging").
  3. Active A/B experiment (bucketed by x-airouter-end-user).
  4. The label production.
  5. Latest version.

This means: send x-airouter-end-user even when you don't think you need it — it's what makes A/B stickiness deterministic.

Errors you'll see

HTTPcodeWhen
400invalid_promptRequired variable missing
400variable_too_largeVariable over its cap (8 KB default, or the declared max_bytes)
404prompt_not_foundSlug doesn't exist in this workspace
403version_pin_not_allowedversion_id sent with a browser/EUT key

See also