Skip to main content

Taut

CI codecov Python versions

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 in docs/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 say and taut read --json are 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 #ops in your project beats one that echoes into a log nobody follows.
  • People who think a chat app should be installable with pipx and deletable with rm.

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 init creates 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 participanttaut list shows what's new for you; exit codes make it shell-composable.
  • Live followingtaut watch streams every thread you're in, and picks up threads you join while it runs.
  • Durable direct-message navigationtaut say @claude ... maps the current name to a stable member-id pair queue. read, log, and watch reopen it by current name or stable dm.d_* handle; list --dms discovers 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 --explain keeps it inspectable.
  • SimpleBroker all the way down.taut.db is a standard SimpleBroker database. broker -f .taut.db list works. 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.1 (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:

  1. With no explicit selector, taut captures current evidence and infers the member through claims, anchor healing, and human-session fallback.
  2. --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 as join or a viable direct message.
  3. taut rejoin Claude (or taut 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.db or the configured Postgres schema can read all history; any that can write it can post as anyone — --as requires 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. Explicit as and 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.db is created 0600. Want another local user in the SQLite chat? That's a chmod/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.1 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 the taut-summon extension (see Summon Extension above and docs/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. The codex adapter 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 --out-dir dist .
uv build --out-dir extensions/taut_pg/dist extensions/taut_pg
uv build --out-dir extensions/taut_summon/dist extensions/taut_summon
uv build --out-dir extensions/taut_mcp/dist 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.yml
  • taut-pg: .github/workflows/release-gate-pg.yml
  • taut-summon: .github/workflows/release-gate-summon.yml
  • taut-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

taut_chat-0.8.1.tar.gz (108.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

taut_chat-0.8.1-py3-none-any.whl (137.4 kB view details)

Uploaded Python 3

File details

Details for the file taut_chat-0.8.1.tar.gz.

File metadata

  • Download URL: taut_chat-0.8.1.tar.gz
  • Upload date:
  • Size: 108.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for taut_chat-0.8.1.tar.gz
Algorithm Hash digest
SHA256 2c6d2bb6e4ca426b3158b2d6d95b0792b226521d0de7e3f1674a08eed8b37cc2
MD5 405cae163f06fffc0ecf0cce048cae13
BLAKE2b-256 7628d8de979656eebffd628bb7f99032d7a8ff20102381bb42175fb208513b50

See more details on using hashes here.

Provenance

The following attestation bundles were made for taut_chat-0.8.1.tar.gz:

Publisher: release-gate.yml on VanL/taut

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file taut_chat-0.8.1-py3-none-any.whl.

File metadata

  • Download URL: taut_chat-0.8.1-py3-none-any.whl
  • Upload date:
  • Size: 137.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for taut_chat-0.8.1-py3-none-any.whl
Algorithm Hash digest
SHA256 07902d55ab26689702cf3dc19236069f2208e3ce186c6afb52e13bb295713544
MD5 89f7a3158ca6caf74f959ce4690e0a42
BLAKE2b-256 dbba04ce862529ee57778f01cf13884b7a0c14d8984f2f4229b712c949974b00

See more details on using hashes here.

Provenance

The following attestation bundles were made for taut_chat-0.8.1-py3-none-any.whl:

Publisher: release-gate.yml on VanL/taut

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page