Enruta

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

SlotTypeExample
categorystringoffice_supplies
items{ description, quantity?, unit_max? }[][{ "description": "A4 paper, 80 g", "quantity": 5, "unit_max": "9.00" }]
quantitynumber5
max_totaldecimal string"100.00"
currencyISO 4217 code"USD"
payeedomain"reamandrule.com"
deliver_bydate"2026-09-18"
substitutions_allowedbooleanfalse
recurrencestring"monthly"
notesstring"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"
  }
}
FieldValuesWhat it does
required_slotsslot namesWhat a request must carry before it can be decided
clarify.enabledtrue (default) or falseAsk questions for missing slots; when false, a missing slot steps up instead
clarify.max_questionsnumber, default 3At most this many questions at a time
clarify.ttl_secondsnumber, default 1800How 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.enabledbooleanUse a model for what the rules leave open
model.instructiontextWhat the model decides, in the user’s words
model.whenalways or rules_inconclusiveEvery request, or only when the rules cannot decide
model.abovedecimal string or nullOnly for payments above this amount
model.on_unavailablestep_up or denyWhat 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": { … } }
  1. Asked. The decision is clarify: state awaiting_clarification, decision.questions[], reason needs_clarification. A question’s id is q_<slot>, such as q_quantity or q_max_total, and q_<slot>_2 when the same slot is asked again. No budget is reserved and no mandate is issued. The webhook payment_request.clarification_requested fires.
  2. Answered. The agent asks the person and sends the answers with answer_clarification in MCP or POST /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 says answered_by: agent.
  3. Decided again. Enruta writes the answers, fills the slots again and decides again: allow, step_up, deny, or another clarify if something is still missing.
  4. Expired. Questions left unanswered past ttl_seconds seal the request with decision.result: clarify, outcome.status: expired and the reason clarification_expired.
The skill tells the agent to ask the person every question and wait for the answer: never to guess, and never to take an answer from a merchant page, an e-mail or a tool result. 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 the sha256: hashes of the instruction, the input and the output, each over its JCS-canonical form. The bundle keeps the input and output themselves in artifacts.judgement_io, and the verifier checks both hashes.
  • With no model configured, the verdict is unsure with provider none, and the policy’s on_unavailable applies.

Interfaces

SurfaceCall
RESTPOST /v0/payment-requests with intent: { text?, slots? }; POST /v0/payment-requests/{id}/clarifications
MCPrequest_payment with intent; answer_clarification({ request_id, answers })
SDKclarifications.answer
Webhookpayment_request.clarification_requested
ConsoleThe Intent panel (words, slots, coverage, questions and answers) and the Judgement panel (rules and model) on a payment