The History tool: past chats become a primitive.
Every conversation with a loomcycle agent lives somewhere. Runs record events. Sessions group runs into a single conversation across model turns, tool calls, and reasoning. Sessions have IDs. But until v1.20.0, a chat had no handle you could grab. You could not list your recent conversations. You could not search a title. You could not give a chat a name. You could not pin the one you keep coming back to. You could not ask an agent "recap what we talked about yesterday" without threading the transcript through prompt prose. And you could not resume a chat cleanly from an operator's context.
The old surface, for what it was worth, was Context op=history. Agent-relationship-scoped (self or any). Returned a single session's raw events by agent_id. No listing. No search. No annotation. And with any, it read cross-tenant transcripts flat. That last one was a real problem: a tool that looked like a lookup was, under one config value, a cross-tenant history mirror.
v1.20.0 replaces the whole shape. RFC BE ships the History tool: a built-in surface with ten ops, four owner scopes, an admin-only cross-tenant tier, and the same tenant-safe visibility fold applied on every read. Context op=history is gone. There is no drifting pair of tools. The runtime has one history surface, and it is designed to be the one you'd actually build a UI on.
The short version. A "chat" is a session (session → runs → events). History adds the human and organizational layer on top: a title, a description, tags, a pin flag, an archive flag, a stored recap summary, and semantic-similarity edges. Ten ops on one tool: list (chats in a scope, pinned-first, paginated, per-chat token/cost/run-count aggregates), get (metadata + full transcript, format:markdown renders it), search (case-insensitive title match; body-search is a deferred FTS index), rename / annotate / pin / archive, recap (an idempotent LLM summary stored on the session row, live-and-parked-chat-safe), resume (returns a continuation handle for a new run against the chat's session_id), related (semantic similar-chats via the configured embedder, cosine ranking in Go, no pgvector). Owner scopes self / user / tenant / global, gated per agent by history_scope, resolved server-side from RunIdentity(ctx) and never the wire. global is stripped for non-admin principals at policy resolution and fails closed. Cross-scope by-id reads fold to an opaque not-found so session IDs are never an existence oracle. Transcripts are persisted already-redacted (RFC Z) so History readers can never see a secret. Transports: HTTP POST /v1/_history, gRPC History RPC, MCP history meta-tool, and @loomcycle/client + Python adapters at 1.20.0. Migrations 0057 (session metadata columns + ListSessions / SetSessionMeta) and 0058 (session_embeddings index) are additive on both backends.
The mental model: a chat is a session
Sessions have been on loomcycle for a long time. A run belongs to a session. A session belongs to a tenant. A session's ID is what you pass on POST /v1/runs to continue a conversation across turns. What the sessions row did NOT carry, until now, was a human handle. It was addressed by UUID, discovered by grep, remembered by the agent that had a run against it. Not by you.
Migration 0057 adds seven columns to the sessions table: title, description, tags, pinned, archived_at, summary, and summary_updated_at. All optional. Existing sessions read the zero value; no backfill. Migration 0058 adds a small session_embeddings table for the related op (one row per chat, populated lazily). Both are additive on sqlite and postgres.
What that gets you is the human layer. A chat is now a thing you can name, describe, tag with project:jobember, pin so it stays at the top of your list, or archive when you're done. The metadata rides with the session. Every run against the session inherits the title. The chat is the primary handle; the runs and events are what it contains.
Owner scopes: the load-bearing part
Whose chats you reach is the first thing the tool decides, and it is decided from the caller's identity, not from anything on the wire. Same non-negotiable rule the Memory tool follows for scope_id: a model-supplied owner ID would let one tenant's agent read another's chats.
| scope | whose chats | filter built |
|---|---|---|
self (default) | this agent's | agent == AgentName(ctx) + caller's tenant |
user | this end-user's | caller's user_id + caller's tenant |
tenant | this tenant's | caller's tenant |
global | all tenants | no tenant filter (admin only) |
Access is gated per agent by a history_scope field in YAML (default-deny; an agent with no history_scope cannot use the tool at all). It is the same field the old op used, repurposed to this vocabulary:
agents:
support-triage:
tools: [History]
history_scope: [self, user] # its own + the end-user's chats
Legacy behavior: the old self is kept. any is accepted as an alias for global, so an existing agent that had history_scope: [any] keeps working, only now the alias is a lot more honest about what it means. The never-implemented siblings / descendants / named:<n> values are retired; config-load rejects them with a message pointing at the new set.
global is admin-gated, and the gate fails closed
global is cross-tenant, so it is honored only under an admin principal. The gate lives at policy-resolution time, not inside the tool. server.go historyPolicyForAgent (in-loop), grantOperatorPolicies (MCP), and substrateAdminCtx (off-run HTTP) strip global from the resolved scope list for a non-admin principal. So a tenant agent whose YAML lists global still resolves only its own tenant when it calls the tool. The tool trusts policy.Scopes; no separate admin check lives inside.
The gate fails closed. An absent principal (open dev mode, a resumed run re-dispatched at boot with no request context) is treated as non-admin and loses global. Operationally that costs nothing: with no tenant boundary in dev mode, scope:tenant already spans everything. Practically it guarantees a resumed tenant run can never silently regain cross-tenant reach after a restart.
By-id reads fold to opaque not-found
Ops that take a session_id (get, rename, annotate, pin, archive, recap, resume) fetch the row first, then enforce the resolved scope's owner constraint (tenant match for tenant and self; tenant plus user for user; tenant plus agent for self; any for admin global). A row outside the scope and a row that doesn't exist return the same opaque "not found". The fold never becomes a cross-tenant existence oracle. Session IDs are not secret, so the fold not leaking their existence matters.
Ten ops on one shape
Everything the tool does is a single op-discriminated request. Grouped by what you actually do with it:
Discover. list returns chats in a scope, pinned-first then most-recent, paginated. Filters: status, from / to on last-activity (RFC3339), tag, title_contains, pinned_only, include_archived, limit, offset. Each row carries per-chat token, cost, and run-count aggregates (from RFC AV's cost ledger). search does a case-insensitive title match within the scope; body-search over event content is a deferred FTS index. related finds semantically similar chats, see below.
Curate. rename sets a title. annotate sets a description and can replace tags. pin floats a chat to the top (pinned:false unpins). archive is a reversible soft-hide (archived:false restores). Archive is deliberately distinct from RFC AV's usage-retention pruner, which hard-deletes; archive is for the human "I don't want to see this anymore but I might come back to it."
Read. get returns one chat's metadata plus its full transcript. format:"markdown" renders the transcript as a Markdown export instead of a structured event array. That is the export path: give a chat a title, pin it, ask get with format:markdown, and you have a portable Markdown transcript of a conversation.
Compute. recap and resume are the two ops that do work beyond CRUD. Below.
Recap: an LLM summary that lives on the chat
recap produces an LLM summary of the transcript-so-far and stores it on the chat's metadata (sessions.summary, with summary_updated_at). Three properties matter.
Idempotent. Re-running recap replaces the cached summary. The metadata always holds the current view of "what this chat is about." A stale recap becomes a fresh one on the next call.
Live-safe. The op reads the transcript and never touches the run loop. That means an in-loop agent (or an operator) can recap a chat that is still running or parked awaiting_input. The intended shape is: an interactive run parks itself waiting for a user answer, an operator or a schedule kicks a recap periodically during the long wait, and every list or get or search that comes after gets a fresh summary without re-reading the whole transcript.
Fold-first. The scope fold runs before any summarization. A cross-scope recap is refused with the same opaque not-found as any other by-id op. Nobody spends model tokens on a chat they weren't going to be allowed to see.
The tool itself never calls a provider directly. The server injects the summarizer (Server.RecapSession), which is the off-loop, session-scoped twin of the context-compaction summary step (loop.Summarize). It replays the whole session transcript (a chat spans every run, like resume), resolves provider and model the same way compaction does (the agent's compaction.model when set, else its resolved tier model), and carries the chat's RFC AX operator-key restriction from its most recent run. So a restricted tenant's recap never spends the operator key. A recap of an already-compacted chat summarizes its current effective post-compaction view.
Resume: a continuation handle, not a run
resume is small on purpose. It returns a continuation handle: {session_id, agent, tenant_id, user_id, status, last_activity, hint}. It does not itself start a run. To continue the chat, issue a POST /v1/runs with the session_id (loomcycle's existing session-continuation path); the handle hands you exactly those coordinates plus a hint.
The split matters. A UI's "continue this chat" button is a one-line call to resume for the handle plus a one-line call to the standard run-trigger surface. The History tool does not duplicate the run trigger. The run trigger does not gain history semantics. Two primitives, one composition.
Related: semantic similarity, cosine in Go
related finds chats close in meaning to a source. The source is either a chat (session_id, embedded from its title plus summary plus description, excluded from its own results) or a free-text query. It uses the same vector embedder the Memory tool uses (memory.embedder in operator YAML) and refuses cleanly with related requires an embedder ... when none is configured. Every other History op works without one.
The index is a small session_embeddings table (one row per chat, migration 0058). It fills lazily and best-effort: recap (summary change), rename (title change), and annotate (description change) re-embed the chat and upsert its row. A populate failure is logged and never fails the triggering op. There is no historical backfill in this PR. An old chat becomes matchable only after it's next recapped, renamed, or annotated. A single helper sessionEmbedText (title + summary + description) is the one source of embed text, so a chat's stored vector and a by-id related lookup of it are computed identically.
No pgvector required. Unlike memory_embeddings, which needs pgvector or sqlite-vec, the per-chat index is small, so the vector is a plain TEXT column (store.EncodeVector) and cosine ranking runs in Go over the folded candidate set (store.CosineSimilarity). This keeps related working on the default single-binary SQLite build. It also lets the same real search run in CI on both backends. A recency cap (sessionSimilarCandidateCap) bounds how many in-scope chats a search loads before ranking, so a large tenant can't blow memory on a similarity call.
The tenant fold applies at the SQL WHERE, not after ranking. SessionEmbedSearch takes the same SessionFilter as ListSessions and applies the owner/tenant fold on the denormalised session_embeddings.{tenant_id,user_id,agent} columns (copied from the authoritative session row at upsert; they are immutable session facts) for the owner axes, and on sessions.archived_at for the archived filter. A cross-tenant chat therefore never leaves the DB, even when its vector is the closest possible match. The fold beats similarity.
Redaction: transcripts persist already-scrubbed
Transcripts are persisted already-redacted. The recording emit path (makeRecordingEmit) scrubs secrets out of tool-call inputs and tool-result text at write time, using the RFC Z redact context-transform. So get returns scrubbed content without re-applying the transform. A secret that redaction would mask never reaches a History reader in the first place. That is the same property Memory has for stored values, applied to the transcript.
The five post-merge review fixes
A full feature review of the History line (#723) found five real defects. All fixed with fail-before-verified regression tests. Because the fixes are the shape of the tool's guarantees, they're worth naming.
1. Identity-required isolation. filterForScope silently widened self and user scope to the whole tenant when the run carried no agent or user identity. An empty owner axis dropped the filter, so a legacy or misconfigured run could see rows that the strict by-id fold would never have returned. Now it refuses. The read and the by-id fold are symmetric: no identity, no listing.
2. Effective limit in the response. list and search echoed the raw request limit instead of the effective one. That broke pagination when a client sent limit:100 and the server capped it to 50. The response now returns the effective limit, so cursors advance correctly.
3. Tag matching handles quotes and backslashes. The tag filter never matched a tag containing a quote or backslash. Raw needle-in-haystack vs. JSON-escaped storage. Now it matches through a shared store.EncodeTagMatch helper so the needle is encoded the same way the storage encodes it. A tag like project:jobember-"emag" is matchable.
4. Unknown status is a refusal, not an empty page. An unknown status filter silently returned an empty page, which looks like "no chats match" but is actually "you asked for something that doesn't exist." Now it rejects. A typo in a client's query surfaces immediately instead of masquerading as an empty result.
5. A stale Context doc comment. The Context tool's doc referenced op=history after it was removed. Corrected. Small but real: an agent reading the tool doc would have found an op that isn't there.
Transports and adapters
Same shape on every wire. In-loop, an agent with tools: [History] plus a history_scope calls it as an ordinary built-in. Off-run, POST /v1/_history (bearer-authed, ScopeTenant; admin satisfies for global), the MCP meta-tool history, the gRPC RPC, and the @loomcycle/client plus Python adapter twins. All resolve owner-scope and tenant from the authenticated principal, never the wire. related is a new op on the existing tool, so it rides the same op-discriminated dispatch and JSON envelope. Every transport picked it up with no wire change.
@loomcycle/client and Python both bump to 1.20.0 for the History methods. The gRPC RPC follows the same ScopeTenant op-discriminated shape as the other substrate tools.
Where this lives in the substrate
History is a tool, not a def. It sits at the same tier as Memory, Channel, Document, and the other built-ins. What is new is the layer of session metadata it exposes. Sessions were substrate state already; History adds the human handle.
Composition with the other primitives you already have on loomcycle. Document can now be a table-of-contents of chats via related and list. Path can name a specific chat via a resume handle. A team's team/orchestrator can recap its own driving chat to refresh its self-summary before a long input wait. A Schedule can run recap at 4am every day so every chat that had activity yesterday has a fresh summary by morning. A LoomBoard-style UI can build a chat list, a search box, a pin toggle, and a semantic-similar sidebar without adding anything to the runtime.
What's next
Body-content search (an FTS index over event bodies) is the deferred piece. Title-match is a working MVP that answers the "what did I call it" question, but it does not answer "what did we talk about that involved emag.ro." An additive index over event text, gated by the same owner-scope fold, lands in a v1.20.x point release once the pgvector-vs-fts5-vs-postgres-fts triple is decided. Cross-tenant history export for the admin who legitimately needs one (audit, compliance) is a v1.21.x candidate. A LoomBoard chat list backed by list plus recap plus related is queued for LoomBoard v0.2 alongside signed builds.
Companion reading: agent teams (RFC AP + RFC BD) for the multi-agent workflow primitive; client-executed tools (RFC BC) for how LoomBoard's extension actuates through the runtime; per-tenant credentials and cost ledger (RFC AR + RFC AV) for how the per-chat token/cost aggregates fold into list; and the HISTORY.md operator reference for the full scope-fold posture and the wire shapes.