Skip to Content
DocsWorkspaceWorkspace lifecycle

Workspace lifecycle (0.10.0+)

The base workspace is a coordination layer: agents write notes, other agents read them. The v0.10 surface adds the lifecycle and self-improvement features that turn the notebook into a substrate an agent gets smarter on, run over run.

Everything here works on both LocalDiskWorkspace and InMemoryWorkspace. Example 19 exercises all eight features offline, no API key.

Namespacing

A namespace is a sub-bucket within one workspace. It lets a single notebook hold logically distinct sub-projects without spinning up separate workspaces.

await ws.write_note( author="agent", title="API rate limits", body="...", user_id="u", namespace="backend", ) await ws.write_note( author="agent", title="Button hover states", body="...", user_id="u", namespace="frontend", ) everything = await ws.list_notes(user_id="u") # both notes backend_only = await ws.list_notes(user_id="u", namespace="backend") # one

Namespace is metadata, not a partition. A default list_notes sees every namespace. That’s deliberate. Teammates’ adjacent work should stay visible. The namespace filter is there when you want to narrow, not to wall things off.

Versioning

Every update_note snapshots the prior body into the note’s revision history. Nothing is lost.

n = await ws.write_note(author="agent", title="Design doc", body="draft 1", user_id="u") await ws.update_note(author="agent", slug=n.slug, body="draft 2", user_id="u") await ws.update_note(author="agent", slug=n.slug, body="final", user_id="u") versions = await ws.list_versions(n.slug, author="agent", user_id="u") # versions → two prior revisions, oldest first old = await ws.read_version(n.slug, 1, author="agent", user_id="u") # old.body → "draft 1"

list_versions returns NoteVersion objects with a body_preview so you can scan history without loading every revision. Fetch the full body of one revision with read_version. Revisions are immutable. update_note appends, it never modifies an old one.

Archive

Archiving soft-hides a stale note. It drops out of listings and search, but stays readable by slug.

await ws.archive_note(author="agent", slug=n.slug, user_id="u") await ws.list_notes(user_id="u") # excludes it await ws.list_notes(user_id="u", include_archived=True) # includes it await ws.read_note(n.slug, user_id="u") # still works

Use it when a note is superseded but you don’t want to lose the record. For actually deleting notes, see prune below.

Questions

The question/answer pattern is opt-in. Pass questions=True to make_workspace_tools and three more tools appear: ask_question, answer_question, list_open_questions.

from loomflow.workspace.tools import make_workspace_tools tools = make_workspace_tools(ws, author="alice", questions=True) # tools now include ask_question / answer_question / list_open_questions

A question is a note with kind="question" and answered=False. Answering it is a kind="finding" note that points back via parent_slug, plus a flag flip on the question.

The flag flip is the one documented cross-author carve-out. Bob can mark Alice’s question answered without owning it:

await ws.update_note( author="bob", slug=question.slug, body=question.body, user_id="u", mark_answered=answer.slug, ) # question.answered is now True, question.answered_by is the answer's slug

Everywhere else, update_note raises PermissionError if the author doesn’t own the note. mark_answered is the exception, and it’s intentional. It’s how the Q&A pattern works without giving every agent write access to every note.

By default search_notes uses BM25. Wire an embedder= on the backend and you unlock cosine similarity and hybrid scoring.

from loomflow import InMemoryWorkspace from loomflow.memory.embedder import OpenAIEmbedder ws = InMemoryWorkspace(embedder=OpenAIEmbedder()) hits = await ws.search_notes("apple", user_id="u", mode="semantic")

The mode argument selects scoring:

modeBehavior
"auto" (default)Hybrid (BM25 + cosine via RRF) when an embedder is wired, BM25 otherwise
"bm25"Text-only, even when an embedder is wired
"semantic"Cosine-only. Falls back to BM25 if no embedder
"hybrid"Explicit hybrid. Falls back to BM25 if no embedder

When an embedder is set, every write_note also computes and stores an embedding. An embedder failure never breaks the write. The note still lands. You just lose semantic recall on it.

Citation tracking and outcome attribution

This is the self-improvement loop. The framework tracks which notes an agent actually read during a run, and lets you attribute the run’s outcome back to those notes.

read_note logs every read into a per-run citation set (an ambient contextvar, isolated per agent.run()). At the end of the run, you call attribute_outcome:

await ws.attribute_outcome(success=True, user_id="u")

For every note the run cited, this bumps cited_count, sets last_cited_at, and bumps success_count if success=True. Over many runs, the notes that keep getting read on successful runs accumulate a track record.

attribute_outcome is observation-class. It doesn’t check author ownership. Anyone with workspace access can report what they cited and how the run went. That’s how you learn which past notes were useful, not just present.

Call it once per run. Calling it twice double-counts. To opt out of citation tracking entirely, just don’t call it. The per-run citation log evaporates with the contextvar.

Once notes have citation history, search_notes(boost_relevance=True) ranks frequently-cited-on-success notes higher than plain text matches.

hits = await ws.search_notes("useful", user_id="u", boost_relevance=True) # the note that's been cited on successful runs ranks first, # even if a different note is an equal-tier text match

It’s opt-in so callers that don’t want the signal keep the plain text-match ordering. When you do want it, it’s the difference between “notes that contain this word” and “notes that have actually helped before”.

Prune

prune is the garbage collector. It hard-deletes notes that are stale and low-value. It’s citation-aware, so it keeps what’s been used.

from datetime import timedelta result = await ws.prune( older_than=timedelta(days=30), min_cited_count=1, keep_kinds=["decision"], user_id="u", ) # result.notes_deleted, result.notes_kept, result.versions_deleted

A note is pruned only when all of these hold:

  • older_than is set and the note’s last activity (max(updated_at, last_cited_at)) is older than that window.
  • The note’s cited_count is below min_cited_count (default 1, so a note cited even once survives).
  • The note’s kind is not in keep_kinds.

Always pass older_than. When older_than is None, age stops being a filter and every note becomes age-eligible. A freshly-written note could be pruned before it’s had any chance to be cited. Pass a real window.

keep_last_versions trims revision history on surviving notes to the most recent N. Left unset, history is untouched.

prune is observation-class like attribute_outcome. It doesn’t check author ownership, because it’s an operator action, not an agent action. Don’t wire it as an agent tool. Call it from a cron job, an end-of-benchmark hook, or by hand.

Last updated on