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):
| Rule | Detail |
|---|---|
| Placeholder | {{name}} — must start with letter/underscore, then [A-Za-z0-9_] |
| Missing variable | Renders as empty string |
| Required variable | Declared in the prompt editor → 400 invalid_prompt if omitted |
| Max length | 8 KB per variable by default → 400 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) |
| Sanitisation | ASCII control chars (except \n/\t) are stripped |
| Whitespace | Variables 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.slugresolves to the active version in the workspace registry (server-side).messageshere is the user's question; the template's system/user messages live in the registry and get rendered around it. If you also sendmessages, your messages win over the rendered ones — useful for appending the actual user turn.endUserbecomesx-airouter-end-userso 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_byteson 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:
prompt.version_id(explicit pin) — forbidden for browser keys and end-user JWTs (returns403).prompt.label(e.g."production","staging").- Active A/B experiment (bucketed by
x-airouter-end-user). - The label
production. - 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
| HTTP | code | When |
|---|---|---|
| 400 | invalid_prompt | Required variable missing |
| 400 | variable_too_large | Variable over its cap (8 KB default, or the declared max_bytes) |
| 404 | prompt_not_found | Slug doesn't exist in this workspace |
| 403 | version_pin_not_allowed | version_id sent with a browser/EUT key |
See also
- Prompts and A/B — registry concepts, labels, experiments.
- Shared callodnoga() helper — the helper used above.
- End-user attribution — why
x-airouter-end-usermatters.