Docs · Concepts
Intent and clarification
LiveUpdated
Agents. Written for people who build agents, the hosts they run in, and the people and companies the agents pay for.
An agent pays for what a person asked. request.intent keeps the person’s words, the slots taken from them, the slots the policy needed, and every question asked to fill a gap. decision.judgement keeps how the request was judged: rules first, a model only for what rules cannot decide.
Sending intent
request_payment({
payee: { name: "Ream & Rule", domain: "reamandrule.com" },
amount: { value: "42.50", currency: "USD" },
purpose: "Printer paper for the office",
checkout: { protocol: "ucp", reference: "chk_01M3B6…" },
intent: {
text: "Buy printer paper for the office",
slots: { category: "office_supplies", substitutions_allowed: false }
}
})intent.text is the person’s words, verbatim. intent.slots is what the agent already knows, in the policy’s vocabulary. Both are optional, and the 0.2 shape (intent_text, constraints) is still accepted.
The slots
| Slot | Type | Example |
|---|---|---|
category | string | office_supplies |
items | { description, quantity?, unit_max? }[] | [{ "description": "A4 paper, 80 g", "quantity": 5, "unit_max": "9.00" }] |
quantity | number | 5 |
max_total | decimal string | "100.00" |
currency | ISO 4217 code | "USD" |
payee | domain | "reamandrule.com" |
deliver_by | date | "2026-09-18" |
substitutions_allowed | boolean | false |
recurrence | string | "monthly" |
notes | string | "No recycled paper" |
Slot names are the policy’s vocabulary: required_slots and rules refer to them. Enruta fills the slots from intent.slots, from 0.2 constraints (max_amount becomes max_total, the first entry of categories becomes category), and from the checkout’s line items (items, quantity, payee and the currency).
Policy: the intent block
"intent": {
"required_slots": ["quantity", "max_total"],
"clarify": { "enabled": true, "max_questions": 3, "ttl_seconds": 1800 },
"rules": [
{ "slot": "max_total", "op": "lte", "value": "200.00", "on_fail": "step_up" },
{ "slot": "category", "op": "in", "value": ["office_supplies"], "on_fail": "deny" }
],
"model": {
"enabled": true,
"when": "rules_inconclusive",
"instruction": "Decide whether every line item is an office supply for a small robotics company.",
"above": null,
"on_unavailable": "step_up"
}
}| Field | Values | What it does |
|---|---|---|
required_slots | slot names | What a request must carry before it can be decided |
clarify.enabled | true (default) or false | Ask questions for missing slots; when false, a missing slot steps up instead |
clarify.max_questions | number, default 3 | At most this many questions at a time |
clarify.ttl_seconds | number, default 1800 | How long the questions stay open |
rules[] | { slot, op, value, on_fail } | op is lte, gte, eq, in, not_in or matches; on_fail is step_up or deny |
model.enabled | boolean | Use a model for what the rules leave open |
model.instruction | text | What the model decides, in the user’s words |
model.when | always or rules_inconclusive | Every request, or only when the rules cannot decide |
model.above | decimal string or null | Only for payments above this amount |
model.on_unavailable | step_up or deny | What happens when no model answers |
Order of checks
The intent checks run in the same evaluation as every other check, each reported in decision.checks[] and in decision.judgement.rules: intent_coverage first (a missing required slot gives clarify, or step_up when clarifying is off), then agent_identity and payee_identity, then intent_rules, then the policy checks that existed before, and intent_model last. A model verdict of deny denies with model_judgement_deny; step_up or unsure steps up; no model available applies on_unavailable with model_unavailable.
Clarify, step by step
The person said “Buy printer paper for the office”, and the policy requires quantity and max_total. The checkout holds five packs, so quantity is filled; nothing says how much may be spent.
POST /v0/payment-requests → 201
{
"id": "preq_01M3B7…",
"state": "awaiting_clarification",
"decision": {
"result": "clarify",
"reasons": ["needs_clarification"],
"questions": [{ "id": "q_max_total", "slot": "max_total", "question": "What is the most I may spend in total?" }]
},
"mandate": null
}
POST /v0/payment-requests/preq_01M3B7…/clarifications
{ "answers": [{ "question_id": "q_max_total", "answer": "100.00" }], "answered_by": "user" }
→ the request, decided again: { "state": "decided", "decision": { "result": "allow", … }, "mandate": { … } }- Asked. The decision is
clarify: stateawaiting_clarification,decision.questions[], reasonneeds_clarification. A question’s id isq_<slot>, such asq_quantityorq_max_total, andq_<slot>_2when the same slot is asked again. No budget is reserved and no mandate is issued. The webhookpayment_request.clarification_requestedfires. - Answered. The agent asks the person and sends the answers with
answer_clarificationin MCP orPOST /v0/payment-requests/{id}/clarifications. Only the request’s agent or a developer key may answer. Answers count as the user’s unless the body saysanswered_by: agent. - Decided again. Enruta writes the answers, fills the slots again and decides again:
allow,step_up,deny, or anotherclarifyif something is still missing. - Expired. Questions left unanswered past
ttl_secondsseal the request withdecision.result: clarify,outcome.status: expiredand the reasonclarification_expired.
answered_by: agent is only for a detail the person told the agent to decide, and the record shows who answered each question.What the record keeps
"request": {
…,
"intent": {
"text": "Buy printer paper for the office",
"slots": { "category": "office_supplies", "items": [{ "description": "A4 paper, 80 g", "quantity": 5 }], "quantity": 5,
"max_total": "100.00", "currency": "USD", "payee": "reamandrule.com" },
"coverage": { "required": ["quantity", "max_total"], "filled": ["quantity", "max_total"], "missing": [] },
"clarifications": [
{ "id": "q_max_total", "slot": "max_total", "question": "What is the most I may spend in total?", "answer": "100.00",
"answered_by": "user", "asked_at": "2026-09-14T09:20:11Z", "answered_at": "2026-09-14T09:21:40Z" }
],
"mapping": { "by": "enruta", "at": "2026-09-14T09:21:40Z" }
}
},
"decision": {
"result": "allow",
"judgement": {
"rules": [
{ "name": "intent_coverage", "result": "PASS" },
{ "name": "agent_identity", "result": "PASS", "detail": "A2" },
{ "name": "payee_identity", "result": "PASS", "detail": "verified" },
{ "name": "intent_rules", "result": "PASS" }
],
"model": null
}
}The model
- Called only when the policy enables it and its conditions (
when,above) hold. Otherwise nothing is sent to any model. - It sees the policy’s instruction, the slots, the payee and the line items. Never credentials, keys or the user’s e-mail address.
- Temperature 0, a JSON answer checked against a schema, an 8-second timeout. Verdicts:
allow,step_up,deny,unsure. - The record keeps
judgement.model: the provider, the model, the verdict, a rationale, a confidence, and thesha256:hashes of the instruction, the input and the output, each over its JCS-canonical form. The bundle keeps the input and output themselves inartifacts.judgement_io, and the verifier checks both hashes. - With no model configured, the verdict is
unsurewith providernone, and the policy’son_unavailableapplies.
Interfaces
| Surface | Call |
|---|---|
| REST | POST /v0/payment-requests with intent: { text?, slots? }; POST /v0/payment-requests/{id}/clarifications |
| MCP | request_payment with intent; answer_clarification({ request_id, answers }) |
| SDK | clarifications.answer |
| Webhook | payment_request.clarification_requested |
| Console | The Intent panel (words, slots, coverage, questions and answers) and the Judgement panel (rules and model) on a payment |