The intent engine accepts one user message and returns a small transport intent. It does not return a route answer. That distinction is visible in the API response type and in the call graph: inference produces JSON, compileAssistantIntent removes unsupported or invented fields, and validateIntent checks whether the request contains enough context for the client to execute it.

The engine has one responsibility at each boundary. The prompt tells the model which intent and fields are legal. The JSON schema rejects an unexpected shape. The compiler checks that a copied field came from the message. Semantic validation checks location, selected-place, prior-result, and known-route requirements. The client then calls the planner, place resolver, fare logic, live service, or curated FAQ.

Prompt assembly is versioned input

The prompt is seven Markdown sections imported by the API and joined in a fixed order. The sections cover role, intents, slots, language, follow-ups, safety, and helpfulness. The request adds the UI language and, when present, the result type of the previous structured response.

example.ts
1 return (
2 sections.join("\n") +
3 "\n" +
4 "uiLanguage=" +
5 input.uiLanguage +
6 ". " +
7 followUpSentence
8 )

Source: prompt/assemble.ts. The version is logged with every inference request through PROMPT_VERSION. This matters when a benchmark result says prompt version ai-intent-v5 while the current source reports ai-intent-v11. The two measurements cannot be silently treated as the same prompt.

The provider request sets temperature to 0, limits completion to 256 tokens, and asks for the assistantIntentJsonSchema with additionalProperties: false. The model sees the user's message as JSON data after an instruction not to answer it. It is asked to extract, not to reason about the transit network.

Compilation is where copied text becomes a contract

Model output is not accepted as-is. The compiler copies a place or route field only when the value occurs in the original message. It removes punctuation at the end for matching, but returns the original slice, preserving case, abbreviations, spelling, and a prefix such as “a place called.”

example.ts
1 const index = messageLower.indexOf(candidateLower)
2 if (index < 0) return undefined
3
4 const end = index + candidate.length
5 const before = message.slice(0, index)
6 const namedPlacePrefix = before.match(/a place (?:called|named) $/iu)
7 const start = namedPlacePrefix ? index - namedPlacePrefix[0].length : index
8 return message.slice(start, end)

Source: intent-contract.ts. This guard is what turns “North Star Moon Base” into a place candidate for deterministic resolution, while preventing a model from adding a place that the rider never named.

The compiler also handles the awkward shapes that appear in real messages. It maps a route code returned in destinationText to routeText for a live-status request. It treats “Pila ang pamasahe gikan SPMC?” as an origin-only fare request rather than inventing a destination. It ignores a negative mode mention such as “without a crowded jeepney” instead of turning it into a jeepney preference.

Semantic validation owns request context

The shared contract package performs checks that a model cannot perform from text alone. A nearest-stop request needs a location. A trip needs an origin and destination, or a selected place that supplies one side. A live-status request needs a route field or a known route in the previous result. A fare follow-up needs a prior trip or fare result with both endpoints.

The trip check is intentionally small:

example.ts
1 const hasOrigin =
2 intent.usesCurrentLocation ||
3 Boolean(intent.originText) ||
4 Boolean(request.selectedPlaceId && intent.destinationText)
5 const hasDestination = Boolean(
6 intent.destinationText || request.selectedPlaceId
7 )
8 if (!hasOrigin || !hasDestination) {
9 return { kind: "result", result: { kind: "unknown" } }
10 }

Source: packages/contracts/src/intent.ts. If the intent uses current location but the request has no coordinates, the validator returns needs_location. The validator does not geocode a place, search the graph, or infer a missing endpoint.

The API orchestrator adds one more boundary. Known action IDs produce fixed intents without inference. A free-text request calls the provider once, compiles the result, validates it, and returns one reply. An invalid model response becomes unknown after that one attempted call. This is why the assistant is a front door rather than a second orchestration engine.

Network and failure boundaries

The API rejects a body larger than 16,384 bytes before inference. The provider gives inference 10 seconds. The route gives the complete assistant request 12 seconds. Responses are marked no-store, because an intent or an unavailable state is request-specific.

Rate limiting uses the Cloudflare AI_RATE_LIMITER binding with an IP key and an optional client key. A process-local map would only limit one isolate and would not be a production-wide decision. If the binding is absent or unavailable, the route returns an unavailable response instead of pretending to have enforced the limit.

The provider also records the model ID, prompt version, schema version, request ID, duration, and model-call count in its structured log event. That gives a failed extraction a traceable boundary without logging the rider's place text as part of the outcome event.

Contract history

The assistant entered the API in b69c2ba. Evaluation followed in ab58004. The later sequence 3e4c7e7, dd9c446, and ef1641b centralizes shared contracts, simplifies semantic validation, and expands evaluation coverage. The direction is clear in the code: more authority moved into typed and deterministic boundaries, while the model's output surface stayed small.

The model provider remains replaceable. The intent contract is not a model feature. It is the interface between uncertain language and deterministic transit behavior, so changing a model must not change what the planner, live tracker, or fare logic is allowed to believe.