Agent trace¶
The agent trace records how an agent session moves through the graph: which tools it called, the queries it sent, what came back (counts, not values), which Records and Links each answer touched, how long it took and whether the response cache answered. An agent can attach notes (its reasoning between two tool calls) to the same session. The web app shows a session as a timeline with the touched subgraph on the Canvas (Agent activity).

Tracing is debugging, not compliance: it is off by default, opt-in per request or per
principal, written best effort off the request path and deleted after a retention period. For
evidence of who read what, use the read audit (keeper.auth.audit.reads).
Keeper stays model-free: the trace only observes requests.
Contract¶
These names are what agents, Herald and the example agent (examples/agent)
rely on. The header names are configuration (keeper.trace.*_header); the values below are the
defaults.
| What | Value |
|---|---|
| Session header | X-Lorebase-Session: <session id>, 1 to 128 characters of A-Z a-z 0-9 . _ : -. Explicit session ids always win. Without one, a traced request joins an inferred session (below); with a malformed one it is not traced. |
| Tool header | X-Lorebase-Tool: <tool name> (optional), for example the MCP tool that made the call. |
| Opt-in header | X-Lorebase-Trace: on, or debug to add query plans (needs trace.explain). |
| Trace context | W3C traceparent (optional); its trace id is stored with each step. |
| Notes endpoint | POST /api/v1/trace/sessions/{session_id}/notes with {"text": "...", "step": 3, "domain_id": "supply"} (step and domain_id optional). Answers 202 {"recorded": true}, or {"recorded": false} when tracing is off. |
| Sessions | GET /api/v1/trace/sessions?actor=&domain_id=&limit=&cursor= |
| Session timeline (API) | GET /api/v1/trace/sessions/{session_id} (the caller's session; ?actor=<principal> for another principal's) |
| Session timeline (web app) | /{domain}/agent-activity?session={session_id} |
A note is opaque text: stored as sent (at most keeper.trace.max_note_chars characters),
never interpreted and shown as plain text. Notes go through the same queue as calls, so a note
and the call it describes keep their order within a few milliseconds. Notes and calls are kept
per session and principal: post notes with the credential that makes the calls (through Herald,
the credential Herald forwards).
Herald¶
Herald sends the session header, the tool header (the MCP tool name) and a traceparent on
every Keeper call. Each tool call is a span and each Keeper request a child span of it with its
own span id, in the caller's trace; when an OpenTelemetry span is active in Herald, each
request is a child of that span instead. With tracing off nothing is sent. The session id is the one the MCP client sends in X-Lorebase-Session, else
herald.trace.session_id, else the MCP session (mcp-session-id, stateful Streamable HTTP).
When none applies (stateless HTTP, stdio without session_id) Herald sends no session id and
Keeper infers the session. Herald forwards an incoming X-Lorebase-Trace header, and sends
on itself with herald.trace.opt_in: true. See MCP server and herald.trace in
config/lorebase.example.yaml.
Guide and the example agent¶
Guide traces each exploration as one session (guide-<hex>) with these header
names and posts the model's reasoning to the notes endpoint (guide.trace). The
example agent does the same with agent-<hex> sessions,
through Herald. Both default to the values above; a test keeps Guide's defaults equal to
Keeper's routes and settings.
Inferred sessions¶
A traced request without a session id is not dropped and not given a session of its own: Keeper
groups it with the previous calls of the same client. The client is the principal plus a
fingerprint of its credential (the API key id, else a digest of the web session, else the
authentication method) and of its User-Agent; only digests are stored. A call joins the
inferred session of its client with a call at most keeper.trace.inferred_session_idle_s
(300 s) before or after it (the nearest one); after a longer gap a new one starts. A step
carries the time its request started but is written when the request finishes, so the window
looks both ways: concurrent calls of one client finish out of order and still share a session.
Inferred sessions have ids
inferred-<16 hex>, are marked inferred: true in the API and with a badge in the web app
(grouped heuristically: two agents behind the same key and user agent land in one session), and
are never shared across principals. An explicit session id always wins. Notes need an explicit
session id, so an agent that wants its reasoning interleaved sends one.
Python client and CLI¶
from lorebase_client import LorebaseClient
client = LorebaseClient(base_url, api_key=key, session_id="agent-42", trace="on", tool="my-agent")
client.search("supply", q="bearing") # traced under agent-42
client.trace.add_note("agent-42", "checking suppliers next", domain_id="supply")
timeline = client.trace.session("agent-42")
What a step holds¶
| Field | Content |
|---|---|
step, at, seq |
Position in the timeline, request start, write order. |
tool, method, route |
Tool header, HTTP method, route template (values unexpanded). |
status, latency_ms, cache |
Answer status, time in Keeper, response cache hit, miss, bypass or not_modified. |
query |
Reads only: the request body (body) and query parameters (params) as structured data: filters, traversal spec, search text, mode, limits. |
result |
Reads only: counts of the lists in the answer, search mode and corrections, context pack budget, truncated, has_more. |
refs |
Records (kind, key) and Links (relation, from, to) the answer touched, at most max_refs. |
explain |
With X-Lorebase-Trace: debug and trace.explain: plan summaries (node types, indexes and relations scanned, estimated rows and cost) of the query and search statements. EXPLAIN only plans; nothing runs twice. |
proposal_id |
Writes: the Proposal the answer names (opened or acted on). The timeline links it. |
note |
Notes only: the text. |
Never stored: values of Records, titles and snippets, response bodies, credentials, request
bodies of writes. In the query, values compared by attr and derived predicates are kept only
on fields that are not sensitive (capture_filter_values: non_sensitive, the default). The
read checks every field it filters, orders or groups by against the caller's masking and
reports, per field, whether it is sensitive under the effective tags (the schema read, the
default branch's, and those carried through Derivations): a field resolved through a Trait is
sensitive when the Attribute of any implementing Kind is, title when a title Attribute is,
key when a primary key Attribute is. A name resolved to a sensitive field anywhere in the
read keeps no value anywhere; fields the read never resolved, and steps outside a Domain, keep
nothing. Kept
values are scalars or lists of scalars, strings cut to max_text_chars and lists to
max_filter_value_items; objects and every other value are replaced by their type
({"redacted": "string"}). capture_filter_values: none replaces them all; capture_literals:
true keeps them all, sensitive ones included. Query vectors are replaced by their dimension,
cursors are dropped and long strings cut (max_text_chars). Search text
(q, search, match) is kept, since it is what an agent asked; capture_search_text: false
keeps only its length and drops the search corrections, which name the query words. Members named values are never read when refs are collected, so
references held in values are not refs.
Who sees what¶
A session id names one session per principal: GET /trace/sessions/{id} reads the caller's own
session unless actor names another principal (the sessions list gives it), so another
principal reusing the id cannot slip steps or notes into the timeline you open.
- Every principal sees its own steps and notes in full (an API key limited to Domains: in those Domains, and the notes).
- Steps of other principals in a Domain: principals holding
trace.readthere through a Domain-wide grant (the built-inadminrole holds it; custom roles can grant it). Refs are filtered to the Kinds the reader may read. The query, the search corrections and the text of notes are what another principal typed and may hold values (a name searched for, a filter value, a model's reasoning), so they are shown only to a reader with Domain-widedata.readand every sensitivity clearance (pii.read,confidential.read,restricted.read); for others the step is markedwithheld: trueand keeps its counts and refs only. - Notes without
domain_idand steps outside a Domain of other principals: installation administrators only. A note withdomain_idis visible like a step of that Domain; posting it needs a grant in that Domain. Notes are writes: rate limits count them like other writes. - Query plans are recorded only for callers holding
trace.explainin the Domain, and shown to other readers only when they hold it too.
Settings¶
keeper.trace in config/lorebase.example.yaml (reference).
A request is traced when enabled is true and one of: it opts in with
the opt-in header (allow_header_opt_in), its principal is in principals, its API key id is
in api_keys, or trace_all is set and the request is not the web app's own.
The web app marks its requests with X-Lorebase-Client: web (client_header, web_client).
trace_all leaves those out, and requests authenticated by a browser session cookie that name
no session (session_header), unless include_web: true: browsing the screens is not agent
activity. Guide forwards the user's cookie with a guide-<hex> session id and Herald sends its
own credential, so both are still traced. Opting in with the header and listing a principal or
key trace the web app too. The header is a convenience, not a control: a client can send it to
stay out of trace_all, so trace an agent you must see by its principal or key.
| Setting | Default | Meaning |
|---|---|---|
enabled |
false |
Record traces (needs the database section). |
trace_all |
false |
Trace every authenticated request except the web app's own. |
client_header, web_client |
X-Lorebase-Client, web |
Header and value marking the web app's requests. |
include_web |
false |
Let trace_all trace the web app's requests too. |
capture_filter_values |
non_sensitive |
Filter values kept: on fields not sensitive, or none. |
max_filter_value_items |
20 | Items kept of a list compared by a filter. |
capture_literals |
false |
Keep every filter value, sensitive ones included. |
capture_search_text |
true |
Keep search text. |
max_query_bytes, max_text_chars |
16384, 500 | Bounds of a recorded query. |
max_response_bytes |
1 MiB | Larger answers are recorded without counts and refs. |
max_refs |
500 | Refs kept per step. |
max_note_chars |
4000 | Longest note. |
max_steps_per_session |
2000 | Steps kept per session and principal; later ones are dropped. The count lives in the session row and is raised under its row lock in the transaction writing the steps, so the cap holds across Keepers. |
inferred_session_idle_s |
300 | Gap after which calls without a session id start a new inferred session. |
retention_days |
7 | Steps older than this are deleted every prune_interval_s. |
queue_size, batch_size, flush_interval_s, write_timeout_s |
10000, 200, 1 s, 5 s | Off-path writing, like the read audit. |
Steps live in agent_trace_steps (migration 0023, row-level security like every table with
a domain_id). Each written batch also keeps two session tables (migration 0029):
agent_trace_sessions counts the steps of a session and principal (the cap), and
agent_trace_session_domains summarizes them per Domain (first and last step, calls, notes,
tools, Proposals; indexed on principal and on Domain, each with the last step). Sessions are
listed from the summaries; pruning lowers both by the steps it deletes and removes rows left
empty. Dropped steps are counted in lorebase.trace.steps.dropped by reason
(queue_full, write_failed, session_full, shutdown), written ones in
lorebase.trace.steps.recorded (metrics).
Cost¶
An untraced request pays one header lookup. A traced read keeps its request body (up to
max_query_bytes) and, when it is a successful JSON answer to a traced principal, its answer
(up to max_response_bytes; 64 KiB for writes, read only for the Proposal they name) in memory
until it is sent, then parses it once for counts and refs; the write happens in a background
batch. A read Keeper rejects as invalid (422) is recorded as {"rejected": true}, without its
query. Each batch updates one session row per session and one summary row per session and
Domain in its transaction; listing sessions reads the summaries, never the steps. Turn on
trace_all only for short investigations on busy installations.