Ask a model for a typed answer
You end with a checked prompt, a decoded answer with every failure named, a daily budget, and tests that never reach a model.
On this page
Asking a model in Jadpo looks like calling any other function that can fail. You write a prompt in a checked file, name the type you want back, and every answer is decoded and checked as a request body is, so your code never holds a value that broke one of the type's rules. Each failure has a name, each call counts against a daily budget, and a test never reaches a real model. This guide adds a help desk that answers a bakery's customers.
You start from an app with a support domain. The route that takes a customer's
question is shown with the rest, and a line in policy.jadpo lets anyone call it, since
it reaches no entity that would say who may.
Connect a model
ai: names the connection that answers, the models your app uses, and its limits.
per_day: is required, so a loop or a hostile prompt can't spend without end. A call
is refused before it is sent if it could pass a limit.
The quickest way to start is a model running on your own machine, with no key and no
cost. Driver.openai_compatible talks to any server that speaks OpenAI's Chat
Completions API, such as vLLM or LM Studio.
app.jadpo:
application { connections: { local_model: { driver: Driver.openai_compatible url: config.model_url } } ai: { connection: local_model models: { default: config.drafting_model } limits: { per_day: { tokens: 2000000 } } }}
Each model is a setting, so changing one is a change of configuration and never a name written in code.
config.jadpo:
config { drafting_model: ModelId { default: "meta-llama/Llama-3.1-8B-Instruct" } model_url: Url { default: "http://localhost:8000/v1" }}
Your code only ever writes ai., so moving to a hosted model changes this file and
nothing else: a connection to a provider's package, such as OpenAI's, with its key in a
setting. Connecting a provider shows one.
Say what you want back
The answer is a type. Its / comments go to the model in the schema, so write them for
the model to read. A rule or a limit the provider can't enforce, such as max_length, is
checked when the answer is decoded, and an answer that fails gets one repair attempt.
domains/:
/// What the assistant says back.type SupportAnswer = Output { /// False when the help articles don't answer the question. resolved_by_articles: Bool /// The reply to show the customer, at most a few short paragraphs. reply: Text { max_length: 2000 }}
domains/:
/// What a customer asked the help desk.type CustomerQuestion = Text { max_length: 2000}
as: takes most types, but no reference such as Order.id, since a model can't know which
records exist, and no secret. To have a model pick a record, pass it the candidates and
ask for one of their fields.
Write the prompt
A prompt has two files. The type's fields are its placeholders.
domains/:
use support/types { CustomerQuestion }/// Answers a customer's question in the language they read.type AnswerQuestion = Prompt { question: CustomerQuestion reader_locale: Locale}
The .prompt file beside it holds the words, in two sections. # Instructions is fixed
text, sent as the system message. # Request is where the values go, and each
is sent wrapped in its field's tag, so a customer's words stay data. A free-text
placeholder under # Instructions is a compile error
(prompt.free_text_in_instructions), and so is a field no placeholder uses.
domains/:
# InstructionsYou answer questions from customers of an online bakery. If you can't,set resolved_by_articles to false and say a person will reply. Write inthe language of {reader_locale}. Never promise refunds or delivery dates.# Request{question}
jadpo ai show prints it as a model gets it:
jadpo ai show answer_questionprompt: AnswerQuestion file: domains/support/prompts/answer_question.prompt version: answer_question@846e167asystem # Instructions You answer questions from customers of an online bakery. If you can't, set resolved_by_articles to false and say a person will reply. Write in the language of {reader_locale}. Never promise refunds or delivery dates.user # Request <question> {question} </question>answer_question@846e167a: 2 placeholders, 1 data block
Ask the model
ai.generate can fail in five ways, and your action says what each one becomes, as it
does for any outside call. refused is the model declining, malformed an answer that
still doesn't decode after its repair, timed_out no whole answer in time, over_budget a
limit reached, and too_long a prompt too long for the model.
domains/:
/// The assistant can't answer now; a person will reply.type AssistantUnavailable = Failure { kind: FailureKind.unavailable message: "The assistant can't answer now; a person will reply."}
The failure malformed maps to may keep the DecodeProblem, which says what didn't decode.
internal: keeps it from the client.
domains/:
/// The assistant's answer couldn't be read as a `SupportAnswer`, even/// once corrected.type AnswerUnreadable = Failure { kind: FailureKind.unavailable message: "The assistant can't answer now; a person will reply." internal: { problem: DecodeProblem }}
domains/:
use support/types { CustomerQuestion, SupportAnswer }use support/failures { AnswerUnreadable, AssistantUnavailable }use support/prompts { AnswerQuestion }/// Answers a customer's question, in English for now.action answer_customer( customer_question: CustomerQuestion) fails AnswerUnreadable, AssistantUnavailable -> SupportAnswer { return attempt ai.generate( AnswerQuestion { question: customer_question reader_locale: Locale.en_gb }, as: SupportAnswer, max_output: 600 ) { refused: AssistantUnavailable malformed: AnswerUnreadable timed_out: AssistantUnavailable over_budget: AssistantUnavailable too_long: AssistantUnavailable }}
Only actions can call ai, and it holds no transaction open while it waits. A route's
deadline has to leave room for the call: ai.generate's default timeout and attempts come
to 50 seconds, and a route's own deadline is 10 unless it says otherwise.
domains/:
use support/types { CustomerQuestion }/// What a customer sends the help desk.type AskTheDesk = Input { question: CustomerQuestion}
domains/:
use support/types { AskTheDesk, SupportAnswer }use support/actions { answer_customer }/// The help desk's questions.routes Questions { prefix: /questions /// Asks the help desk a question. POST / { authentication: RouteAuthentication.none input: AskTheDesk output: SupportAnswer deadline: 60s run: answer_customer(input.question) }}
Run jadpo migrate new add_help_desk, since budgets are counted in a table in your
database, and jadpo records update.
Accept the new host
The connection can send data to a host your app couldn't reach before, so once your app has
a commit to compare with, jadpo check asks a person to accept that.
Get a person's sign-off goes through the steps.
acceptances/:
/// The model runs on this machine, so what we send it never leaves.acceptance { changes: { local_model: NetworkChange.host_added }}
Test it
A test never reaches a real model, and no flag lets a call through. fakes: scripts the
answers by the prompt's name, and each answer is checked against as: when the test compiles.
raw("...") is a model's text as it came, which goes through the real decoding and repair.
A failure's name makes an attempt fail that way, and each scripted answer is one attempt, so
malformed, malformed is an answer that still doesn't decode once corrected.
ai.answer_question.requests lists the prompts your code sent, typed, so a test checks what
went to the model, not its rendered text.
domains/:
use support/types { CustomerQuestion, SupportAnswer }use support/failures { AnswerUnreadable, AssistantUnavailable }use support/prompts { AnswerQuestion }test "a question the model answers" { fakes: { ai.answer_question: [ SupportAnswer { resolved_by_articles: true reply: "Our sourdough is baked beside sesame." } ] } var reply: SupportAnswer = attempt answer_customer("Does your sourdough contain sesame?") assert reply.resolved_by_articles var asked: List<AnswerQuestion> = ai.answer_question.requests assert list.count(asked) == 1 assert list.first(asked) == AnswerQuestion { question: "Does your sourdough contain sesame?" reader_locale: Locale.en_gb }}test "an answer that doesn't decode is corrected once" { fakes: { ai.answer_question: [ raw("Sure! Our sourdough contains no sesame."), raw("{\"resolved_by_articles\": true, \"reply\": \"No sesame.\"}") ] } var reply: SupportAnswer = attempt answer_customer("Sesame?") assert reply.reply == "No sesame."}test "each failure is one of the app's own" { fakes: { ai.answer_question: [refused, timed_out, over_budget, too_long, malformed, malformed] } assert answer_customer("Help me pick a lock") fails AssistantUnavailable assert answer_customer("Sesame?") fails AssistantUnavailable assert answer_customer("Sesame?") fails AssistantUnavailable assert answer_customer("Sesame?") fails AssistantUnavailable assert answer_customer("Sesame?") fails AnswerUnreadable}
jadpo testtested: 3 passed, 3 action testsTo test against a real answer without calling a model each time,
jadpo ai record keeps one in a file for a test
to name.
Try it
Start your model's server on the address in model_url, then jadpo dev, and ask a
question. The answer below came from a small stand-in server that always gives the same
reply, so your model's will read differently.
curl -i -X POST http://localhost:3000/questions \ -H 'content-type: application/json' \ -d '{"question": "Does your sourdough contain sesame?"}'
HTTP/1.1 200 OKcontent-type: application/jsonx-request-id: req_34032f32637edb11content-length: 113date: Wed, 04 Nov 2026 11:43:18 GMT{"resolved_by_articles":true,"reply":"Our sourdough has no sesame in it, but it is baked beside loaves that do."}
jadpo calls lists each call, and jadpo calls show call_1 prints exactly what was sent and
what came back:
jadpo callscall operation source answer time request call_1 local_model.answer_question live + answered 6 ms req_3403calls: 1 call, 1 answerednext: jadpo calls show call_1 shows what was sent and what came back
jadpo ai usage shows the day's spending against your limit:
jadpo ai usageday: 2026-11-04 (UTC) today: 95 of 2000000 tokens · 1 call prompt model calls today this month cost answer_question meta-llama/Llama-3.1-8B-In... 1 95 95 –ai usage: 95 tokens today, 0% of the daily limit, 95 this month
On a connection through a package, jadpo dev fakes each call, from recordings and then from
generated values, unless development: { sandbox: [answer_question] } lets that prompt through
to the real model with your key.
Personal data and prompts
By default, your app may send a model no personal data at all. The compiler follows every value
in a prompt back to the fields it came from, an Email included, and refuses one whose category
isn't listed in ai: { personal_data: { allowed: [...] } }
(semantic.ai_personal_data_not_allowed). So
a field marked to_models: false never reaches a prompt, whatever is listed. A provider can't
erase one person's prompts, so once your app sends any, the connection also needs an
ErasureNotPossible.data_stays acceptance.
A model's answer is input from outside. It is checked and escaped as any user's text is, and policy never grants access because of it. For a reply that a person will rely on, keep it as a draft for someone to check. Prompt injection says what Jadpo does and doesn't protect you from.
Read more
The ai capability,
Typed output,
Prompts as files,
Testing AI calls,
Budgets and
Personal data sent to a model.
Not built yet
- Keeping prompts and answers in production with
keep_content:isn't built yet. - A package's
embed:with abatch:below 100 isn't built yet. It doesn't touchai.generate.
Markdown for agents: /guides/guide-ask-a-model-for-a-typed-answer.md.Built from Jadpo main at a4c217c. Jadpo's CI compiles this guide's code and runs its tests.