A coordination space for humans, AI, and automation. Your agent forgets between sessions; your notes and CLAUDE.md get edited by whoever ran last; nobody can reconstruct who wrote what. textus is durable, multi-writer memory that stays current and survives the model, the session, and the vendor — you keep your space, agents keep theirs, automation keeps external data fresh, and every change crosses a review queue and an audit log.
textus is Latin for "the fabric a text is woven from" — same root as context, from con-texere, "to weave together."
The idea
Three actors write to your repo today:
- Humans — you, your team. Authoritative on identity, decisions, voice.
- Agents — Claude, Cursor, custom assistants. Smart, fast, forgetful, and not always right.
- Automation — cron jobs, fetchers, CI. Bring outside data in and compile published artifacts.
flowchart LR
human(["human"]) -->|author| knowledge["knowledge<br/>(canon)"]
agent(["agent"]) -->|keep| notebook["notebook<br/>(workspace)"]
agent -->|propose| proposals["proposals<br/>(queue)"]
proposals -->|human accepts| knowledge
automation(["automation"]) -->|fetch| feeds["feeds<br/>(quarantine)"]
automation -->|build| artifacts["artifacts<br/>(derived)"]
Each actor writes only into its own lane; low-trust input climbs to authoritative lanes only by passing a guarded transition (an agent's proposal needs a human accept).
Without coordination, they overwrite each other and nothing remembers why. textus gives each actor a lane — called a zone in the manifest and CLI, the term used everywhere technical from here on — routes everything they can't write directly through a proposals queue, and writes every successful change to an append-only audit log. The lanes are enforced at the protocol level, not by convention.
knowledge/ author only — who you are, what you decide, how you sound (knowledge.identity.* for identity facts)
notebook/ keep only — agent's own durable lane (agents keep theirs; bytes climb to knowledge only via propose→accept)
feeds/ fetch only — declared external inputs
proposals/ propose (agent + human) — proposals waiting on a human accept
artifacts/ build only — computed, published artifacts
An agent that tries to write directly into knowledge/ gets write_forbidden. It writes to proposals/ (to change authoritative content) or its own notebook/ (for working memory). You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry uid: means a reorganization doesn't break references. A monotonic audit cursor (textus pulse --since=N) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
That's the load-bearing claim: coordination is a protocol invariant, not a library convenience.
See it in four commands
gem install textus
textus init # creates .textus/ with zones + schemas
# agent proposes a change to proposals/
printf '%s' '{"_meta":{"name":"oncall","proposal":{"target_key":"knowledge.notes.oncall","action":"put"}},"body":"Patrick on call.\n"}' \
| textus put proposals.notes.oncall --as=agent --stdin
# you accept it — textus promotes to knowledge/ and audits the move
textus accept proposals.notes.oncall --as=human
Try the gate the other way (textus put knowledge.notes.X --as=agent) and you get write_forbidden, with the role that would be allowed named in the error. That refusal is the whole point.
Try it
- Worked end-to-end store — the role gate (propose → accept), build/publish (
CLAUDE.md/AGENTS.mdgenerated from knowledge entries), schemas, templates, and a hook:examples/project/ - Wire textus into Claude Code via MCP — 4 steps, ~5 minutes:
docs/agents-mcp.md
Protocol, not just a gem
This Ruby gem is the reference implementation of textus/3 — a wire format and storage convention any language can speak. The protocol owns the envelope shape, the role/zone gate, the audit log format, and the key grammar. The gem version (semver, see badge) and the protocol version (textus/3) move independently; envelopes carry the protocol field so consumers can pin to the contract, not the implementation.
- Specification:
SPEC.md - Architecture:
docs/architecture/README.md - Per-release notes:
CHANGELOG.md
A second implementation in another language would share the same .textus/ directory and the same audit log. That's deliberate.
Install
gem install textus
Or from this repo:
bundle install
bundle exec exe/textus --help
What textus init gives you
You get .textus/ with all five zone directories, baseline schemas, an empty audit log, and a starter manifest. Roles declare capabilities; each zone declares a kind:, and write authority is derived from the role's capabilities crossed with the zone's kind:
roles:
- { name: human, can: [author, propose] }
- { name: agent, can: [propose, keep] }
- { name: automation, can: [fetch, build] }
zones:
- { name: knowledge, kind: canon } # author — canonical truth
- { name: notebook, kind: workspace } # keep — agent's own durable lane
- { name: feeds, kind: quarantine } # fetch — declared external inputs
- { name: proposals, kind: queue } # propose — proposals awaiting accept
- { name: artifacts, kind: derived } # build — computed outputs
.textus/
manifest.yaml # role capabilities + zone kinds + key-to-path mapping
audit.log # append-only NDJSON, every write
schemas/ # YAML field shapes per entry family
templates/ # mustache templates for derived entries
hooks/ # one .rb per hook
sentinels/ # publish bookkeeping
zones/
knowledge/ # author — identity (knowledge.identity.*), voice, decisions, notes
notebook/ # keep — agent's own durable lane (agents keep theirs)
feeds/ # fetch — declared external inputs (actions)
proposals/ # propose (agent + human) — proposals awaiting accept
artifacts/ # build — computed outputs
Manifest path: fields are relative to .textus/zones/. So knowledge.notes.org.jane lives at .textus/zones/knowledge/notes/org/jane.md.
Read and write:
textus get knowledge.notes.org.jane
textus list --zone=knowledge
printf '%s' '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
| textus put knowledge.notes.bob --as=human --stdin
textus freshness --zone=artifacts # per-entry fresh/stale/never_fetched/no_policy
textus rule list # show every rule block
textus audit --limit=20 # query the audit log
(All verbs return JSON envelopes by default; pass --output=json explicitly if you prefer.)
For a worked store — knowledge entries, a staged proposal, schemas, a template, and a build that publishes CLAUDE.md / AGENTS.md — see examples/project/.
What's shipped
- Per-entry formats & publish.
format: markdown|json|yaml|textper entry;publish_to:/publish_each:byte-copy derived files to their consumer paths. (SPEC §5.2–5.3) - Stable identity. Auto-minted
uid:survives writes andtextus key mv; reorganising never breaks references. - Capability × zone-kind gate. Writes carry
--as=<role>; a role may write a zone iff it holds the capability the zone'skind:requires (canon→author,workspace→keep,quarantine→fetch,queue→propose,derived→build). The wrong role getswrite_forbiddennaming the capability needed and the roles that hold it. (SPEC §5) - Agent loop.
textus bootorients a fresh session;textus pulse --since=Nis the per-turn heartbeat (changed entries, stale keys, pending proposals). (docs/agents-mcp.md) textus doctor. Health checks across schemas, hooks, keys, sentinels, and the audit log.
CLI and zones
All verbs accept --output=json and return the envelope defined in SPEC §8. Write verbs require --as=<role> (role resolution: --as → TEXTUS_ROLE env → .textus/role file → default human). Default roles: human, agent, automation (rename or add your own in the manifest's roles: block).
- Full verb table — read, write, health, scaffolding — is in SPEC §9.
- Zone semantics and the capability × zone-kind mapping live in SPEC §5, with a tutorial expansion in
docs/zones.md.
textus boot prints the same information for the current store: zones, entry families with schemas, registered hooks, write flows, and the verb catalog. Run it inside a store and you get the live picture; reach for the SPEC when you want the contract.
Compute and publish
Derived entries declare compute: { kind: projection, select: ..., pluck: ..., sort_by: ..., limit: ..., transform: name } and either a template under .textus/templates/ (markdown/text) or a templateless path that lets a transform hook shape the output directly (json/yaml). Projections cap at 1000 rows; the vendored Mustache subset caps at depth 8. No partials, no lambdas, no HTML escaping.
For externally-generated entries, declare compute: { kind: external, sources: [...] } — textus tracks the declared sources for staleness; the build automation produces the file.
publish_to: [path] byte-copies a single derived file to one target. publish_each: "template/{basename}.md" on a nested entry byte-copies every leaf to its templated target — substitutes {leaf}, {basename}, {key}, {ext}. Sentinels for every published file live under .textus/sentinels/. See SPEC §5.2, §5.3, §5.12.
Extension points
textus exposes a hook DSL. Drop .rb files into .textus/hooks/ (subdirectories are fine; files load alphabetically by full path). Events:
:resolve_intake— bring bytes in from elsewhere (returns{_meta:, body:}):transform_rows— transform rows during projection (returns rows):validate— custom doctor check (returns issues):entry_put,:entry_deleted,:entry_fetched,:build_completed,:proposal_accepted,:file_published,:entry_renamed,:proposal_rejected,:store_loaded— react to lifecycle events:fetch_started,:fetch_failed,:fetch_backgrounded— background-fetch lifecycle
# Inside .textus/hooks/local_file.rb
Textus.hook do |reg|
reg.on(:resolve_intake, :local_file) do |config:, args:, **|
path = config["path"] or raise "local-file requires intake.config.path"
{
_meta: { "last_fetched_at" => Time.now.utc.iso8601, "source_path" => path },
body: File.read(File.(path)),
}
end
end
Textus.hook do |reg|
reg.on(:transform_rows, :rank_by_recency) do |rows:, **|
rows.sort_by { |r| r["updated_at"].to_s }.reverse
end
end
To keep a batch of stale intake entries current in one shot:
textus fetch stale --prefix=feeds --zone=feeds --as=automation
# or just fetch everything stale in the feeds zone:
textus fetch stale --zone=feeds --as=automation
See SPEC.md §5.10 for the full hook contract.
Schemas (.textus/schemas/<name>.yaml) declare field shapes, per-field maintained_by: ownership, and an evolution: block (added_in, deprecated_at, migrate_from). Full contract in SPEC §5.8.
See docs/agents-mcp.md for the agent boot → pulse loop.
Examples
examples/project/ — textus as a project's own context store (a fictional Rails service, ledger). Human-authored knowledge/ (project facts, runbooks), a staged ADR in proposals/ showing the agent-propose / human-accept loop, schemas validating each family, a mustache template plus a :transform_rows hook, and a build that publishes the artifacts/orientation projection to CLAUDE.md and AGENTS.md. Includes a copy-paste adoption recipe for your own repo.
Tests
bundle exec rspec
Includes conformance fixtures A–I from SPEC §12.
Code quality
bundle exec rubocop # lint
bundle exec rubocop -A # lint + autocorrect
Lefthook hooks (brew bundle install then lefthook install) run rubocop on pre-commit and rspec + rubocop on pre-push. Bypass with LEFTHOOK=0 git commit ... when needed. CI runs rspec (Ruby 3.3 / 3.4) and rubocop via GitHub Actions.
License
MIT.