Skip to content

Core Concepts

Overview

This chapter explains the mental model behind Trails. Once you understand how knowledge graphs, capabilities, node types, shapes, and the context object fit together, everything else in the framework becomes predictable. Take 30 minutes here and save hours of guesswork later.

Learning Objectives

After this chapter you will be able to:

  • Explain what a knowledge graph is and why Trails uses one
  • Describe what capabilities, node types, and shapes are
  • Use the context object (ctx) to access the graph, LLM, and config
  • Articulate the progressive-enhancement philosophy
  • Explain how Trails differs from Django/Rails

The Knowledge Graph Mental Model

A knowledge graph stores data as a web of connected facts. Every fact is a triple -- three parts that read like a sentence:

subject  --  predicate  -->  object

"Alice"  --  "works_at"  -->  "Acme Corp"
"Alice"  --  "role"      -->  "engineer"
"Acme"   --  "located"   -->  "Munich"

That is the entire data model. No tables, no columns, no foreign keys. Just subjects connected to objects through predicates.

Nodes and Edges

In graph terms:

  • Nodes are the things (Alice, Acme Corp, Munich)
  • Edges are the connections between things (works_at, role, located)

A node can have any number of edges pointing to other nodes or to literal values (strings, numbers, dates). There is no fixed schema unless you choose to add one.

Triples

The formal term for a subject-predicate-object fact. Trails stores triples in RDF format using Oxigraph as the embedded triple store. You do not need to know RDF to use Trails -- the framework handles serialisation for you. But when you hear "triple", it means one fact.

IRIs

Every node in the graph has a globally unique identifier called an IRI (Internationalized Resource Identifier). Think of it as a URL that uniquely names a thing:

trails://blog/Note/018f3a2b-...

Trails mints IRIs automatically using UUIDv7 (time-sortable). You never need to construct one by hand unless you want to.

SPARQL

SPARQL is the query language for knowledge graphs, the way SQL is the query language for relational databases. Trails generates SPARQL for you behind the ORM, but you can always drop to raw SPARQL when needed:

rows = ctx.kg.query("SELECT ?title WHERE { ?note a <Note> ; <title> ?title }")

Most of the time, you will use Note.where(title="Hello").fetch(ctx) instead. The ORM compiles that to SPARQL so you do not have to.


Capabilities

A capability is a named function that Trails can discover, invoke, and audit. It is the primary unit of functionality -- the equivalent of a controller action in Rails or a view function in Django.

from trails import capability

@capability
def create_note(ctx, title: str, body: str) -> dict:
    note = Note(title=title, body=body)
    ctx.kg.add(note)
    return {"id": note.id}

What the @capability decorator gives you:

  • Discovery -- the function appears in trails routes, MCP tools/list, and the HTTP OpenAPI spec
  • Dispatch -- trails.invoke("create_note", {"title": "Hi", ...}) calls it through the runtime, which handles policy, validation, provenance, and cost
  • Provenance -- every invocation records a PROV-O activity triple in the graph. Who called what, when, with what inputs, producing what outputs. Automatic, always on.
  • Type checking -- arguments are validated against the function signature before the handler runs

Every capability is exposed as an MCP tool automatically. When trails dev or trails serve starts, it reads all registered capabilities and derives the MCP tool manifest — name, description, and typed inputSchema — directly from your Python type annotations. If you change a capability or model while trails dev is running, it hot-reloads and sends notifications/tools/list_changed to connected clients. You never write MCP code.

Capabilities can invoke other capabilities:

@capability
def create_and_list(ctx, title: str, body: str) -> dict:
    created = ctx.invoke("create_note", title=title, body=body)
    all_notes = ctx.invoke("list_notes")
    return {"created": created, "all": all_notes}

For the complete decorator API, see the Capabilities guide.


Node Types

A node type declares a data entity with named, typed fields. It is the equivalent of a Django model or a Rails ActiveRecord class.

The recommended way is Graph-as-Code (GaC): define your model as a plain Python class with Annotated[] fields, then decorate it with @app.model. Constraints live right next to the field declarations and compile to SHACL automatically.

# Modern form — constraints inline via Annotated[]
from typing import Annotated
from trails import App
from trails.gac import required, optional, min_value, max_value

app = App("clinic")

@app.model
class Patient:
    name: Annotated[str, required(), min_value(1)]
    age:  Annotated[int, optional(), min_value(0), max_value(150)]

You can also use the explicit decorator form when you need to reference an existing class or prefer the explicit field dict:

# Explicit form — same result, more verbose
from trails import node_type

@node_type("Patient", fields={"name": str, "age": int})
class Patient: ...

What both forms give you:

  • Validation -- writes to the graph are checked against the field types. Pass a string where an int is expected and you get a clear error, not silent corruption.
  • IRI minting -- each instance gets a unique trails:// IRI automatically
  • ORM methods -- Patient.find(ctx, iri), Patient.where(age__gte=18).fetch(ctx), patient.save(ctx), Patient.delete(ctx, iri), Patient.count(ctx)
  • Hydration -- query results come back as Python objects with attribute access (patient.name, not row["name"])

Node types are additive. Adding @node_type("Patient", ...) does not affect any existing capability that does not mention Patient. There is no migration step, no schema change to coordinate.

Linking node types

Reference another node type to create typed edges:

@node_type("Doctor", fields={"name": str})
class Doctor: ...

@node_type("Patient", fields={"name": str, "doctor": Doctor})
class Patient: ...

The doctor field stores an IRI reference. Query across the link with property-path traversal:

# All patients of a doctor named "Smith" -- resolved in one SPARQL query
Patient.where(doctor__name="Smith").fetch(ctx)

For the complete ORM API, see the ORM guide.


Shapes

Shapes express constraint validation that goes beyond type checking: minimum length, numeric ranges, allowed values, regex patterns.

When you use @app.model, every Annotated[] marker is a constraint. Trails compiles them to SHACL automatically -- no separate decorator needed.

from typing import Annotated
from trails.gac import required, optional, min_length, max_length, pattern

@app.model
class Note:
    title: Annotated[str, required(), max_length(120)]
    body:  Annotated[str, required(), min_length(1)]

Constraint markers quick reference

Marker What it checks
required() field must be present (sh:minCount 1)
optional() field may be absent (default)
min_length(n) / max_length(n) string length bounds
min_value(n) / max_value(n) numeric range
pattern(r"...") regex pattern
one_of([...]) enumeration of allowed values

For cross-property rules, add a method decorated with @constraint:

@app.model
class Note:
    title: Annotated[str, required(), max_length(120)]
    body:  Annotated[str, required()]

    @constraint
    def body_longer_than_title(self):
        return len(self.body) > len(self.title)

Explicit path (advanced)

Use @shape + predicate() when you need raw SPARQL constraints, custom predicate IRIs, or you are adding shapes to a class defined with the explicit @node_type form:

from trails import node_type, shape, predicate

@node_type("Note", fields={"title": str, "body": str})
@shape
class Note:
    title = predicate("schema:name", required=True, max_length=120)
    body  = predicate("schema:description", required=True, min_length=1)

Either path generates the same SHACL triples under the hood. You can export them with trails export --format ttl for interop with other RDF tools.

For the complete shapes API, see the Shapes guide.


The Context Object

Every capability receives a ctx parameter. This is the framework's handle to the outside world:

@capability
def my_capability(ctx, title: str) -> dict:
    # The knowledge graph
    ctx.kg.add(note)                       # write a node
    ctx.kg.query("SELECT ...")             # raw SPARQL
    notes = Note.where().fetch(ctx)        # ORM query

    # LLM client (if configured)
    response = ctx.llm.complete("Summarize this...")

    # Configuration
    project_name = ctx.config["project"]["name"]

    # Invoke another capability
    result = ctx.invoke("other_capability", arg="value")

    return {"done": True}

ctx.kg -- the Knowledge Graph handle

This is the primary surface you interact with. It provides:

  • ORM methods via node types: Note.where().fetch(ctx), note.save(ctx), Note.find(ctx, iri)
  • Label-first methods for untyped data: ctx.kg.node(labels=["Tag"], properties={"name": "urgent"}), ctx.kg.edge(...), ctx.kg.match(...)
  • Raw SPARQL: ctx.kg.query("SELECT ..."), ctx.kg.update("INSERT DATA { ... }")

The ORM and label-first surfaces coexist on the same handle. You can mix them in the same capability. See Working with Data for details.

ctx.llm -- the LLM Client

When configured, provides access to language models:

response = ctx.llm.complete("Summarize: " + text)

Supports Anthropic, Ollama, OpenAI, and a mock backend for testing. Each call is tracked in provenance (which model, how many tokens). See the LLM guide.

ctx.config -- Configuration

Access to trails.toml settings. Project name, base IRI, store backend, LLM provider config.

ctx.invoke -- Capability dispatch

Call another capability from within a capability. The nested call goes through the full dispatch pipeline (policy, validation, provenance), so it is audited just like a top-level call.


Progressive Enhancement

This is the design philosophy that makes Trails different from every other framework. It means:

Every feature is additive. Code you write on day one keeps working on day 365. New features never force rewrites.

In practice, this creates a ladder you climb at your own pace:

Level What you add What you get Old code changes?
1 @capability Named function, provenance, discovery --
2 ctx.kg.node() Label-first graph storage No
3 @node_type Typed fields, validation, ORM No
4 @shape Constraint validation (SHACL) No
5 ontology/*.ttl Reasoning, cross-system interop (OWL) No
6 @policy + .cedar Access control (Cedar) No
7 @before/@after Middleware (logging, metrics) No

The right column is the key. At no point do you rewrite what you already have. You only add.

This is not a theoretical promise -- it is enforced by the architecture. The kernel uses "strongest available type" matching: Cedar policy, SHACL validation, and provenance all inspect each entity and act on whatever typing is present -- label only, JSON-Schema type, SHACL shape, or OWL class. One code path, feature-detected at dispatch time.

The normative decision behind this is ADR-0021. The tutorial that walks the entire ladder is Growing Your KG App.


How Trails Differs from Django and Rails

If you are coming from a web framework, some things will feel familiar and some will not. Here is a quick orientation.

What is similar

Concept Django/Rails Trails
Data model Model / ActiveRecord @node_type
Request handler View / Controller action @capability
Validation Form / Validator @shape + predicate()
Query API QuerySet / ActiveRecord Model.where().fetch(ctx)
Middleware Middleware classes @before / @after / @around
Generators rails generate trails g cap\|sh\|res
Dev server manage.py runserver trails server --watch
Console rails console trails console

What is different

Graph, not tables. Data is stored as triples (subject-predicate- object), not rows in tables. There are no migrations because there is no fixed schema to migrate -- adding a field to a node type does not require altering a table. Old nodes simply lack the new field.

IRIs, not integer IDs. Every entity has a globally unique IRI, not an auto-incrementing integer. This makes federation and cross-system references natural.

SPARQL, not SQL. The query language underneath is SPARQL. The ORM hides this most of the time, but when you need raw queries, it is SPARQL you write.

Provenance is always on. Every write records who did what, when, producing what. There is no equivalent in Django or Rails -- you would have to add an audit library. In Trails, it is built into the kernel.

Policy is declarative. Access control is written in Cedar (a purpose-built policy language), not as conditional checks in handler code. The framework evaluates policy before your handler runs.

Capabilities, not routes. The primary abstraction is the capability (a named, typed, auditable function), not an HTTP route. Routes and MCP tools are projections of capabilities, generated automatically.


What's Next

Now that you understand the mental model, put it to work:

Working with Data -- Create, query, filter, and aggregate data using the full ORM surface.

For deep dives into individual concepts:

  • Capabilities guide -- all decorator options, dispatch lifecycle, cost envelopes
  • ORM guide -- field types, inheritance, async, dirty tracking
  • KG guide -- label-first operations, traverse, match
  • Shapes guide -- SHACL export, shape-from- annotation