Skip to content
ONTO

Ontology Extraction Explained: The Four Levels Every Agent SDK Should Have

Pulling typed facts from natural language is half the problem. The other half is knowing which level the fact lives at. Here's the four-level model and why every agent SDK should have one.

· 8 min read · ONTO team

When an agent reads “I’m Alice, in UTC, I prefer mornings, and right now I’m in Lyon planning a trip,” what should happen? Most agent SDKs store the whole thing as a chat turn and move on. The next session, the agent has forgotten everything.

A smarter agent extracts typed facts from that sentence. But where do those facts go?

  • “Alice’s timezone is UTC” → durable; should survive forever.
  • “Alice prefers mornings” → durable; should survive forever.
  • “Alice is currently in Lyon” → transient; should evaporate when the trip ends.

Three different lifecycles in one sentence. A single “memory” bucket can’t hold them right. You need levels.

The four levels

ONTO’s extraction routes assertions to one of four levels:

Session. Lives within the current session. Examples: current location during a trip, current intent (“rebook”), current document being worked on. Evaporates at session end unless promoted.

Profile. Durable identity. Examples: timezone, name, employer, preferences, primary language. Long decay; survives across sessions.

Domain. App-scoped facts in a particular domain. Examples: a customer’s tier in your CRM, a patient’s allergy list, a research paper’s claim about drug X. Scoped by domain_id (tenant, matter, project).

Tool. Tool-local state. Examples: a saved search filter, a pinned dashboard, a configured threshold. Belongs to the tool’s behavior rather than to the user or domain.

Each level has its own decay curve, retention policy, and consent model. Each is queried by a different lookup pattern. Each has different audit obligations.

The extraction flow

When you set extract=True on a run:

  1. The LLM reads the user input + relevant memory.
  2. It proposes a list of typed assertion candidates, each with a level label.
  3. The runtime audits the routing (does the assertion’s content match the level’s expected shape?).
  4. Policy Guard checks consent scopes for the writes.
  5. Assertions are persisted to the appropriate stores.
result = onto.run_sync(
    "Hi, I'm Alice. UTC, mornings. Currently in Lyon planning a trip.",
    user_id="alice",
    consent_scopes=["memory:write"],
    extract=True,
)

for ea in result.extracted_assertions:
    print(f"{ea.level:8} {ea.subject}.{ea.predicate} = {ea.object!r}")
# session  alice.current_location = "Lyon"
# session  alice.current_intent   = "plan_trip"
# profile  alice.timezone         = "UTC"
# profile  alice.prefers          = "mornings"

Four assertions, four levels, each routed correctly.

Why session vs profile matters

Imagine the user wrote “I’m in Lyon” and you stored it as a profile fact. Six months from now the agent thinks Alice lives in Lyon. The reverse — storing “Alice’s timezone is UTC” as a session fact — would mean the agent forgets her timezone next session.

The level is the decay policy. Conflating them is the underlying cause of “the agent doesn’t really remember me” complaints.

Why domain matters

Domain-level extraction is what makes multi-tenant memory clean. A customer-support agent extracts assertions about Tenant A’s customer; those go into the domain="tenant_A" partition. Tenant B’s data lives in domain="tenant_B". Cross-domain reads refuse.

This is also how research-agent corpus claims work. A claim about drug X42 goes into domain="oncology_progression"; a claim about a different topic lives elsewhere. The graph stays sane.

# Healthcare: each patient is a domain.
onto.run_sync(
    intake_text,
    user_id="patient_a912",
    domain_id="patient_a912",
    consent_scopes=["memory:write", "patient:intake"],
    extract=True,
)

# Research: each field is a domain.
onto.run_sync(
    paper_text,
    user_id="researcher_lim",
    domain_id="oncology_progression",
    consent_scopes=["memory:write", "domain:claims:write"],
    extract=True,
)

The same SDK, the same call shape, different domain partitioning.

Why tool matters

The tool level is the one people forget. It holds the tool’s state, not the user’s. Examples:

  • A saved search filter the user configured in the search tool.
  • A pinned dashboard arrangement in the analytics tool.
  • A custom retry threshold a workflow uses.

These aren’t user facts (you wouldn’t query “what is Alice’s timezone” by going to tool-level memory). They aren’t domain facts (they’re independent of the domain). They’re configuration that belongs to a tool.

Storing them in tool-level memory means a single user can have tool configurations that survive across sessions without polluting their profile or any domain.

The extraction prompt

The LLM has to label each candidate assertion with a level. The extraction prompt is where the labeling is taught. A minimal one:

You are extracting typed assertions from user messages. For each fact you find,
output an assertion with one of four levels:

- session: transient context (current location, current intent, current task)
- profile: durable identity (timezone, preferences, name, employer)
- domain: app-scoped facts (customer tier, patient allergy, research claim)
- tool:    tool-configuration state (saved searches, pinned views, thresholds)

Output JSON. Be conservative: prefer "session" over "profile" when unsure.

The “prefer session over profile when unsure” is important. Wrong-level “profile” assertions accumulate; wrong-level “session” assertions just evaporate. Bias toward the recoverable error.

Conflicts within a level

Within a level, two assertions on the same (subject, predicate) create a conflict. The runtime surfaces this as a Plan Mode task:

Conflict in profile:
  alice.timezone = "UTC" (March 2026)
  alice.timezone = "JST" (May 2026)
  Supersede or merge?

The resolution decides whether the new fact supersedes the old (Alice moved) or whether they coexist (Alice is in two timezones — possible for a digital nomad).

When you don’t extract

extract=True is opt-in. A read-only chat agent skips it. A one-shot Q&A doesn’t need it. The decision is per-call.

For high-volume background ingestion (e.g., a research corpus), use extract_async=True to spawn the writes to a background task. The next ingestion call starts immediately.

What if your SDK doesn’t have levels?

Build them as a wrapper. Tag every assertion you write with level=session|profile|domain|tool and enforce the routing in your application code. The LLM still labels; your code still partitions. You lose runtime enforcement but gain the architectural discipline.

Or pick an SDK that ships this. ONTO is one; we expect others to follow as memory-typing matures.

Summary

The four-level model — session, profile, domain, tool — is the right vocabulary for routing typed assertions. Without it, all memory ends up as one flat bucket and the agent can’t tell what should survive a session vs what should evaporate.

It’s not magic. It’s just a small architecture pattern that, applied consistently, makes agent memory actually work over time.


The features page covers extraction in detail. The thesis on agent memory explains the underlying argument.

Frequently asked questions

Why four levels and not three or five?

Empirically these are the four scopes that survive the simplification test. Session, profile, and domain are obvious; tool is the one people forget — facts that belong to the tool's internal state (a saved search, a pinned filter) rather than the user or domain.

Does the LLM always pick the right level?

Usually. Where it's wrong, the runtime audit catches it and you can correct the extraction prompt. Wrong routing is a bug class that surfaces during eval, not silently in production.

Can I add more levels?

Not currently — the four are baked into the routing logic. The right way to extend is via tags within a level (e.g., domain assertions can carry sub-domain tags). We may make levels pluggable post-1.0.

Build with ONTO

The agent SDK where humans drive the state.

Plan Mode, typed memory, per-call consent scopes, and open-weight defaults. Open source under MIT or Apache-2.0.