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