Demo
This is Ontologiq end to end: a business object defined in YAML, compiled to SQL, served to an AI agent, and an action the agent can propose but never perform alone. Every output below is real — copy the commands and you will see the same thing.
Start from the example project:
pip install "ontologiq[serve]" ontologiq-duckdb
ontologiq init shop && cd shop && ontologiq build
1. One file describes the object
objects/order.yml, in full:
object: order
description: An order placed by a customer.
source:
table: orders
identity: [order_id]
properties:
- name: total
type: decimal
column: amount_eur
- name: placed_at
type: timestamp
column: placed_at
# Declaration order is priority: a disputed order stays disputed even if
# it has not shipped yet.
state:
disputed: dispute_opened_at is not null
open: fulfilled_at is null
else: fulfilled
relations:
customer:
type: belongs_to
target: customer
actions:
- name: cancel
description: Cancel an order that has not shipped yet.
requires: state == 'open'
params:
- name: reason
type: string
required: true
policy:
roles: [support]
approval: required
audit: true
effect:
type: webhook
url: "{{ env.OPS_API }}/orders/{order_id}/cancel"
Four things are declared there that a table cannot express: a stable identity, a state derived from data rather than stored, a relation to another object, and an action with the rules that govern it.
2. The compiler turns it into SQL
target/views/order.sql, generated:
SELECT
"t"."order_id" AS "order_id",
"t"."amount_eur" AS "total",
"t"."placed_at" AS "placed_at",
CASE
WHEN NOT "t"."dispute_opened_at" IS NULL
THEN 'disputed'
WHEN "t"."fulfilled_at" IS NULL
THEN 'open'
ELSE 'fulfilled'
END AS "state"
FROM "orders" AS "t"
Ordinary SQL, in your warehouse’s dialect, with every identifier quoted —
the demo has an object called order on purpose, which is a reserved word
everywhere.
3. Give it to an AI agent
ontologiq serve --role support
The server speaks MCP over stdio. Point Claude Desktop at it by adding this
to claude_desktop_config.json:
{
"mcpServers": {
"ontologiq": {
"command": "ontologiq",
"args": ["serve", "--role", "support", "--project-dir", "/path/to/shop"]
}
}
}
The agent now sees seven tools:
get_customer, list_customer, traverse_customer_orders,
get_order, list_order, traverse_order_customer,
propose_order_cancel
Ask it “who is customer 1 and what have they ordered?” and it calls
get_customer then traverse_customer_orders:
{
"rows": [{
"customer_id": 1,
"full_name": "Ada Lovelace",
"country": "UK",
"lifetime_value": "1240.50",
"state": "active"
}],
"count": 1,
"redacted_properties": ["email"],
"note": "Properties marked sensitive in the ontology are not returned. They were never read from the database."
}
Note what is missing. email is marked sensitive: true in the ontology, so
it was never selected — the value did not leave the warehouse, and the agent
is told the field exists rather than left to hallucinate around a gap.
4. The agent proposes; it cannot act
The user asks to cancel order 102. The agent calls
propose_order_cancel, and this is the whole of what it gets back:
{
"outcome": "pending_approval",
"proposal_id": "92e207a4-18d2-41b6-a555-dcce94ad3396",
"expires_at": "2026-08-02T11:01:42.791Z",
"would": {
"type": "webhook",
"method": "POST",
"template": "{{ env.OPS_API }}/orders/{order_id}/cancel"
},
"note": "A human must approve this proposal before anything happens. You cannot approve it yourself; report the proposal id to the user."
}
Nothing happened. There is no tool on the MCP surface that can approve this, so there is no sequence of calls that gets the agent any further.
Before proposing it can also ask what would happen, with
dry_run: true — an evaluation against live data that changes nothing.
When the precondition does not hold
Order 101 has already shipped. The agent gets a refusal that names the actual state, so it can tell the user something true instead of guessing:
{
"error": "precondition_failed",
"message": "order 101 does not satisfy 'state == 'open'' (state is 'fulfilled')",
"state": "fulfilled"
}
When the role does not allow it
Start the server with --role analyst and the same call returns:
{
"error": "denied_role",
"message": "role 'analyst' may not call order.cancel (allowed: support)"
}
The warehouse was never touched. Start it with no --role at all and the
propose_ tools are not even listed — an agent cannot call what it cannot
see.
5. A human approves
The effect is a webhook — {{ env.OPS_API }}/orders/{order_id}/cancel —
so something has to receive it. The scaffold ships one:
ops/webhook_listener.py, a stdlib-only receiver that prints whatever
arrives and answers 204. In a second terminal:
python ops/webhook_listener.py
Then, in a different process, which the model has no way to reach:
export OPS_API=http://127.0.0.1:8099
ontologiq approvals list
92e207a4-18d2-41b6-a555-dcce94ad3396
order.cancel {"order_id": "102"}
params: {"reason": "customer changed mind"}
proposed by support (<your OS user>) at 2026-08-01T11:01:42.791Z, expires 2026-08-02T11:01:42.791Z
(The name in parentheses is the operating-system user that started the serving process — stdio has no authentication primitive, and the audit row records exactly that rather than pretending otherwise.)
ontologiq approvals approve 92e207a4-18d2-41b6-a555-dcce94ad3396 --note "checked with the customer"
The listener is plain http://, which works here only because the host is
loopback — 127.0.0.1 and localhost are exempt from the https-only rule,
precisely so this local loop needs no ceremony. Point an effect at any
other plain-http host and the approval is refused unless you pass
--allow-insecure-effects, the explicit override. A real deployment points
at https and never needs it.
executed: HTTP 204
The effect was applied.
At that moment, and not before, the webhook fired:
POST /orders/102/cancel
Idempotency-Key: 92e207a4-18d2-41b6-a555-dcce94ad3396
{
"ontologiq": {
"object": "order",
"action": "cancel",
"proposal_id": "92e207a4-18d2-41b6-a555-dcce94ad3396",
"actor_role": "support",
"actor_user": "fernando",
"ontology_digest": "a43e5805798610ff"
},
"identity": {"order_id": "102"},
"params": {"reason": "customer changed mind"}
}
Your system performs the cancellation. Ontologiq decided that it was allowed to, and recorded that it happened.
6. The trail
ontologiq audit --proposal 92e207a4-18d2-41b6-a555-dcce94ad3396
2026-08-01T11:01:42.791Z proposed order.cancel support pending_approval
2026-08-01T11:01:51.992Z approved order.cancel support
2026-08-01T11:01:52.038Z effect_intent order.cancel support
2026-08-01T11:01:52.135Z effect_outcome order.cancel support executed
The intent row is written before the call, so a process that dies mid-request still leaves evidence that something was attempted.
7. No agent required
The ontology is the operational layer for humans too. The same action the agent proposed, initiated from the terminal — same role check, same live precondition, same queue:
ontologiq propose order cancel --key 102 \
--param "reason=customer changed mind" --role support
pending approval: 41cf0f37-cabc-4c60-9f5a-1c5741bafcb5
a human approves with:
ontologiq approvals approve 41cf0f37-cabc-4c60-9f5a-1c5741bafcb5
Proposing and approving stay separate verbs on purpose, even when both are you: the queue is one place where every intent — human or agent — waits for a signature, and the audit trail reads the same either way.
8. The part that matters most
The demo listener only prints — it does not change the data — so order 102
is conveniently still open. Use the proposal you just made, and let the
order ship while it waits. Stop serve and the workbench first if they
are running — a DuckDB file admits one writer or many readers, and this
UPDATE is the writer:
python -c "import duckdb; duckdb.connect('ecommerce.duckdb').execute( \
\"UPDATE orders SET fulfilled_at = now() WHERE order_id = 102\")"
ontologiq approvals approve 41cf0f37-cabc-4c60-9f5a-1c5741bafcb5
the precondition 'state == 'open'' no longer holds (state is now 'fulfilled')
— nothing was executed
The precondition is evaluated twice: once when proposing, so the proposer gets a useful answer immediately, and again at execution, because hours can pass and the answer may have changed. The proposal you approved at 14:00 was true at 09:00 — Ontologiq notices it stopped being true, refuses, and records the refusal in the audit trail. That gap between deciding and acting is where approval systems usually leak, and closing it is most of why the runtime exists.
To rewind the demo data:
python -c "import duckdb; duckdb.connect('ecommerce.duckdb').execute( \
\"UPDATE orders SET fulfilled_at = NULL WHERE order_id = 102\")"
9. Watch all of it, live
ontologiq workbench --open
One page on localhost: the entity graph, order 102 with its computed state as of right now, a dry-run console that evaluates role and precondition without proposing anything, the approval queue with the exact terminal command next to each pending proposal, and the audit trail as it grows. Deliberately read-only: the browser informs the decision, the terminal makes it.
10. The artifact for whoever signs off
Everything the agent could and could not do above was declared in one YAML file. Render that declaration as a page for the people who will never read YAML — security, compliance, the owner of the system your webhook touches:
ontologiq docs
target/docs/index.html is a single self-contained file: the entity graph,
every state with the predicate that computes it, each governed action with
its roles and approval gate, and a role selector showing exactly which
tools an agent sees — flip it to “no role” and watch the propose_ tools
vanish, which is precisely what serve does. No warehouse data is read;
the page is safe to commit, diff in a pull request, or email.
Where to go next
- Concepts — the model behind what you just saw.
- Security model — what is enforced, and what is merely declared.
- MCP reference — every generated tool, in detail.