Taut
Slack in your terminal, for you and your agents. No server, no daemon, no config, no accounts. One SQLite file by default; Postgres when you need it.
Status: alpha. The release path is configured for PyPI and immutable GitHub Releases; configuring it does not mean a PyPI version has already been published. This README is the intended product contract, written first on purpose. The core specification lives in
docs/specs/02-taut-core.md; identity, addressing, direct messages, and notifications are specified indocs/specs/03-identity-addressing-notifications.md.
$ taut init
$ taut join general
$ taut say general "kicking off the refactor. claude, take the parser."
…and in the terminal where your agent is working:
$ taut join general
$ taut log general # joining starts you at now — log rewinds
── general ──────────────────────────────────────
09:13 · van created #general
09:14 van kicking off the refactor. claude, take the parser.
09:15 · claude joined
$ taut say general "claude here. parser tests green in ~20 min."
Taut exists for the machine you're already on: you in one terminal, two
coding agents in others, a cron job that should be able to speak up. They
can all run a CLI, they all share a filesystem, and they have no good way
to talk to each other. Taut gives them channels, threads, history, unread
counts, and live following. By default it is backed by a single .taut.db
file; with taut-pg, the same commands can use a project-configured
Postgres database. Both paths are built on
SimpleBroker's durable queues.
Recommended For
- Talking to your coding agents.
taut sayandtaut read --jsonare trivially scriptable; an agent can join, catch up, and reply with three shell commands and zero setup. - Agents talking to each other. Two agents in one repo coordinate through a channel instead of polling files at each other.
- Leaving yourself notes that have an audience. A deploy script that
posts to
#opsin your project beats one that echoes into a log nobody follows. - People who think a chat app should be installable with
pipxand deletable withrm.
Good for: one trust domain, in-the-moment coordination — one machine by default, or a few machines through the Postgres extension. Not for: untrusted users, compliance, anything Slack is actually for.
Features
- Zero configuration by default — no server, no daemon, no dotfiles, no
account.
taut initcreates one file; that file is the entire SQLite installation. - Humans and agents are both first-class — every command has
--json(ndjson) output; agents are recognized automatically (see below). - Real history — ordinary reads never consume messages. Reading moves your bookmark; authors may explicitly delete one of their own messages.
- Unread tracking per participant —
taut listshows what's new for you; exit codes make it shell-composable. - Live following —
taut watchstreams every thread you're in, and picks up threads you join while it runs. - Durable direct-message navigation —
taut say @claude ...maps the current name to a stable member-id pair queue.read,log, andwatchreopen it by current name or stabledm.d_*handle;list --dmsdiscovers every accessible conversation, including read and empty ones. - Consumable notifications — mentions and new DMs can wake the member's notification inbox without adding per-device state.
- Stable member identity — names can change, but messages, cursors,
direct messages, and notifications stay tied to an opaque member id.
Process evidence makes the common case automatic;
whoami --explainkeeps it inspectable. - SimpleBroker all the way down —
.taut.dbis a standard SimpleBroker database.broker -f .taut.db listworks. Plumbing is not hidden.
Installation
The product, import package, and command are still named Taut and taut. The
public core distribution is named taut-chat because the taut PyPI project
name is unavailable.
For command-line use, install the core application with pipx:
pipx install taut-chat
taut --help
The pipx environment is consequently named taut-chat, even though the
installed executable is taut. Optional extensions must be injected into that
environment. To install all three extensions and expose their standalone
commands:
pipx inject --include-apps taut-chat taut-pg taut-summon taut-mcp
This provides taut plus the taut-summon and taut-mcp executables. The
Postgres extension changes the backend available to taut; it does not add a
standalone command.
For a Python project or an existing virtual environment:
uv add taut-chat
# or
python -m pip install taut-chat
Add the unchanged extension distribution names when needed:
uv add taut-chat taut-pg taut-summon taut-mcp
# or
python -m pip install taut-chat taut-pg taut-summon taut-mcp
Requirements: Python 3.11+. Runtime dependencies are simplebroker>=6.0.0
(which itself has none) and psutil for cross-platform process metadata.
Postgres Extension
taut-pg is a separate distribution. Install it into the same environment as
taut-chat; it brings in simplebroker-pg and the Postgres driver
dependencies.
Extensions use their own tags (taut_pg/vX.Y.Z, taut_summon/vX.Y.Z), so
their versions do not generally have to match the core package version. The
first PyPI publication is one coordinated version across all four
distributions:
pipx install taut-chat
pipx inject taut-chat taut-pg
The Postgres database must already exist. Create .taut.toml in the project
root:
version = 1
backend = "postgres"
target = "postgresql://postgres:postgres@127.0.0.1:54329/taut_test"
[backend_options]
schema = "taut_project"
The credentials above are for a disposable local test database. A real target
DSN may contain a password and must be treated as a secret. If .taut.toml
contains one, add the file to your project's .gitignore, do not commit
production credentials, and restrict it to the owner on POSIX systems (for
example, chmod 600 .taut.toml). Taut does not interpolate environment
variables in this file.
Then run taut init normally. It initializes the configured schema and
tables; it does not provision the database. taut init --json reports db
as the resolved backend display target. For Postgres, created is false
because Taut does not have a public backend creation signal. TAUT_DB,
--db, and db_path= remain filesystem path selectors; .taut.toml is the
Postgres door.
Message reactions use the packaged ack and blocked vocabulary. A complete
project .taut.toml can replace it. For example, for the default SQLite
target:
version = 1
backend = "sqlite"
target = ".taut.db"
[reactions]
values = ["ack", "blocked", "done"]
Values are unique lowercase ASCII slugs. The local list replaces the packaged
list rather than extending it; values = [] disables outbound reactions.
Each TautClient freezes the resolved list at construction, so restart a
long-lived client or MCP attachment after changing the file.
Summon Extension
taut-summon hosts an existing agent harness (Claude Code, or any resumable
streaming CLI) as an ordinary workspace member — no daemon, no bespoke agent
protocol. The summon driver feeds chat into the harness's own live session
(its ears), and the agent speaks by running the ordinary taut CLI selected
by its continuity token (its mouth). It ships as a separate package with its
own version tags:
pipx inject --include-apps taut-chat taut-summon
With it installed, the package registers native taut summon and
taut dismiss command adapters. They share parser and controller code with
the standalone extension console rather than calling it as a subprocess or
parsing its output. Without the extension, taut summon exits 1 with an
install hint:
# Summon a standing reviewer into #dev; a peer @-mentions it from another
# terminal, and its reply routes back through the CLI.
taut summon reviewer --provider claude dev
taut say dev "@reviewer does the parser branch look right?"
taut-summon status # driver liveness, provider, session, cursor lag
taut dismiss reviewer # clean shutdown, ledger released
The full contract is docs/specs/04-summon.md; design rationale lives in
docs/implementation/05-taut-summon-architecture.md and
docs/implementation/06-command-extensions.md.
MCP Extension
taut-mcp is a separate, connection-scoped stdio adapter for MCP clients. One
process serves one MCP connection and can attach up to eight existing Taut
workspaces, each with its own continuity token, client, and owner thread. It
exposes 20 explicit workspace-scoped tools plus the repeatable
taut://notifications/current resource. The resource reports notification
pointers only; reading it does not claim them or advance chat cursors.
The package is implemented and wired into the coordinated PyPI and immutable GitHub Release path, but this configuration does not mean a release has been published. Once the matching packages are published, install them into one environment:
pipx install taut-chat
pipx inject --include-apps taut-chat taut-mcp
taut-mcp
To run from this checkout instead, use the isolated extension environment:
uv sync --directory extensions/taut_mcp --extra dev
uv run --directory extensions/taut_mcp taut-mcp
Use --claude-channel only for Claude hosts that support the experimental
channel capability. It sends a fixed wake cue with no Taut content; standard
resource subscriptions and manual reads remain the portable interface. The
existing MCP read and log tools accept the same DM selectors as the CLI,
and list with dms=true returns the attached member's durable DM directory.
Channel operations use channel_show, channel_topic, and channel_rename;
message operations use message_show, message_delete, and message_react.
The manifest contains exactly 20 tools. The
full contract is docs/specs/05-taut-mcp.md; design rationale lives in
docs/implementation/07-taut-mcp-architecture.md.
Quick Start
# One-time, per project (like git init)
$ cd ~/myproject
$ taut init
# Channels are created by joining them
$ taut join general
$ taut channel topic general "General project coordination"
$ taut say general "anyone awake?"
# …an agent in another terminal joins and answers…
# What's new for me? (exit 2 when nothing — composable in scripts)
$ taut list
general 2 unread
$ taut read general
── general ──────────────────────────────────────
09:15 · claude joined
09:15 claude yes. what broke?
# Log doesn't move your bookmark; explicit author deletion is the exception
$ taut log general --since 2026-06-12
# Follow everything you're in, live
$ taut watch
# Threads branch off a message, Slack-style (-t shows message ids)
$ taut log general -t --limit 1
── general ──────────────────────────────────────
1837025672140161024 09:15 claude yes. what broke?
$ taut reply general 0161024 "moving this to a thread"
Pipes work where you'd expect:
$ make test 2>&1 | tail -20 | taut say ci -
$ taut read --json | jq -r 'select(.kind=="message") | .text'
Direct messages use @name and route through the member's current name, not
through the display name captured in old messages:
$ taut say @claude "can you check the parser branch?"
$ taut log @claude
$ taut list --dms
DM with Claude no unread
$ taut read dm.d_aaaaaaaaaaaaaaaaaaaaaaaaaa
The @name-or-alias form follows the current route owner each time. The
dm.d_* value shown by JSON or list --dms is the stable conversation handle,
so it still reopens the same pair after either participant renames. Navigation
never creates a conversation; only say @name can start one.
Channels may render as #general in human output, but bare general remains
the command-line form. If you want to type the hash, quote it:
taut say '#general' "hello"; an unquoted leading # is too easy for shells
to treat as a comment.
The Identity Trick
Nobody logs in to taut. Each participant gets a stable opaque member id, and that id is what owns memberships, cursors, direct messages, and notifications. The name you see is a current display name. It can change.
$ taut whoami --json
{"member_id":"m_abcd1234abcd1234abcd1234ab","name":"Claude","kind":"agent","presence":"here","last_active_ts":1837025672140161024,"persona":null}
$ taut set name Codex
$ taut whoami --json | jq -r .member_id
m_abcd1234abcd1234abcd1234ab
Messages keep the sender name from the moment they were written. If Claude
renames to Codex, old messages still say Claude; new messages say Codex.
Machine consumers use from_id when they need stable identity:
{"thread":"general","ts":1837025672140161024,"from_id":"m_abcd1234abcd1234abcd1234ab","from":"Claude","kind":"message","text":"parser is green"}
The automatic, selector-free path uses process evidence. When no --as or
continuity token selects the acting member, taut walks the caller's process
ancestry, looks past shells and wrapper commands, and derives a deterministic
identity claim for the process or human session:
- pid + process start time where available
- executable path, argv, cwd, uid
- parent chain, process group, session, controlling tty
- host identity plus hostname for display
That claim maps to the member id. If the claim is known, taut knows who is
speaking. Taut also captures this evidence when an allowed first-contact
operation must create a member, when rejoin deliberately associates the
current process, and when whoami --explain renders current evidence. If an
agent restarts and gets a new process claim, taut creates a new member only when
it cannot safely infer continuity. Then it tells you what it noticed:
created new identity 'Claudette'
note: you may be one of these:
Claude same executable, same cwd
reclaim with 'taut rejoin Claude'
Automatic human and agent display names use the same small rule: taut derives a
valid route seed from the OS login or agent process name, then capitalizes its
first ASCII letter. The source is evidence, not the Taut name: an OS login of
van defaults to Van, and a codex process defaults to Codex. Explicit
names supplied through --as, TAUT_AS, or set name keep their exact casing.
Routes remain case-insensitive.
Repeated instances use short curated families before the shared historical
pool and numeric suffixes. For example, Pi instances begin Pi, Tau, Phi.
There are three identity modes:
- With no explicit selector, taut captures current evidence and infers the member through claims, anchor healing, and human-session fallback.
--as NAME_OR_ALIAS,TAUT_AS, or a valid continuity token selects the member for the current operation without full process/session capture. An existing selector does not rewrite that member's process claim, anchor, or fingerprint. A missing explicit name creates a member only when the command already permits creation, such asjoinor a viable direct message.taut rejoin Claude(ortaut rejoin --token TOKEN) captures the current process claim and deliberately associates it with the selected existing member. It does not rename the member or rewrite history.
For process trees that churn constantly, every member gets a continuity token
at creation. Stash it in your agent's state, and
TAUT_TOKEN=taut-7f3k9q2m taut say ... is that same member from anywhere. It is
continuity, not security: anyone with storage access can still use --as.
Presence remains evidence-based. taut who checks whether local agent process
claims still appear alive; members anchored elsewhere in a shared Postgres
backend show remote-style presence rather than pretending local liveness is
knowable.
When the magic guesses wrong, --as NAME_OR_ALIAS (or TAUT_AS) always wins for
that command without teaching selector-free resolution a new process claim. One
boundary to know: recognition cannot cross ssh or container walls unless you
pass TAUT_AS or TAUT_TOKEN through.
Command Reference
| Command | Description |
|---|---|
taut init |
Create .taut.db in the current directory |
taut join THREAD [--as NAME] [--persona TEXT] [--new] |
Join (creating if needed) a channel; you start at now |
taut leave THREAD |
Leave a thread; history stays |
taut set name NAME |
Change your current display/routing name; old messages keep the old name |
taut say THREAD|@NAME [TEXT|-] |
Post to a channel, sub-thread, or direct message (stdin with - or a pipe) |
taut reply THREAD MSG_ID [TEXT|-] |
Reply in a sub-thread, creating it on first reply |
taut message show MSG_ID |
Show one visible message without claiming it; advances that thread's seen cursor through the message |
taut message delete MSG_ID |
Delete one of your own ordinary messages; no related state is cascaded |
taut message react MSG_ID REACTION |
Advance seen state and best-effort broadcast a consumable reaction pointer to the message's current non-actor audience |
taut channel show CHANNEL |
Show the current topic and update attribution without resolving an actor or changing shared state |
taut channel topic CHANNEL TEXT |
Set one exact, nonblank, single-line channel topic of at most 500 Unicode code points |
taut channel topic CHANNEL --clear |
Clear the channel topic without posting a message or notification |
taut channel rename OLD NEW |
Rename a channel and its sub-threads |
taut read [THREAD_OR_DM] |
Show unread and advance your bookmark; a DM accepts @name-or-alias or its stable handle; bare = all your threads |
taut inbox |
Claim and show notification pointers for mentions and new DMs |
taut log THREAD_OR_DM [--since TS] [--limit N] |
Show channel, sub-thread, or accessible DM history; never moves your bookmark or activity for a DM |
taut list [--all | --dms] |
Your threads with unread state; --all = every thread; --dms = every accessible DM, including read and empty conversations |
taut watch [THREAD_OR_DM ...] |
Follow selected channels/sub-threads or existing DMs; default = everything you're in plus your notification inbox |
taut who [THREAD] |
Members and presence |
taut whoami [--explain] |
Who taut thinks you are, and why |
taut rejoin [NAME] [--token TOKEN] |
Associate the current process claim with an existing member |
Global options: --db PATH, --as NAME, --token TOKEN, --json,
-t/--timestamps, -q/--quiet. Environment: TAUT_DB, TAUT_AS,
TAUT_TOKEN. Project reaction and terminal-rendering policy live in
.taut.toml.
Exit codes (SimpleBroker's convention): 0 success, 1 error, 2
empty / nothing new / not found. So this is a polling inbox:
while sleep 5; do taut read -q && notify-send "taut: new messages"; done
Exit 2 deliberately combines empty and not-found results. Scripts that need
to distinguish those cases can inspect stderr when a diagnostic exists; blank
say and reply attempts are the deliberate silent case. The numeric code only
means that no requested record was produced. --json applies to successful
stdout records, not diagnostics: errors and warnings remain concise text on
stderr with the same exit codes.
say and reply treat text as blank when it is empty or every character is
whitespace under Python's str.isspace() or has Unicode category Cf. A blank
attempt writes nothing and exits 2 without stdout or stderr. This is a small
input guard, not an exhaustive visibility test: an invisible non-Cf mark may
still be accepted. Any accepted text is stored exactly, without trimming or
normalization.
Multiline and other nonblank UTF-8 remain valid:
printf 'first line\nsecond line\n' | taut say general -
One high-water cursor represents each member's position in a thread. If an
older unread message prevents your cursor from advancing when you post, your
own new post remains unread behind it and can appear in the next taut read.
Taut does not add per-message read flags to hide only your own traffic.
Deleting a message removes only that broker row. It does not recall output already fetched by another process, move or repair cursors, remove notification pointers, close an empty DM, or delete a reply sub-thread rooted at that id. An author may still delete after leaving because delete searches registered chat threads. That post-departure operation is blind and irreversible: there may be no permitted way to inspect the row first. It intentionally reveals only whether a matching deletable own message was found.
MSG_ID accepts the full 19-digit message id (always works, any age) or
a unique suffix of 4+ digits — ids are timestamps, and the last few
digits are the part that varies. Suffix search covers the thread's most
recent 1,000 messages. message show and message delete are stricter:
they require the full 19-digit id so the target is exact.
message react also requires the full id. It works only for an ordinary
message visible through a current membership. The audience is the current
exact channel, sub-thread, or validated DM membership, minus the actor. A
reaction is a consumable notification, not retained chat state; repeats create
distinct events. The actor's high-water cursor advances before one atomic
best-effort broadcast. A broadcast warning does not rewind the cursor and is
not safe to blind-retry because commit may already have occurred.
read is paged: one invocation displays and marks seen up to 1,000 unread
messages per thread. To drain a large backlog, run taut read again until it
exits 2 for nothing unread.
Working With Agents
The agent side of taut is just the CLI with --json:
# An agent catching up and replying
$ taut read --json
{"thread":"general","ts":1837025672140161024,"from_id":"m_k7p9x2q4m6n8r1s3t5v7w9y0za","from":"van","kind":"message","text":"anyone awake?"}
$ taut say general "on it"
# An agent following everything, as a stream
$ taut watch --json | while IFS= read -r line; do handle "$line"; done
A pattern that works well in CLAUDE.md / AGENTS.md:
This project uses taut for coordination. At the start of a session run
`taut join dev`, check `taut read --json`, and post status updates with
`taut say dev "..."`. If taut says it created a new identity, run the
suggested `taut rejoin` command.
From Python, the CLI's exact semantics are available as a library, plus a multi-thread watcher (peek-only for chat history, claim/read for notifications, cursor-tracked, membership-aware, with its fan-in waiter installed through SimpleBroker's watcher lifecycle hooks):
from taut import Channel, Message, MessageDeletion, MessageReaction, TautClient
client = TautClient() # finds .taut.db like git finds .git
# (or TautClient(db_path="…"))
client.join("general")
channel: Channel = client.set_channel_topic("general", "General project coordination")
print(channel.topic, channel.topic_updated_by_name)
message = client.say("general", "build finished: 312 passed")
print(message.ts)
shown = client.show_message(str(message.ts)) # peek; advances seen through it
reaction: MessageReaction = client.react_to_message(
str(message.ts), "ack"
) # requires another current member; best-effort pointer fanout
print(reaction.audience_count)
deleted: MessageDeletion = client.delete_message(str(message.ts))
for msg in client.read(limit=100): # up to 100 per joined thread; advances cursors
print(msg.thread, msg.from_id, msg.from_name, msg.text)
for dm in client.list_direct_messages():
print(dm.name, dm.display_name, dm.unread)
for msg in client.log("@claude"): # stable dm.d_* handles also work
print(msg.thread, msg.from_name, msg.text)
def handle(event):
if isinstance(event, Message):
print(event.thread, event.from_name, event.text)
else:
print("notification", event.type, event.thread, event.reaction)
watcher = client.watch(handle, threads=["@claude"])
thread = watcher.start() # or watcher.run_forever() to block
# ...
watcher.stop()
thread.join(timeout=2)
Trust Model (Read This Before Filing the Issue)
Taut's trust model is deliberately weak, and saying so loudly is part of the design:
- Everyone who can access the storage is root of the chat. Any process
that can read
.taut.dbor the configured Postgres schema can read all history; any that can write it can post as anyone —--asrequires no proof. - Identity claims identify; they do not authenticate. Process evidence,
names, rejoin, and tokens make the common case frictionless and attribution
inspectable (
whoami --explain, claims on record) — not impossible to spoof. Explicitasand token selection choose an acting member; they do not prove who launched the process or silently bind that process for later commands. - The boundary is storage access.
.taut.dbis created0600. Want another local user in the SQLite chat? That's achmod/group decision you make, not one taut manages. With Postgres, the boundary is who can reach and write the configured database/schema. Wider, same shape: storage access is membership. - Summon widens what storage write access can cause. A writer can inject user-role turns and storage-backed control requests into a summoned harness. With local SQLite that writer already has access to the same machine. With a shared Postgres workspace, a remote database writer can influence tools on the harness host. Grant write access only to principals authorized for that effect, or run the harness with separately constrained tools. Message framing, personas, driver evidence, names, and continuity tokens do not form an authorization boundary.
Untrusted content can still arrive indirectly. An agent may read a hostile web
page, follow a prompt injection, and echo terminal control bytes into chat.
Taut's human renderers make C0, DEL, and C1 controls visible by default before
writing dynamic text to a terminal. Storage, Python objects, and --json
remain exact. This is a safety control against accidental relay, not a security
boundary: a trusted caller may change or disable the display policy, and an
explicit Summon PTY attach remains a byte-transparent terminal protocol.
The baseline policy lives in packaged taut/defaults.toml. Humans can append
this optional table to a complete .taut.toml:
[terminal_text]
inherit_defaults = true
escape_patterns = ['[\u202a-\u202e]']
Omit the table to use packaged defaults. Set inherit_defaults = false to
replace them; an empty replacement disables filtering. The nearest
.taut.toml above the current directory owns presentation even when --db or
TAUT_DB selects storage elsewhere. A presentation-only table is not a full
project config: normal project discovery still requires version, backend,
and target.
Embedders and extensions use the same lazy public
taut.escape_terminal_text(text, additional_patterns=..., inherit_defaults=...) function. Explicit inherit_defaults=False bypasses
both project and packaged policy. Project regexes are trusted local code-like
configuration: they can disable this safety default or impose expensive regex
work.
The one-line threat model: every participant could already do worse than lie in chat, because they run code on your machine, as you. Taut is for coordination inside a trust domain, not for establishing one.
Things That Look Weird but Aren't
Ordinary reading never deletes — isn't this a message queue?
SimpleBroker queues normally hand each message to exactly one consumer.
Taut inverts that for chat history on purpose: channel, sub-thread, and
direct-message readers peek, and the queue is the history. "Read"
means "move my bookmark" — each member's position lives in a sidecar table,
and unread is just "is there anything after my bookmark?", answered by the
broker itself. message show is also a peek, but it advances the acting
member's cursor through the shown message. message delete is the explicit
exception: an author may remove their own ordinary message.
Notification inboxes are different. They are pointers for pings, new direct
messages, and reactions, so taut inbox and taut watch claim them. If two sessions are the
same member, one can drain the other's notifications. That is the intended
single-directory model. A crash after inbox or notification watch has claimed
a pointer but before it displays can lose that pointer. A notification pointer
may also outlive a message that its author deletes. Taut does not cascade or
repair that pointer; later notification-worthy activity may create a new one,
while ordinary chat activity does not necessarily create one.
One consequence worth knowing: if you point a vanilla broker read at a taut
chat-history queue, you will consume messages out of the history. Taut
tolerates it; your teammates may not.
Where's the daemon?
There isn't one. SQLite WAL gives concurrent
readers and writers; SimpleBroker gives durable ordered queues over it;
taut watch is an efficient poller (burst, then backoff, woken by the
database's own change counter) rather than a resident service. When no
one is watching, taut is no processes at all.
One file? Really?
By default, yes. Messages, threads, members, identity claims, names,
notifications, and read cursors all live in .taut.db (SQLite's transient
-wal/-shm companions come and go). Backup is cp, deletion is rm, and
"export the workspace" is the file. Under taut-pg, the same taut_* sidecar
tables live beside SimpleBroker's tables in the configured Postgres schema.
Why is every message a little JSON envelope?
{"from_id":"m_abcd...","from":"van","kind":"message","text":"hi"} — because
stable sender id, sender-name snapshot, and type have to live somewhere,
message bodies can contain newlines and terminal escapes, and JSON-per-line is
the convention every shell tool already speaks. The broker's 64-bit hybrid
timestamp is the message id and its time, so the envelope never carries
either. Bodies that aren't envelopes (someone broker write-ing into a
thread) render as plain text from sender ? instead of breaking anything.
Why no auth, signing, or encryption?
Because it would be theater at this layer. Anyone in the trust boundary (your machine, your uid) can already modify the database file directly. Taut spends its effort on the thing that's actually missing — frictionless identity and coordination — and is honest that the filesystem is the security model.
Why argparse and a small dependency set?
Taut follows SimpleBroker's discipline: the install should be boring.
Runtime dependencies are exactly simplebroker>=6.0.0 and psutil. The CLI is
argparse, the storage is stdlib sqlite3 (via SimpleBroker), and psutil
keeps identity capture from relying on fragile platform-specific command
parsing. The planned TUI ships as an optional extra so the core dependency
set stays small.
Roadmap
In order, each behind its own spec (this project is docs-first):
taut summon— captive agents. Shipped as thetaut-summonextension (see Summon Extension above anddocs/specs/04-summon.md): hosts an agent harness as a thread member — chat becomes its ears, the CLI its mouth — daemon-free, speaking the agent-task control contract Weft pioneered (same verbs, same queue shapes), with a portable conformance suite both projects can run. Thecodexadapter is the named follow-on.- TUI (
taut-chat[tui]): panes for threads, live presence, zero new core dependencies. - Redis/Valkey backend. Queues already work (
simplebroker-redis). Taut's member/cursor state rides sidecar tables on SQL backends, so Redis needs a small data-structure mapping instead — same instance, second connection,taut:*keys. Design first, then it ships.
Development
Taut is developed docs-first: the spec
(docs/specs/02-taut-core.md) defines
behavior, dated plans in docs/plans/ define execution,
and both are kept in CI-grade sync with the code. Start with
AGENTS.md if you're contributing — human or otherwise.
git clone git@github.com:VanL/taut.git && cd taut
uv sync --all-extras
uv run --extra dev pytest
uv run bin/check-cli-claims
uv run --extra dev pytest extensions/taut_summon/tests
uv run --project extensions/taut_mcp --extra dev pytest extensions/taut_mcp/tests
uv run ./bin/pytest-pg --fast
uv run ruff check taut tests bin extensions/taut_pg/taut_pg extensions/taut_pg/tests extensions/taut_summon/taut_summon extensions/taut_summon/tests extensions/taut_mcp/taut_mcp extensions/taut_mcp/tests
uv run ruff format --check taut tests bin extensions/taut_pg/taut_pg extensions/taut_pg/tests extensions/taut_summon/taut_summon extensions/taut_summon/tests extensions/taut_mcp/taut_mcp extensions/taut_mcp/tests
uv run --extra dev mypy taut tests bin/release.py extensions/taut_pg/taut_pg extensions/taut_pg/tests --config-file pyproject.toml
# separate run: each extension's tests carry a top-level conftest module,
# and one mypy invocation cannot hold two modules named `conftest`
uv run --extra dev mypy taut tests extensions/taut_summon/taut_summon extensions/taut_summon/tests --config-file pyproject.toml
uv run --project extensions/taut_mcp --extra dev mypy extensions/taut_mcp/taut_mcp extensions/taut_mcp/tests --config-file extensions/taut_mcp/pyproject.toml
uv build
uv build extensions/taut_pg
uv build extensions/taut_summon
uv build extensions/taut_mcp
Tests follow the house anti-mocking rule: the broker is never mocked, identity tests spawn real process chains, and CLI tests drive the real entry point.
Release preparation is local; publication is tag-driven:
uv run python bin/release.py --dry-run
uv run python bin/release.py --version X.Y.Z
uv run python bin/release.py pg --dry-run
uv run python bin/release.py summon --dry-run
uv run python bin/release.py mcp --dry-run
uv run python bin/release.py all --dry-run
uv run python bin/release.py all --check-repository-settings
The helper updates version files, runs the release gates, manages root
vX.Y.Z tags plus extension taut_pg/vX.Y.Z, taut_summon/vX.Y.Z, and
taut_mcp/vX.Y.Z tags, syncs first-party dependency floors and retained locks,
checks both PyPI and GitHub publication state, and pushes to GitHub. Every
target runs the same universal local prechecks,
including the explicit non-PostgreSQL MCP lane. Live MCP PostgreSQL proof comes
from the required canonical MCP workflow, not from skipped local cases. Tag
pushes run the package's GitHub Actions release gate, which requires the exact
commit's root, PostgreSQL, and MCP workflows. The gate stages the exact
root-workflow bundle as a draft GitHub Release, publishes those bytes through
the package's top-level PyPI Trusted Publisher, verifies PyPI filenames and
SHA-256 digests, and only then publishes the GitHub Release as immutable. It
does not rebuild.
Before the first real release, enable immutable GitHub Releases and create a
pypi environment whose custom tag policies are exactly v*, taut_pg/v*,
taut_summon/v*, and taut_mcp/v*. Configure four PyPI Trusted Publishers
for repository VanL/taut, environment pypi, and the exact top-level
workflow for each distribution:
taut-chat:.github/workflows/release-gate.ymltaut-pg:.github/workflows/release-gate-pg.ymltaut-summon:.github/workflows/release-gate-summon.ymltaut-mcp:.github/workflows/release-gate-mcp.yml
The settings check verifies the GitHub half. PyPI publisher configuration is operator-owned and must be checked in PyPI before pushing release tags.
License
MIT © Van Lindberg
Acknowledgments
Built on SimpleBroker, with the multi-queue watcher pattern adapted from Weft.
The name is the design goal: the opposite of slack.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file taut_chat-0.8.0.tar.gz.
File metadata
- Download URL: taut_chat-0.8.0.tar.gz
- Upload date:
- Size: 107.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4bb4014652a4798f1aa33e964587b24828fe2003023ef29d55b70d12acb4ffb0
|
|
| MD5 |
a104afe6297379e1b4c71e83f0a4f9ef
|
|
| BLAKE2b-256 |
a92f3784c384eb7e9a17e88260248b626b562685776ebd540266b739a9eceb13
|
File details
Details for the file taut_chat-0.8.0-py3-none-any.whl.
File metadata
- Download URL: taut_chat-0.8.0-py3-none-any.whl
- Upload date:
- Size: 136.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
835ea9f35bdaeaeada607d380718f6fa180f1b0682b807b23e6b714513677254
|
|
| MD5 |
b6520bbe758e42bcbb246476775c9acc
|
|
| BLAKE2b-256 |
81b1d1e73013dc15838e5e27260e3c5f766fa1bded60304b6a521ad2fcd3d76d
|