Architecture¶
Both factories are thin wrappers over deepagents.create_deep_agent. What they
add is the wiring that makes an OKF bundle usable by an agent. This page
explains each piece and why it is shaped that way.
The filesystem¶
The agent sees exactly one tree: your bundle, at the virtual root.
/ -> your OKF bundle
├── AGENTS.md
├── raw/
└── wiki/
├── index.md
├── log.md
├── assets/
├── documents/
├── entities/
├── concepts/
└── syntheses/
Two consequences worth naming:
- The bundle sits at the virtual root. A tool call addresses a page by its
bundle path,
/wiki/concepts/foo.md, with no prefix to prepend and no translation layer. Links inside the pages are a different matter: they are written relative to the page holding them (../concepts/foo.md), so that the bundle stays navigable outside the agent too — on GitHub, in an editor, after a move. raw/sits besidewiki/, not inside it. Everything the wiki owns is underwiki/; the sources are not part of it. That is what makes a source two hops up from a category page (../../raw/...) and one hop up fromwiki/log.md(../raw/...).- Nothing is added to your directory. The agent's instructions are in its
system prompt, not in files;
lson your wiki shows your pages and nothing else.
Because virtual_mode=True, the backend is confined to its root: the agent
cannot escape the bundle via ../ or ~/.
Bringing your own backend¶
Pass backend= instead of wiki_path= for a bundle held in a store, a
sandbox, or anywhere that is not a local directory. The factory then uses your
backend verbatim. There is nothing else to mount — the agent's instructions
travel in the prompt, so any backend that serves the bundle at its root works
as-is.
The permission model¶
deepagents applies FilesystemPermission rules inside FilesystemMiddleware,
before a file tool executes. That makes them a property of the graph rather
than of the conversation: no prompt, system or user, can talk past them.
The manager agent:
raw/ is where source documents live and the format declares them immutable —
better structural than dependent on the model's compliance. Set
protect_raw=False to lift it, which leaves the manager with no permission
rules at all.
The reader agent:
Every write, everywhere. Reads are untouched, so ls, read_file, glob and
grep all work normally. This is the guarantee that makes it safe to expose the
reader to untrusted questions: a prompt-injected instruction to "update the
wiki" fails at the tool boundary, not at the model's discretion.
"write" covers every mutating tool, not just write_file — deepagents maps
edit_file and delete onto the same operation, so the rule above needs no
amendment as that tool set grows.
Passing your own permissions replaces these rules entirely — including, for
the reader, the read-only guarantee.
Why the instructions are in the prompt¶
The prompts carry the substance: bundle layout, frontmatter conformance, the
ingest workflow, the query protocol, the lint checklist, bootstrap, the log
format. Earlier versions kept all of that in a mounted okf-wiki skill and had
the prompt tell the agent to read it first. That was traded away deliberately.
A skill can be dropped silently. When SKILL.md's YAML frontmatter was once
invalid, deepagents' SkillsMiddleware skipped the skill without raising:
the agent started normally, with no instructions at all, and produced
plausible-looking nonconformant output. A system prompt is a string in the
process; it cannot be silently dropped.
Progressive disclosure bought nothing here. The instructions are not
optional — they apply to every request either agent handles. So the first tool
call of every session was a read_file whose result we already knew we wanted,
and whose omission the model could decide on.
Removing the mount simplified everything downstream. The backend collapsed
from a CompositeBackend over two FilesystemBackends to a single one, and
skills_mount / skills_dir / extra_skills / normalize_mount /
resources.py all left the public surface. The limit=1000 wart — both prompts
had to warn the agent that the default 100-line read truncates the file — went
with them.
What it costs: about 2k tokens on every turn rather than once per session, largely amortized by prompt caching, and the reason the content is split by audience rather than pasted into both prompts. The manager gets everything; the reader gets bundle structure, enough conformance to read frontmatter and follow links, and the query protocol — not ingest, bootstrap or the log format, which it can never act on.
Customizing behavior now goes through system_prompt=, which was already the
documented override.
skills/okf-wiki/ still exists¶
The skill remains in the repository, at the root rather than inside the package, and is no longer shipped in the wheel. It is the canonical human-facing statement of the format and stays usable in Claude Code or any other skill-aware harness.
tests/test_prompt_drift.py is what keeps "source of truth" from being merely a
comment: every section of SKILL.md must be declared as covered by the manager
prompt, the reader prompt, both, or neither, and a checksum of the file must be
updated deliberately. Editing the skill without revisiting the prompts fails
the suite.
What the prompts own beyond the skill's content:
| Contract | Where it lives | Why |
|---|---|---|
| The not-found sentence | prompt (not_found_message) |
a per-deployment product decision, not a property of the format |
| No answering from model knowledge | prompt | the skill's query section assumes a cooperative reader; the reader agent needs it as a hard rule |
| Where the bundle root is | prompt (WIKI_ROOT) |
a property of the mount, not of the format |
| The answer's shape | prompt + schema (structured_output) |
how a caller consumes an answer is an integration concern; a human-facing skill has no equivalent |
Why the answer's shape is opt-in¶
structured_output=True is the one place where a contract is stated twice: as
WikiAnswer, which the model must satisfy, and as a prompt section explaining
how its fields relate to the not-found rule. The schema alone would be filled
inconsistently — nothing in list[str] says "wiki pages, not sources under
raw/", and nothing in bool says a partial hit is found: true.
It stays off by default because the two representations are not interchangeable. Prose lets the reader answer as the question deserves; fields let a caller branch without parsing. The library cannot pick for a deployment it cannot see, so it exposes both and makes the choice one keyword wide.
The lint tool¶
The manager's prompt tells it to validate the bundle before declaring a write complete. A deep agent has file tools but no shell, so it cannot run a validator itself.
create_okf_lint_tool closes the gap: it wraps
deep_wiki_agent.okf_lint.lint — ordinary imported code — as a LangChain tool
bound to one bundle. The same module keeps an argparse entry point, installed
as the okf-lint console script (and still reachable as python -m
deep_wiki_agent.okf_lint), so a human can run okf-lint <bundle> [--fix]
[--json] against the same implementation. It is stdlib-only, so a bundle stays
verifiable by anyone holding the directory.
lint itself walks the bundle through a small list_pages/read/exists/
edit interface rather than Path directly. A local directory is wrapped in
it automatically; tools/lint.py additionally adapts a deepagents
BackendProtocol (state, store, sandbox) to the same interface over its own
glob/read/edit. okf_lint.py itself never imports deepagents, so the
shell entry point still needs nothing beyond the standard library.
Two more design details:
- The bundle (path or backend) is captured in the closure, not exposed as
a tool argument, so the model cannot aim the linter (and its
fix=Truewrites) at an arbitrary location. - The report is capped at 50 findings per section. A badly broken bundle would otherwise flood the context window; the summary counts stay exact.
Because the abstraction covers every backend, the factory attaches the tool
unconditionally whenever enable_lint_tool=True — there is no longer a
backend type the linter cannot reach.
The document tool, and why it is optional¶
Not shipping loaders is the right default — which formats a wiki ingests is
domain-specific, and a mandatory PDF stack is dead weight for a bundle built
from plain text. But nearly every bundle's raw/ directory holds PDFs, and
every user was writing the same adapter. tools/documents.py is that adapter,
behind the documents extra, so the cost is paid only by those who opt in.
The extra installs markitdown, which
covers PDF, docx, pptx, xlsx, html and epub through one interface — hence
read_document rather than read_pdf. markitdown is imported inside the
converter, not at module scope, so importing deep_wiki_agent stays free for
everyone else and a missing extra surfaces as an ImportError carrying the
install command rather than as a crash at import time.
Three design details, two of them borrowed from the lint tool:
- Bytes come through the backend, via
download_filesrather thanread.readreturns decoded text, which a PDF does not survive;download_filesreturns raw bytes on every backend, so the tool reads the same tree the agent's file tools do — including a state, store, or sandbox-backed bundle — instead of reaching around it to the local filesystem. - The bundle is captured in the closure and reads are confined to
/raw. The model chooses which source to open and nothing else; path resolution is lexical (..is collapsed, then the result is checked against the root), so traversal cannot walk out of the source directory or into the wiki's pages. - Output is capped at
max_chars, with the truncation announced in the returned text so the model knows it is reading a prefix.
The tool is not attached by any factory: it is passed in tools=, like any
other loader, since its dependency is not part of a default install.
The semantic index, and why it stays outside the bundle¶
The format's claim is that at moderate scale a hand-written index beats
embedding retrieval, and it holds — up to a point. Past a few hundred pages the
category indexes get long and a page linked from the wrong place stops being
reachable. The semantic extra adds a second entry point, under one
constraint: a bundle must not acquire a database.
So the vector store is the caller's, not the library's. Nothing is pinned, and
nothing about the index is authoritative: it holds no fact the bundle does not,
and deleting it costs a re-ingest and nothing else. What stays in the bundle is
one small JSON manifest under .okf/ — invisible to okf_lint, which walks
**/*.md — recording each file's digest and the ids its chunks were stored
under. That is what makes a second ingest cheap and, more importantly,
correct: it is how a rewritten page updates instead of duplicating, and how
the chunks of a page that shrank or was deleted are actually removed. An index
that only ever grows eventually answers questions with pages that no longer
exist.
The rest follows the shape of the other two tools:
- Everything is read through the backend. Markdown through
read, other formats throughdownload_filesand the samemarkitdownconversionread_documentperforms — reused by import, not reimplemented. The index therefore covers exactly the tree the agent's own file tools see. - The bundle, the store and the model are captured in the closure;
ingestion is confined to
ingest_roots(/wikiand/rawby default). The model chooses what to index and what to search for, never where to read from or where to write to. - The two agents get different halves. The manager, which changes the
bundle, gets
semantic_ingestandsemantic_search; the reader gets search alone. This one is not a prompt convention: the filesystem permissions police the file tools, not a tool that talks to a vector store, so a read-only agent stays read-only by not being handed the tool that writes. - Both operations are plain Python too.
ingest_semantic_indexis the same code path without an agent, because an index that only refreshes when a model decides to call a tool is not an index you can depend on.
Chunking lives in its own module and touches none of the above: header-aware splitting, GFM tables lifted out as atomic chunks (a table cut by a character-count splitter loses its header row, and with it the meaning of its numbers), and the page path, section hierarchy and content type recorded in each chunk's metadata. That metadata is what lets an answer cite a page rather than an excerpt — if it is not attached at chunking time, no retrieval quality puts it back later.
Module map¶
| Module | Responsibility |
|---|---|
factory.py |
the two public factories; argument validation, assembly |
backends.py |
the permission sets |
prompts.py |
the two system prompt templates and the not-found default |
okf_lint.py |
the OKF conformance validator (backend-agnostic), plus its shell entry point |
tools/lint.py |
the okf_lint tool and its deepagents backend adapter |
tools/documents.py |
the optional read_document tool: markitdown conversion over backend bytes |
_paths.py |
virtual-path normalization and confinement, shared by the tools that read through a backend |
semantic/chunking.py |
header-aware markdown chunking, tables as atomic chunks, provenance metadata |
semantic/index.py |
ingestion, the incremental manifest, and hybrid-aware search |
semantic/tools.py |
the optional semantic_ingest / semantic_search tools and their factory |
skills/okf-wiki/ |
the skill, at the repo root: not shipped, not mounted |