A map, not a knowledge dump.

We embed an LLM agent in our product — a work-management platform: projects, work items, reported data. A user opens the assistant inside a project and asks: “which items are at risk before their deadline?” Answering that takes knowledge no foundation model has: what a work item is in this product, that deadlines and assignees live on it, which workflow states exist, and that display names come from configuration — so echoing a raw id back at the user is a bug, not an answer.

The obvious move is to paste all of that into the system prompt. Everybody’s first harness does this, ours included. It fails slowly, then suddenly: the system prompt is re-rendered on every iteration of every turn — in our harness literally so, buildSystemPrompt runs once per LLM call — so every character of standing context is a tax you pay for the lifetime of the conversation. Domain knowledge only grows. Paste-everything doesn’t scale, and the usual escape hatch — truncating history when things get tight — throws away exactly the context an agent needs most.

This series is about the third option, the one our harness commits to everywhere: don’t truncate, defer. Keep an index in the standing context and make the payload something the model pulls on demand — pointers, not payloads. Applied five times, at five different altitudes:

  1. Ontology (this post) — what the domain is
  2. Tool discovery — what the agent can do
  3. Values by reference — the data flowing through it
  4. Memory — what the agent remembers
  5. Skills — what users teach it

Cost is only half the argument. A context window is also an attention budget: the more that’s in front of a model, the worse it uses any one piece of it, and irrelevant schemas and reference text don’t just cost tokens — they compete with the task. What makes deferral work is that it is not omission: everything stays reachable by a stable address, so shrinking the standing context removes noise from the turn without removing information from the system. Less context is more accuracy, exactly as long as nothing necessary becomes unreachable — and keeping the index rich enough that the model knows what to pull is what the mechanisms in this series are careful about.

Everything here is from the production harness in our backend — the mechanisms, the code, and the numbers, which are measured from its source using the rough conversion of one token per four characters.

IN CONTEXT, EVERY TURN — THE INDEX ON DEMAND — THE PAYLOAD Domain ontology 13 root-domain one-liners · ≈340 tokens Concept graph 15 domains · 28 entities · 12 relationships explore_ontology Tool surface namespace line + scaffolding + active set Tool catalog 169 tools · ≈85k chars of schema search_toolsactivate_tool Skill catalog id — title: description per skill Skill bodies full instructions, injected on use activate_skill Memory digest capped index of {key, description} Memory bank capped records, key-addressed recall_memories Tool results bounded previews + $ref stubs Blob store unbounded bytes, server-side $ref resolution
The shape of the whole series: the standing context holds five small indexes; every payload lives behind a pull. This post is the first row.

A graph, not a document

The ontology is not prose. It’s a typed graph: 15 domains (bounded contexts like work-items, statuses, data-points), 28 entities, each owned by exactly one domain, and 12 relationships with ids derived as <from>.<predicate>.<to>:

export const WORK_ITEM_ASSIGNED_TO_MEMBER = new Relationship(
    WORK_ITEM,
    "assigned-to",
    MEMBER,
    "A work item is assigned to a member responsible for it."
);
projects work-items labels statuses members-roles data-points belongs-to labeled-with assigned-to member-of measures project work-item label status status-group member data-point concept status · note (drill-in only) “Users may call statuses ‘stages’ or ‘columns’.”
An excerpt of the graph — ids and structure from the production ontology. Entities live inside their owning domain; relationships may span domains. Entities can carry notes — operational knowledge that ships only when something drills in. (The note text is an illustrative stand-in.)

Two design rules do a lot of quiet work here.

First, references into the graph are validated, not stringly-typed. Anything that tags itself with a concept — a tool declaring semantics = [WORK_ITEM], a memory scoped to an entity — is checked against the ontology, so a bad id fails at authoring time instead of silently matching nothing at runtime. The dependency is strictly one-way: tools point into the ontology; the ontology has never heard of a tool. And a tool’s domain membership is derived from its tags, never authored, so it can’t drift out of sync.

Second, knowledge lives once. Entity notes — the operational gotchas: the words users actually say instead of the entity’s official name, the lookups to perform before presenting something, the edits that silently fail — are data on the entity, rendered wherever the entity is rendered. The interface docs are explicit that notes are “never hand-written into prompt strings”. There is exactly one place to fix a wrong fact.

What actually ships every turn

Here is the entire standing footprint of the ontology, from Ontology.toPrompt():

public toPrompt(): string {
    const roots = this.domainList
        .filter((d) => d.getParent() === null);
    if (roots.length === 0) return "";
    const lines: string[] = ["Domains in this scope:"];
    for (const domain of roots)
        lines.push(`- ${domain.id}${domain.description}`);
    return lines.join("\n");
}

Root domains only, one line each. The in-code comment states the intent better than I could: deliberately brief — a standing orientation (valid ids and where things live), not a knowledge dump. Before rendering, forScope filters the graph down to what the current scope can even mean: domains that can’t apply where the agent is standing are dropped wholesale, and whatever the user is currently looking at — the scope references passed to the agent — pulls its owning domains in. A typical scope leaves 13 lines:

Standing section 13 root one-liners 1,356 chars ≈ 340 tok Full-graph dump 28 entities + notes + 12 relationships ≈7.5k chars ≈ 1.9k tok
Measured from the ontology source: the standing section vs. rendering the full graph (all entity descriptions, notes and relationships) into the prompt. The full dump would also grow with every entity we add; the index grows only with domains.

A five-and-a-half-times reduction is nice, but it’s the smallest number in this series. The point of starting here is that the ontology establishes the pattern — and, as we’ll see, the coordinate system — that the bigger savings in parts 2 and 3 are built on.

Depth is a tool call

The model reaches everything below the root index through one always-available tool, explore_ontology, with three modes:

In the system prompt — every turn 1,356 chars ≈ 340 tokens ## Domain ontology Domains in this scope: - work-items — Work items (tasks/issues): create, edit, move, assign, set status, … (+ 12 more one-liners; sub-domains, entities, notes and relationships all absent) explore_ontology({ entities: ["work-item"] }) On drill-in — the entity glossary, notes included paid once, when pulled - work-item — A task/issue tracked in a project: has a type, status, optional assignee, deadline and labels. Note: “Users may call a work item a ‘task’ or a ‘ticket’.” Note: “Type display names come from configuration — resolve before presenting.” explore_ontology({ query: "deadline" }) On search — BM25 over ids and descriptions paid once, when pulled → work-item — matched on “…status, optional assignee, deadline and labels” → or noMatch: true — the model learns where the vocabulary ends
Progressive disclosure of the domain model. The standing 340 tokens are the only recurring cost; glossaries, notes and search results are paid once, in the turn that needs them. (Wording lightly generalized; the notes shown are illustrative.)

Calling it with no arguments returns the full platform domain map — the escape hatch for tasks that reach beyond the current scope. Erroring on the empty call was explicitly rejected in the source: it “would just force a guess.” Passing ids drills in: a domain returns its entities and relationships plus the neighbouring domains reachable through them; an entity returns its description, its notes, and every relationship touching it. Passing a query runs BM25 keyword search over ids and descriptions, so “deadline” finds work-item through its description without the model knowing the id.

One asymmetry is worth pausing on. explore_ontology has no pagination, no character caps, no limits at all — while search_tools, its sibling in the next post, is budgeted to the character. That’s deliberate. The ontology corpus is small, static and read-only; concept text can’t flood anything that matters. Tool results, as we’ll see, can. Budgets are applied where the failure mode lives, not uniformly.

The index is also a coordinate system

If this were only about shrinking a prompt section, an ontology would be overkill — a hand-tuned paragraph could compete. The reason it’s a graph with stable ids is that those ids become the shared vocabulary for everything else in the harness:

  • search_tools accepts the same domains / entities / relationships ids as filters, so the model can ask for “tools touching work-item” — and a tool tagged with a relationship is findable from either endpoint entity.
  • Each entity’s glossary text is folded into the search document of every tool that touches it, giving lexical search a curated synonym layer with zero embeddings involved.
  • Memories are tagged with the same concepts, scoping recall.
  • Even the value references of part 3 participate: every scope reference knows getEntity() and getDomain(), which is exactly what forScope consumes.

That’s the red thread of this series: the ontology isn’t a prompt section, it’s the address space the other two mechanisms route through.

What it costs, honestly

Depth costs a decision. Every deferred layer is a round trip the model has to choose to make. A model that never drills is operating on 13 one-liners, so those lines have to be written as signals — descriptive enough that the model can tell something worth pulling sits underneath. Deferral moves the hard work from budgeting tokens to writing an index that provokes the right pulls.

The map must track the territory. An ontology is a second description of the product, and second descriptions drift. The standing section is the layer the model trusts most — it arrives as system prompt, not as a tool result — so a stale description doesn’t just miss, it misleads every conversation. Curation is a permanent chore: the index stays small and true because someone keeps it that way.


The ontology solves “what does the domain mean” for a few hundred standing tokens. The next problem is an order of magnitude larger: our agent has 169 tools, and their JSON schemas alone are ~85,000 characters. Declaring them all would cost more than every other prompt section combined — so the harness doesn’t, and the machinery it uses instead is the subject of part 2.