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") # oneNamespace 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 worksUse 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_questionsA 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 slugEverywhere 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.
Semantic search
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:
| mode | Behavior |
|---|---|
"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.
Relevance-aware search
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 matchIt’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_deletedA note is pruned only when all of these hold:
older_thanis set and the note’s last activity (max(updated_at, last_cited_at)) is older than that window.- The note’s
cited_countis belowmin_cited_count(default 1, so a note cited even once survives). - The note’s
kindis not inkeep_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.