Your agent doesn't know what a Booking
is. It can guess β and usually guess well β but the moment it composes a tool call, validates an action, or renders a result for a human, the guess is the bug.
An ontology gives the model a typed contract of your domain: what exists, what relates to what, what's allowed. It also explains why bolting one onto a normal app goes badly, and how to make it stick this time.
The trouble starts when you wire an ontology into a regular codebase. You end up maintaining two contracts: one for the agent (RDF triples, JSON-LD contexts, SHACL shapes) and one for the UI (props, components, validation). Drift is inevitable. The agent produces a Reservation
, the API exposes a Booking
, the UI expects a ReservationViewModel
. Six months in, half your team is writing adapters between layers that were supposed to agree. The schema isn't the problem; having two schemas is.
This is the thesis: the schema that grounds your agent should be the same file that types your UI. When that's true, the ontology stops being a research artifact and starts being a product surface.
A language model treats every prompt as a fresh string of tokens. It has no first-class notion of types, identity, or relations β only statistical resemblance. An ontology hands it something it can lean on: a vocabulary with explicit semantics, a graph of typed relations, and a set of constraints.
Concretely:
{
"@context": {
"@vocab": "https://schema.otf-kit.dev/booking#",
"schema": "https://schema.org/",
"Customer": "schema:Person",
"startsAt": { "@id": "schema:startDate", "@type": "schema:DateTime" },
"endsAt": { "@id": "schema:endDate", "@type": "schema:DateTime" }
},
"@type": "Booking",
"@id": "booking:42",
"name": "Sauna slot β Friday",
"Customer": { "@id": "person:7", "name": "M. Vargas" },
"startsAt": "2026-06-05T18:00:00Z",
"endsAt": "2026-06-05T19:00:00Z"
}
Two things matter here. First, the @context
declares the schema the agent must respect β that's the contract. Second, every field is a URI, which means a SPARQL endpoint, a SHACL validator, and your component layer can all consume the same shape without translation. The agent isn't inventing structure; it's filling in a typed form.
The win isn't "AI understands your data." The win is that the agent's mistakes become recoverable. A malformed startsAt
fails SHACL. A missing Customer
fails the schema. A hallucinated field is rejected before it reaches a user.
The pitch lands. Engineering starts. Three things break first:
Reservation
becomes the API's Booking
becomes the UI's ReservationViewModel
. Each layer invents its own type aliases because nothing forces convergence.These aren't exotic problems β they're the same integration problems every typed system hits when it meets an untyped neighbour. The fix is the fix it's always been: pin the contract at the seam and make every consumer read from it.
Stop storing the schema in two places. Put it in one file and have both the agent and the UI read it. The agent reads it as a JSON-LD context for grounding; the UI reads it as the type system for components.
// schema.otf.ts β the file every layer imports
export const BookingSchema = {
"@context": "https://schema.otf-kit.dev/booking",
"@type": "Booking",
fields: {
id: { type: "id", required: true, uri: "schema:identifier" },
name: { type: "string", required: true },
customer: { type: "ref:Person", required: true, uri: "schema:customer" },
startsAt: { type: "datetime", required: true, uri: "schema:startDate" },
endsAt: { type: "datetime", required: true, uri: "schema:endDate" },
status: { type: "enum", values: ["pending","confirmed","cancelled"] },
},
} as const;
The agent gets the @context
and field map as system instructions. The component layer gets the same map as TypeScript types. The schema file is the source of truth; the build emits two artifacts from it β one for the model, one for the runtime.
[[DIAGRAM: schema.otf.ts β JSON-LD context for the agent, TS types for the UI, SHACL shape for validation β all three consumers reading one file]]
A SPARQL endpoint can still back the data; the UI never speaks SPARQL. The schema file becomes the projection specification β a typed view of the graph, not the graph itself.
If the schema is the contract, SHACL shapes are the contract's immune system. They reject malformed graphs before they enter your pipeline. The point is to run validation at the boundary the agent crosses, not deep inside the app.
ex:BookingShape a sh:NodeShape ;
sh:targetClass ex:Booking ;
sh:property [
sh:path schema:startDate ;
sh:datatype xsd:dateTime ;
sh:minCount 1 ;
] ;
sh:property [
sh:path ex:status ;
sh:in ( "pending" "confirmed" "cancelled" ) ;
] .
The agent produces JSON-LD; a SHACL validator accepts or rejects it. The component layer trusts what the validator returns. Mobile clients carry only validated payloads β which is why a 200-row list stays small and predictable, around 200β400 tokens per record, and renders fast.
This is where the win compounds: validation, type-checking, and rendering are all reading the same schema. There's no second source of truth to drift.
Once the UI binds to typed entities instead of freeform props, the cross-platform problem collapses. A Booking
is a Booking
on web, iOS, and Android β same field names, same validation, same status enums. The component layer reads the schema and renders the entity; the surface adapts.
<BookingCard
booking={entity} // type: Booking (from schema.otf.ts)
onCancel={() => mutate(status)} // status enum from schema
/>
There's no <BookingCardWeb>
and <BookingCardNative>
with different field expectations. When the schema gains a cancellationReason
field, the type system flags every consumer β including the agent's tool definitions β and the component renders it without a separate PR to each platform. The same component name + props + look render on every surface from one codebase.
[[COMPARE: freeform props invented per platform vs schema-bound entities locked to one file]]
Here's the part that doesn't change when the model does. The schema file outlives any particular LLM. The component layer outlives any particular agent framework. The validation rules outlive any particular tool runner.
This is the seam: the agent is a consumer of the schema, the UI is a consumer of the schema, and the schema is the durable surface. Tooling churns β today's model, tomorrow's MCP server, next quarter's orchestrator β but the contract is the contract.
When the AI config lives next to the schema β the same place the component layer reads its types from β agents extend the kit instead of regenerating it. A documented CLAUDE.md
, a .cursorrules
, and a fixed set of tested ai/prompts/
give the agent the same grounding the schema gives the runtime. The agent and the app end up reasoning over the same world.
[[CONCEPT: the schema file as the single source of truth β one file, two consumers, zero drift]]
That's the part that doesn't churn. You swap the model, the schema stays. You swap the agent framework, the schema stays. The components render whatever the schema says is renderable, on whatever surface you ship to.
Three concrete payoffs when the schema is the contract:
startsAt
before it hits a user. The UI never has to defend against bad data because bad data never arrives.cancellationReason
updates the schema, the agent's tool definitions, the SHACL shape, and the component types in one move. The diff is local; the blast radius is one file.The semantic web isn't a research pivot β it's a typed contract pattern that was waiting for agents. The hard part isn't the ontology. The hard part is making the schema the source of truth for every layer that touches it. That's the part most teams skip, and it's the part that costs them six months later.
Pin the contract. Let everything else churn.