Skip to main content

tau-core

The agent core of tau, a personal "smart body" agent platform: build_agent, the persona, permission tiers, model roles, session stores and the tau command line. It is built on Strands Agents and is published on its own as tau-core (import name tau_core).

pip install tau-core        # or, inside the tau repo: uv sync --all-packages --group dev
tau --help                  # chat, hub, components, doctor, init, remote, setup, spend, telegram, version, plus plugin commands (tui, net, ...)
tau setup                   # pick features, install their packages, set them up (docs/reference/setup.md)
tau remote status           # remote access from the phone: Tailscale, SSH, mosh, Termius
tau init                    # create ~/.tau/tau.toml and the persona files from the templates
tau doctor                  # check config, credentials, budgets, sessions, persona, .env, the hub
tau spend                   # what every model role spent in its budget period
tau chat                    # talk to tau in the terminal
tau --lang tr chat          # same, with Turkish UI chrome
import asyncio
from tau_core import build_agent

agent = build_agent("home")  # config from tau.toml + .env, model from the "brain" role
print(asyncio.run(agent.ask("Merhaba!")))

build_agent makes no hub assumptions: no network of its own, no launchd, no globals. With tau_core.testing.FakeModel it runs fully offline, which is how the test suite runs.

Configuration

tau.toml (under TAU_HOME) maps each model role to a provider and model id; code only ever asks for a role (brain, fast, local). Credentials and roots come from the environment or TAU_HOME/.env (never overriding variables that are already set): TAU_HOME (config root, default: the current directory if it has a tau.toml, else ~/.tau), TAU_DATA_DIR (mutable state, default ~/.local/share/tau), TAU_PROFILE (home | travel | sport, default home), TAU_ROLE (hub|node), TAU_HUB_PORT, TAU_LANG (see below). Built-in providers, all called directly: anthropic, openai (also any OpenAI-compatible API), gemini, ollama (a local model, what the shipped local role uses), bedrock (optional) and fake; any role may name a fallback used when its primary has no credentials, and a tau.providers component adds more providers (see Components). A missing credential stops tau chat with a message naming the variables; tau doctor reports it without starting anything, and tau tui opens its settings screen so the key can be added in place.

[models.roles.brain]
provider = "anthropic"    # anthropic | openai | gemini | ollama | bedrock | fake
model_id = "claude-sonnet-5"
max_tokens = 4096
budget = { usd = 15, period = "month", degrade_to = "fast" }  # a hard cap tau enforces itself
cache = { strategy = "auto", ttl = "1h" }  # optional: Strands CacheConfig (anthropic, bedrock)

[models.roles.fast]
provider = "gemini"
model_id = "gemini-3.1-flash-lite"
budget = { usd = 1, period = "day" }

[models.roles.local]
provider = "ollama"       # params.base_url, else TAU_OLLAMA_URL, else http://127.0.0.1:11434
model_id = "qwen3.5:9b"
params = { reasoning_effort = "none" }
failover = "fast"         # answers while Ollama cannot be reached

[models.prices."my-model"]  # extends or overrides the shipped prices.toml ($ per 1M tokens)
input = 0.5
output = 1.5

[agent]
max_turns = 12            # model calls per user turn (Limits(turns=...))
context = "sliding"       # sliding | summarize: the model's window on a long session
window_size = 60          # sliding: messages kept; summarize: recent messages never summarized
max_total_tokens = 200000 # optional per-turn token budget (Limits(total_tokens=...))

Models, budgets and failover

Providers are called directly (ADR 0011), with no gateway or container in between. anthropic reads ANTHROPIC_API_KEY (plus ANTHROPIC_WORKSPACE_ID, sent as anthropic-workspace-id, for a key not scoped to a workspace; params.base_url and params.workspace_id override). openai reads OPENAI_API_KEY, takes params.base_url for an OpenAI-compatible API and sends max_tokens as max_completion_tokens. gemini is Strands' GeminiModel with GEMINI_API_KEY or GOOGLE_API_KEY and max_output_tokens. params.api_key_env names another key variable for these three. ollama calls Ollama's OpenAI-compatible endpoint (params.base_url, else TAU_OLLAMA_URL, else http://127.0.0.1:11434) with a 3-second connect timeout and no retries, and needs no credentials. tau init writes brain and fast from templates/models.toml for the provider it picks and keeps local on Ollama.

budget (a [models.roles.<role>.budget] table or an inline table) is the role's hard spend cap: usd (a positive number of dollars), period (day, week or month: calendar periods in Europe/Istanbul, reset at midnight, on Monday, on the 1st) and optional degrade_to. failover names the role that answers when this role's model cannot be reached. Both must name another defined role, must not loop together (brain → fast → brain is a ConfigError) and are not allowed inside a fallback. resolve_model wraps a role with either in tau_core.budget.RoleModel; other roles are returned unwrapped and are not metered.

  • Before a call RoleModel reads the role's spend in the current period from the ledger. At the cap it answers with degrade_to (the turn goes on and yields Notice(notice.budget_degraded): The brain budget is spent until 01.10.2026 00:00; fast answered instead.) or raises BudgetExhausted, which ends the turn with stop reason error and notice.budget_exhausted: The brain budget is spent ($15.00 of $15.00) and resets at 01.10.2026 00:00. To raise the cap, change it in tau.toml. (notice.budget_exhausted_also when the stand-in is spent too). BudgetExhausted is a ModelNotice: one sentence in the UI language, a warning in the log, never a trace.
  • After a call the answer's Strands usage is priced with tau_core.prices and recorded in tau_core.spend.Ledger, a SQLite file at TAU_DATA_DIR/spend.sqlite3 (mode 0600, WAL) shared by every process on the hub: time, role, provider, model id, token counts and dollars, never text. Prices come from templates/prices.toml ($ per million tokens: input, output, cache_read, cache_write, keyed by model id) and [models.prices."<model_id>"] in tau.toml (input and output required, the cache prices default to input); ollama and fake are free. Anthropic and Bedrock count cached tokens apart from the input tokens, OpenAI and Gemini include them; cost handles both. A call on an unpriced model is recorded without a cost (it counts as $0) and tau doctor warns about a budgeted role on one.
  • Failover: when the provider cannot be reached before anything streamed (connection refused or timed out, a 5xx; is_unavailable walks the exception chain, throttling excluded), the failover role answers, the turn yields Notice(notice.failover) (local is not reachable right now; fast answered instead.) and the log gets a warning.

Stand-in roles are ordinary roles: their own budget and failover apply and their spend is recorded under their own name. A cap change in tau.toml applies when the process starts. tau_core.spend also serves every reader of the ledger: spend_report(config) (a RoleSpend per role, a role without a budget reported for the current month), describe(...) (the one-line form), the budget context source (- Budget: … under ## Now), the spend hub service (GET /spend, see Hub), the Budget: line of /status and tau spend:

tau spend [--json]     # spend, cap, period and reset per role, from the ledger
  brain    anthropic $0.42 of $15.00 this month, resets 01.10.2026 00:00
  fast     anthropic $0.03 of $1.00 today, resets 01.10.2026 00:00
  local    ollama    $0.00 this month, no budget

Editing the two files: tau_core.settings

Both channels edit the bootstrap config in place: /settings in tau chat (a report plus language, profile, model, set NAME and clear NAME setters; the value of set is read with getpass and never echoed) and the settings screen of the TUI. The shared editor is tau_core.settings, small and testable:

from tau_core.settings import (
    load_document,
    set_role,
    set_ui_language,
    set_agent_option,
    save_document,
    validate,
    set_credential,
    clear_credential,
    credential_status,
    known_env_names,
    mask,
    describe_home,
)

doc = load_document(home)  # tomlkit: comments and layout survive
set_role(doc, "brain", "anthropic", "claude-…", max_tokens=4096, fallback=None)  # None removes,
set_agent_option(doc, "max_total_tokens", None)  # KEEP (the default) leaves a key as it is
set_ui_language(doc, "tr")
save_document(home, doc)  # check_document() first, then temp file + rename
set_credential(home, "ANTHROPIC_API_KEY", value, export=True)  # .env at mode 0600; os.environ too
credential_status(home, config.roles["brain"])  # ok | none | missing | fallback | unavailable
validate(home)  # Config.load on the saved files

save_document refuses a document Config would not load (nothing is written), keeps the file's mode, and .env is created private and kept private. No function returns, logs or prints a credential value; the UI shows mask(value) (sk-…abcd from 20 characters on, ab…yz for 12 to 19, only … and the last two below that) or a set / not set flag, and credential_source says whether the shell environment or .env supplies a value (the shell always wins). set_role and check_document refuse a planned provider (one a later issue builds, listed in PLANNED_PROVIDERS; none today) that a save would newly introduce, since a role on it cannot start; one that is already in the file is left alone. set_role leaves a role's budget table and failover as they are; a budget moved onto a model with no price counts nothing until the price is added (tau doctor warns). known_env_names(home) lists what a credentials editor should offer: the variables of the configured providers, then every other provider's, then the names in .env.example and .env. report_lines(config, language=…) is the /settings listing both channels print. Config.load raises ConfigNotFound (a ConfigError) when there is no tau.toml, which is what the TUI's setup screen keys on.

context picks Strands' SlidingWindowConversationManager or SummarizingConversationManager; the session store still keeps every message, only what the model sees is windowed. A session remembers the manager it was recorded with (Strands refuses to restore it under the other one), so after changing context an earlier session fails to open with Session <id> was recorded with a different [agent] context setting; set it back to sliding or start a new session (SessionContextMismatch, a ConfigError) in tau chat --session/--resume, tau tui --resume, /resume and the TUI's picker and sidebar alike; the current session is kept. The SDK's context_manager = "auto"/"agentic" presets are rejected with a ConfigError: they register untiered SDK tools that the tier gate would refuse. cache accepts the CacheConfig fields (strategy, ttl, system_prompt_ttl, tools_ttl); openai, gemini, ollama and fake ignore it.

UI language and commands

Everything a person reads in a channel's chrome (commands, notices, the approval prompt, tier labels, the TUI's titles and key hints) comes from one message table, tau_core.i18n, so the terminal and the TUI never drift. The language is per installation:

[ui]
language = "en"   # en | tr

TAU_LANG overrides the file, tau --lang tr ... overrides both (it is exported as TAU_LANG, like --profile), and /lang en|tr switches it for one run. Config.ui.language is what a channel reads. What the model reads (system-prompt scaffolding, tool descriptions, the gate's refusal texts) is fixed English; errors raised before a Config exists (ConfigError, MissingCredentials, hub messages, tau hub status) are plain English too. The persona itself is Turkish by default and answers in the language the user writes.

Slash commands are shared by tau chat and tau tui (tau_core.commands); the Turkish aliases are accepted forever but never shown in hints:

command aliases meaning
/help /yardım, /yardim, /? list the commands with one-line help
/tools /araçlar, /araclar registered tools with tier and description
/sessions /oturumlar the 10 most recent earlier sessions, numbered
/resume [n|id] /devam resume the most recent earlier session, the n-th of the last listing, or an id
/new /yeni start a fresh session
/session /oturum current session id, title, message count, store
/compact [focus] /özet, /ozet summarize this session into a new one and continue there; the old one is kept (see Compaction)
/clear /temizle, /cls clear the screen; the session continues, nothing is deleted (--resume shows the history again)
/status /durum profile, session, model, context, tools, UI language, versions, hub, budget
/model [role] /rol list the model roles, or switch this session to one (remembered by /resume)
/lang en|tr /dil switch the UI language for this run
/settings [key value] /ayarlar, /ayar show the bootstrap config (roles, credential status, language, profile, context, files); language en|tr, profile NAME, model ROLE PROVIDER MODEL_ID, set NAME (hidden prompt), clear NAME change one and reload; the TUI opens its settings screen
/guard [lock|unlock] /koruma the owner guard's state; lock locks it; unlock unlocks at the lock prompt (see Guard); without a guard it says the guard is off
/quit /çık, /cik, /exit leave (/exit is an alias, /quit is the name the hints show)

Matching is str.casefold() on the typed name (/ARAÇLAR works). In code:

from tau_core import LANGUAGES, SLASH_COMMANDS, command_hint, resolve_command, t

resolve_command("/Devam 2")  # ("resume", "2"); None for "/x" or plain text
command_hint()  # "/help, /tools, /sessions, /resume, /new, ..."
t("session.not_found", "tr", session="x")  # "Oturum bulunamadı: x."

t(key, language, **kwargs) formats with str.format, raises KeyError for an unknown key and falls back to English for a language that lacks the key. Plugins add their own keys with tau_core.i18n.register_messages({"mine.hello": {"en": "...", "tr": "..."}}).

Persona

The system prompt is rebuilt before every model call (a Strands BeforeModelCallEvent hook), so an edit to a persona file applies on the next model call without a restart, even inside a tool loop. Its layout is fixed:

<persona/persona.md>

## User
<persona/user.md, or: No user file (persona/user.md). Address the user formally.>

## Now
- Date and time: 29.09.2026 15:30, Tuesday (Europe/Istanbul)
- Profile: home
- Host role: hub
- Live nodes: unknown
- Budget: brain $0.42 of $15.00 this month (resets 01.10.2026 00:00); fast $0.03 of $1.00 today (resets 01.10.2026 00:00)

The scaffolding (headings, labels, unknown) is English because the model reads it; the persona decides the reply language. The model receives the prompt as three content blocks (PersonaLoader.build_system_content): the persona plus ## User (stable), a cachePoint, then ## Now (changes every minute), so a provider that caches system prompts reuses the stable part; build_system_prompt returns the same text as one string for tests and logging.

  • persona.md is tau's character: Jarvis-style, Turkish by default and mirroring the language the user writes in, polite, dry humor, at most two sentences for notifications and voice, no jokes in approval requests, never inventing facts about the user. It is tracked, so it holds nothing personal.
  • user.md holds the facts about the user and how to address them. It is gitignored; start from the made-up template with cp persona/user.example.md persona/user.md. Without it (or when it is empty) tau addresses the user formally ("siz") and knows nothing about them.
  • A file is re-read only when its (st_mtime_ns, st_size) changes. HTML comments (<!-- ... -->) are notes for humans and never reach the model.
  • A missing, empty or unreadable persona.md makes build_agent raise ConfigError. If the file breaks later (deleted, truncated mid-save, not UTF-8), the last good version stays in use until it is fixed; each such problem is logged once.
  • Time is always Europe/Istanbul. A value no layer provides yet reads unknown (an empty node list reads none), and the persona tells the model never to guess it.

Later layers fill the dynamic block through context sources: zero-argument callables that return partial overrides (nodes, budget), merged in order. They run before every model call, so they must return a cached value quickly. Unknown keys are ignored, None means unknown, and a source that raises is skipped and logged once. Without an explicit context_sources list, build_agent uses the enabled tau.context components (see Components); an explicit list, even an empty one, replaces them.

agent = build_agent(
    context_sources=[
        lambda: {"nodes": ["mac", "arm"]},  # live nodes (L5)
        lambda: {"budget": "fast $0.12 of $1.00 today (resets 01.10.2026 00:00)"},  # spend (L2)
    ],
)

tau-core ships one context source itself, budget (tau_core.spend:context_source): with at least one role that has a budget it fills budget from the local spend ledger before every model call (one indexed SQLite query per role, no network), e.g. brain $0.42 of $15.00 this month (resets 01.10.2026 00:00); fast $0.00 of $1.00 today (resets …); a spent role reads …, spent, fast answers instead. Without any budget it provides nothing and the line reads unknown.

Tiers and approval

Every tool declares a tier; a tool without one cannot be registered.

Tier Meaning What happens
0 Tier.READ reads, no side effects runs freely
1 Tier.DIGITAL_WRITE changes digital state (files, notes, messages) runs and is logged
2 Tier.PHYSICAL acts on the physical world, or activates self-written code waits for an approval
from tau_core import Tier, tau_tool


@tau_tool(tier=Tier.PHYSICAL, effect="Switches the desk lamp on or off.")
def desk_lamp(state: str) -> str:
    """Switches the desk lamp on or off.

    Args:
        state: "on" or "off".
    """

effect is the sentence approval prompts show (default: the tool description); write descriptions and effects in English, the model reads them. A plain Strands @tool has no tier: ToolCatalog.add raises UntieredToolError, build_agent refuses it when passed in tools= and skips it with a warning when a plugin ships it. tau_tool records the tier and effect in the tool spec (tool_spec["annotations"]["tau"] = {"tier", "effect"}), so a @tau_tool method on a class keeps its tier when accessed through an instance (Strands builds a new tool object for bound methods); tier_of/effect_of read the annotation first and the older tau_tier/tau_effect attributes second.

A tool whose calls differ too much for one sentence may also carry tau_describe, a function that gets a copy of one call's arguments and returns that call in plain words; the approval request shows it instead of the effect (tau_core.tiers.effect_for). An exception or an empty answer falls back to the effect. tau-routines words a routine's whole definition this way.

Every call passes TierGate (build_agent wires it in through TierHook, registered at HookOrder.SDK_LAST so the tool_use the gate sees is the one Strands runs, whatever other hooks, interventions or plugins rewrite before it):

  • Unknown tool (not in the catalog, looked up live): refused with Tool '{tool}' is not registered; it was not run. and an error agent event.
  • Tier 0 and 1: allowed.
  • Tier 2: allowed when an exemption matches, otherwise the channel's Approver decides. Anything but Decision.APPROVED (a rejection, a timeout, an approver that raises or answers something invalid) refuses the call, and the model reads The user did not approve this action: {tool}. Do not perform it; tell the user briefly. as the tool result. Prompts are asked one at a time.
  • Every call of every tier emits a tool_call agent event (tool, tier, arguments, status, result_summary, approval, duration_ms, tool_use_id); every tier-2 question emits an approval event. The log gets tier 0 at DEBUG, tiers 1 and 2 at INFO (with arguments, result summary and approval; tau chat writes INFO and up to <data_dir>/logs/tau.log), unknown tools at WARNING.

A channel (TUI, Telegram, the tau app) implements Approver and passes it to build_agent:

from tau_core import ApprovalRequest, Decision, build_agent


class ButtonApprover:
    name = "telegram"
    timeout_seconds = 600  # optional: stop waiting after 10 minutes and record TIMEOUT

    async def decide(self, request: ApprovalRequest) -> Decision:
        # show request.tool, request.arguments and request.effect; await the user's button
        ...


agent = build_agent(approver=ButtonApprover(), channel="telegram")
  • Without an approver, AutoRejectApprover rejects every tier-2 call. No approver approves on its own; tests script decisions with tau_core.testing.ScriptedApprover([Decision.APPROVED]) and use UnansweredApprover for timeouts.
  • TerminalApprover (tau chat) prints the tool, its arguments as JSON and the effect in the UI language (format_approval_prompt(request, language)), and approves only on y, yes, e or evet whatever the language; anything else, Ctrl-D included, rejects. It has no timeout: a blocked input() cannot be cancelled.
  • Timeouts: TierGate(timeout_seconds=...), else the approver's own timeout_seconds, else none. When it runs out the question is cancelled and the decision is timeout; silence never approves.
  • Owner guard (see Guard): with a guard, a tier-1 or tier-2 call is refused without asking when the guard does not admit the turn (approval locked, note "tau was locked before the user answered."). After an approval the guard must admit once more (a lock while the dialog was open refuses the same way; recheck goes on), and an approved tier-2 call then needs the guard's step-up (anything but a literal True turns it into rejected, note "The guard could not confirm the user's identity."; the approval event gains step_up: passed | failed); after a passed step-up the guard must admit once more (a lock while the step-up ran refuses with approval locked). The guard can only refuse; exempt calls reach no check. Every TierGate(...) in the repo passes guard= explicitly (a repo rule).
  • Exemptions let registered routines (#037) skip per-call approval: build_agent(exemptions=[fn]) or agent.gate.add_exemption(fn) (returns an undo function). fn(tool_name, arguments) sees a copy of the arguments and must return True; it is asked only for tier-2 calls to known tools, one that raises does not exempt, and the call is still recorded with approval: "exempt".

TierGate.check() and record() do not need Strands, so the reflex engine and the MCP server can use the same policy.

Extra components. build_agent(hooks=[...], plugins=[...]) passes Strands HookProviders and Plugins through to the Agent. The gate keeps the last word: TierHook runs at HookOrder.SDK_LAST, no BeforeToolCallEvent callback may run after it (build_agent checks the registry and raises ConfigError for a hook registered above SDK_LAST, since the SDK accepts any order), and a tool a plugin registers behind the catalog's back is refused as unknown (fail closed); a component that wants its tools to run ships them through tau.tools with a tier. A hook added to the SDK agent after build cannot be refused; the gate then records a refused call that ran, or an approved call whose arguments changed, as error with an error event (where: tier_gate). When any registered tool is tier 2, build_agent uses the SDK's SequentialToolExecutor, so two approved physical calls in one model turn never run at the same time. Every span carries trace_attributes tau.session_id, tau.channel, tau.profile.

Turns and stop reasons

TauAgent.stream(prompt) runs one user turn and yields TurnEvents; ask(prompt) returns only the text. Calls are serialized with an asyncio.Lock (created per event loop), so two channels on one agent queue up instead of overlapping.

event meaning
TextDelta(text) a piece of the reply
ReasoningDelta(text) a piece of the model's extended thinking (SDK reasoningText)
ToolUseStarted(tool_use_id, tool, tier) the model asked for a tool (tier is None for an unknown tool)
ToolProgress(tool_use_id, tool, text) progress from a streaming tool (SDK tool_stream_event)
ToolFinished(tool_use_id, tool, status, summary, approval) from the tool_call event the gate recorded
Throttled(delay_seconds) the provider throttled the call; the SDK retries after the delay
Notice(text) something the user should read, in the UI language
TurnEnd(stop_reason) always the last event

to_turn_event(event) is the pure mapping from the SDK's dict events (data, current_tool_use, reasoningText, tool_stream_event, event_loop_throttled_delay, force_stop); the terminal prints Throttled as a dim line and ignores reasoning and progress for now. TurnEnd.stop_reason (tau_core.STOP_REASONS):

stop reason what happened notice / event
end_turn the model finished –
limit_turns [agent] max_turns model calls were used; the last call's tools still ran notice.turn_limit, turn_limit event
limit_total_tokens [agent] max_total_tokens was reached notice.total_tokens, turn_limit event
max_tokens the reply hit the provider's output limit; the partial reply is kept notice.max_tokens, notice event
cancelled TauAgent.cancel() stopped the turn at the SDK's next safe point; a cancel while no turn runs is ignored notice.cancelled, notice event
interrupt a hook kept interrupting after max_turns refusals; tau cleared the SDK's interrupt state notice.interrupt_refused, error event
error the model call failed (notice.model_error, or a ModelNotice's own sentence for a spent budget), the context window overflowed (notice.overflow) error event with where: model, context or interrupt
busy another invocation held the SDK's concurrency guard; nothing ran notice.busy, no event
locked the owner guard refused the turn before any history, prompt or model work; nothing was appended (see Guard) guard.refused.<reason>, guard event

tau never appends to the history by hand. When a turn leaves the history dangling - on a user message (a failed model call, the loop guard or token budget after a tool result, a cancel around tool execution, a session restored after a crash) or on an assistant message with a toolUse and no result (the hub died while an approval was pending, or an interrupt was given up) - the next stream() sends a two-message prompt to the SDK: an assistant message with the last notice in parentheses (or "The previous turn did not finish." for a crash-restored session), then the new user message. Both go through the SDK's own append path, so they get tracking ids and reach the session store like every other message; for a dangling toolUse the SDK first inserts its own "Tool was interrupted." result, so roles keep alternating. The pending notice survives a busy turn (nothing was appended) and a window trim (the check is by identity of the last message, not by length). A cancel during model streaming needs no repair: the SDK stores its own "Cancelled by user" placeholder as the reply. A hook that raises a Strands interrupt (event.interrupt(...)) gets {"tau": "rejected"} as the answer inside the same turn, once per interrupt up to max_turns refusals, with the turn's remaining Limits budget; the user sees notice.interrupt_refused. If that does not clear it, or a session comes back from the store in interrupt state, tau leaves the SDK's interrupt state itself and repairs on the next turn, so the agent is never left stuck (interrupt-based approvals are issue 068).

Sessions

Every conversation is a session: its messages, its agent events (tool calls, approval decisions, turn limits, errors) and its metadata (channel, profile, title = the first user message, cut to 60 characters). Callers only use SessionStore, a Strands SessionRepository plus list_sessions, append_event/read_events, write_meta/read_meta and event_sink. [sessions].backend in tau.toml picks the backend and open_session_store(config) builds it:

Backend Where Notes
memory the process tests and throwaway runs
file TAU_DATA_DIR/sessions/<session id>/ the default in the repo's tau.toml
spine D1 through the tau's spine Worker from tau-cloud, for cloud mode (ADR 0007); the hub holds no D1 secrets

The file layout, one directory per session (ids look like 20260929-153012-3f9a, UTC):

session.json                         Strands session record; updated_at orders /sessions
meta.json                            channel, profile, title
events.jsonl                         one agent event per line, append-only
agents/tau/agent.json                Strands agent state
agents/tau/messages/000000.json      one file per message, in order

JSON files are replaced atomically (temp file, fsync, rename), directories are 0700 and files 0600, symlinks and ids that are not plain names are refused. Nothing is ever deleted: the store has no delete method and never overwrites a message; retention is "keep everything".

In tau chat:

  • /sessions lists the last 10 earlier sessions that have messages (n. id date channel title (N messages)).
  • /resume resumes the most recent earlier session and says which one; /resume <n> resumes the n-th of the last /sessions listing (without a listing, of the one /sessions would print now); /resume <id> resumes that session. tau chat --resume [ID] does the same at start-up.
  • /new starts a fresh session; /session shows the current id, title, message count and backend; /clear erases the screen (an ANSI clear, then the ready banner again) and nothing else: the session and the agent go on, and --resume shows the history again.
  • /status prints the profile, the session (id, title, stored message count, role), the model (role → provider, whether the role's fallback is in use, the model id; never a key), the context mode and window with the in-memory message count, the tools (registered, how many need approval), the UI language, the installed tau-* versions, whether a hub answers on http://127.0.0.1:<control_port>/health (the tau doctor probe, one second, information only) and, when a role has a budget, the spend read from the ledger (Budget: brain $0.42 of $15.00 this month (resets 01.10.2026 00:00); fast $0.00 of $1.00 today (resets …)).
  • /model lists the roles of tau.toml with their providers and marks the current one; /model fast rebuilds the agent on the same session with that role: the new agent is built first (build_agent(role=..., session_id=<current>), which restores the stored history and takes over a pending repair notice), and only then is the previous one closed, so a failed build changes nothing. Every session records the role it last ran on in its metadata (build_agent writes it next to channel and profile), so /resume and tau chat --resume come back on that role, also for sessions opened by /new and /compact. tau chat --role ROLE pins the role for the run and wins over the stored one (and is recorded in turn); an unknown role, missing credentials or an unavailable provider leave the current agent unchanged and print why. TauAgent.role is the role an agent was built for.

Resuming rebuilds the agent on the stored session (build_agent(session_id=..., session_store=...)), so Strands restores agent.messages; the model sees the last [agent] window_size messages (or a summary plus the recent ones), the store keeps them all. A new backend implements the nine SessionRepository methods plus the five extras and arrives as a tau.sessions entry point or through register_session_backend(name, factory) (see Components); SESSION_BACKENDS is the built-in seed.

Compaction

/compact [focus] is how a long conversation goes on without its whole history, while every session stays on record:

  1. tau_core.compaction.summarize(agent, focus=...) runs a throwaway Strands Agent on the agent's own model over a deep copy of agent.messages (no tools, no hooks, no session manager, so the tier gate is not involved and the live agent is untouched), with an English system prompt that asks for a continuation summary: facts about the user, decisions, open tasks, tool results that still matter, and the language the user writes in; focus is appended as Focus on: .... An empty history, a failed model call or an empty reply raise CompactionError and nothing changes.
  2. seed_messages(summary, previous_session_id) is the two-message history the new session starts with: a user message Summary of the previous conversation (session <id>): plus the summary, and the assistant's Understood. I will continue from this summary..
  3. build_agent(seed_messages=..., session_id=None) opens the new session with that history. The seed goes in as the Strands Agent(messages=...); the SDK's RepositorySessionManager writes every initial message to the store when it creates the agent record, so the seed is part of the session and survives --resume (a test proves the file-store round trip).
  4. The new session's metadata gets title (the old title, or its first user line) and compacted_from = <old id>; the old session gets compacted_to = <new id> and keeps every message. /sessions lists both.

The channel then prints Compacted N messages into a summary. New session <id>; the previous session <old id> is kept. and the summary itself. N is what was summarized: the messages in memory, which the conversation window may have trimmed below the stored count.

Hub

tau hub run runs tau as a long-lived process in the hub role. Only one hub runs per machine (flock on <TAU_DATA_DIR>/hub/hub.lock; a second start exits 1 with tau hub is already running (pid N)…). While it runs it refreshes hub/state.json every [hub].heartbeat_seconds; SIGTERM or SIGINT stops it cleanly and writes stopped_at. A start that finds a state.json without stopped_at records a crash report (crash_reports.jsonl, last_crash.json); the reason comes from crash.json when the dying hub could write one, else unexpected exit (no clean shutdown).

tau hub run            # foreground; Ctrl-C or `tau hub stop` stops it
tau hub status         # running or not, heartbeat, last crash, launchd state (--json for scripts)
tau hub stop           # SIGTERM to the running hub; waits until it is gone
tau hub install        # macOS: launchd user agent, restarted after a crash or kill
tau hub uninstall
tau hub systemd-unit   # prints the systemd user unit (RPi5 hub)

The control API listens on 127.0.0.1:<control_port> only (default 7877, TAU_HUB_PORT overrides) and speaks JSON: GET /health → {"ok": true, "pid", "uptime_s"}, GET /status → role, profile, version, started_at, heartbeat_at, uptime_s, last_crash, approvals_pending, nodes, plus the keys hub services add (telegram, spend). Requests with a non-loopback Host header get 403.

The spend service adds GET /spend, read from the local ledger on every request (/status carries the same object under spend):

{"roles": {"brain": {"provider": "anthropic", "model_id": "claude-sonnet-5", "spent_usd": 0.42,
                     "cap_usd": 15.0, "period": "month", "since": "2026-09-01T00:00:00+03:00",
                     "reset_at": "2026-10-01T00:00:00+03:00", "exhausted": false,
                     "degrade_to": "fast", "unpriced_calls": 0, "metered": true}}}

Every role of tau.toml has an entry; one without a budget has cap_usd: null and is reported for the current month. tau spend --json prints the same objects without the roles wrapper.

Every other route is protected (ADR 0008): add_route(method, path, handler, open=False), and only /health and /status are open. At start, while it holds the hub lock, the hub writes a random token to <TAU_DATA_DIR>/hub/control.token (0600, hub/ chmod 0700) and deletes it on a clean stop if it is still its own. The token never travels: a protected request carries X-Tau-Nonce and X-Tau-Auth = hex(HMAC-SHA256(token, "req"|nonce|method|path)), and the answer carries X-Tau-Proof = hex(HMAC-SHA256(token, "resp"|nonce|method|path|status|body)) (tau_core.hub.control.auth_headers, request_auth, response_proof). The server answers 403 before dispatch when the token file is missing, empty or not the hub's, when the nonce or X-Tau-Auth is missing or wrong, when any Origin header is present, or when a POST is not application/json. A process that binds the port after a hard hub exit (a stale token file) sees nothing it can sign with, so clients refuse its answers. GET /spend is protected too. Other processes call protected routes with tau_core.hub.HubClient(config): loopback only, no proxy, no redirects, the token re-read on every request and once more after a 403, every request signed and every proof checked; it raises HubUnavailable (reason: no_hub, timeout, bad_proof, bad_response) or HubError(status, payload).

Extending the hub: a HubService has a name, async start(hub) and async stop(); services start in order after the crash check and stop in reverse. tau hub run starts the control API first and then every enabled tau.hub_services component (see Components): the entry point is a factory(config) -> HubService, the service is optional (one that fails to build or to start is logged and skipped, the hub runs on), and inside start(hub) it can call hub.add_status_source(fn) to add keys to /status and hub.find_service("control-api").add_route(method, path, handler) to add protected routes (handlers run in the server thread; reach the event loop with asyncio.run_coroutine_threadsafe(..., hub.loop)). Routes are additive only: add_route raises ValueError for a (method, path) that exists, so no service can replace GET /health, GET /status or another service's route (the service then fails to start and is skipped). find_service returns only services that have started: None for one that failed to start, has not started yet or after the hub stopped, so a service never wires into a half-initialised component. A service's own settings live in [component.<name>] and are read with config.component(name).

class VoiceService:  # in another distribution
    name = "voice"

    def __init__(self, config):
        self.settings = config.component("voice")

    async def start(self, hub):
        hub.add_status_source(lambda: {"voice": {"listening": True}})
        api = hub.find_service("control-api")
        api.add_route("POST", "/voice/say", self.say)

    async def stop(self): ...


def make_service(config):  # [project.entry-points."tau.hub_services"] voice = "pkg:make_service"
    return VoiceService(config)

tau-core itself ships two hub services: telegram (see Telegram) and spend (GET /spend and the spend key of /status, see above).

launchd is driven through the Launchctl protocol; tests pass FakeLaunchctl to the CLI as obj={"launchctl": fake}. The launchd plist and systemd unit are package templates; infra/services/ in the tau repo has rendered examples and the install notes.

Telegram

tau_core.channels.telegram is the Telegram channel, a tau.hub_services component (telegram = "tau_core.channels.telegram:make_service") that runs inside tau hub run. The hub polls the Bot API itself with a getUpdates long poll: outbound HTTPS only, no webhook, no relay, no inbound port (ADR 0006). Each text message from an allowed chat is one turn of that chat's session, built with build_agent(channel="telegram"), and the reply goes back with sendMessage. Messages and button presses from any other chat are dropped and logged, never answered.

It reads two names from .env (or the environment):

variable meaning
TELEGRAM_BOT_TOKEN the bot token from @BotFather (/newbot)
TELEGRAM_ALLOWED_CHAT_IDS the chat ids the bot answers, comma-separated (tau telegram pair sets yours; by hand: send the bot a message, then read message.chat.id from getUpdates)

and its own table in tau.toml:

[component.telegram]
poll_timeout_seconds = 50    # server-side long-poll timeout
backoff_max_seconds = 60     # cap of the exponential backoff after network errors
approval_timeout_seconds = 300   # an unanswered tier-2 approval counts as not approved

Without a token, or without an allowed chat and no pairing code waiting, the service starts idle, logs one line and reports "telegram": {"configured": false, "reason": ...} in /status; it never fails the hub. When it runs, /status shows state (starting, polling, retrying, failed, stopped), pairing, bot, allowed_chats, last_poll_at, last_error, retry_in_s, handled and dropped, never the token or a chat id; tau doctor prints the same as its telegram line. On start it calls deleteWebhook (Telegram refuses getUpdates while a webhook is set). Network errors back off exponentially (1 s, 2 s, 4 s, … up to backoff_max_seconds) and the hub stays up; a token the Bot API rejects (401) stops the poller until the next hub start.

Pairing (issue 172, tau_core.channels.telegram_pairing and tau telegram pair|status): the command writes the token to .env and a one-time code, stored as a salted PBKDF2 hash in TAU_DATA_DIR/telegram/pairing.json (10 minutes, one use, 10 wrong tries). With a hub running it calls the protected POST /telegram/reload: the service re-reads the two variables (.env first) and the code, and polls even with an empty allowlist while the code waits. /start <code> from a private chat makes that chat TELEGRAM_ALLOWED_CHAT_IDS (the .env line replaced, the rest kept) and the chat is told so; wrong, expired or group codes are dropped unanswered. Without a hub the command polls getUpdates itself from the same offset and stops right after the pairing update, so exactly one process polls the bot at a time.

Each allowed chat has a worker that handles its messages in order, so the poller keeps polling while a turn runs (and while it waits for an approval); messages that arrive meanwhile queue up. State lives under TAU_DATA_DIR/telegram/: offset is the next update_id to ask for, written once an update is routed; inflight.json holds the messages a worker has not finished, and a start runs them again, so a restart neither re-processes nor skips a message (one whose turn a stop interrupted runs again); chats.json maps each chat to its current session, so a chat keeps its conversation across restarts.

The slash commands /help, /tools, /sessions, /resume, /new, /session, /status, /model, /compact, /lang and /guard resolve through tau_core.commands with the terminal's wording (Turkish aliases accepted, never shown; /start and a bare / show the help). On start they are set as the bot's command menu (setMyCommands) for each allowed chat only, in the UI language; /lang sets it again. /clear, /quit and any command that edits the configuration answer that they are not available on Telegram; credentials are never set from a chat. Replies are sent as HTML converted from the model's Markdown and fall back to plain text when Telegram rejects the markup; longer than 4096 characters they are split. "typing…" shows while a turn runs.

Tier 2 (issue 017): TelegramApprover sends the tool, its arguments and its effect to the chat the call came from, with Approve / Reject inline buttons labelled in the UI language. The press comes back as a callback_query through the same poll loop; nothing sits between Telegram and the hub, so it is a first-party approval (ADR 0006). It counts only when the user who pressed is in TELEGRAM_ALLOWED_CHAT_IDS (in a private chat that is the chat id) and the request id, chat and message match a pending request; any other press is answered with answerCallbackQuery, logged and has no effect. No answer within approval_timeout_seconds is a timeout. The message is then edited to show the outcome and loses its buttons (also when the hub stops while it waits). Every decision is an approval agent event in the session, like a terminal approval, and /status shows approvals_pending. A pending approval does not survive a hub restart (068 moves it onto Strands interrupts): its message runs again after the restart and asks again. Nothing is ever auto-approved.

The owner guard does not guard Telegram turns: the chat-id allowlist identifies the owner, so _build passes guard=None and a turn runs whatever the guard says (ADR 0008). /guard shows the guard's state, /guard lock locks it with trigger telegram (the owner's remote kill switch; the answer says whether the lock took, read back from the guard) and /guard unlock answers that unlocking happens only at the Mac. The service finds the in-hub guard lazily through hub.find_service("guard") plus an isinstance(Guard) check; without a guard service /guard says the guard is off (or, when a guard is enrolled, that this hub runs no guard service).

Tests use tau_core.testing.FakeBotApi, an offline Bot API: push_message(chat_id, text) queues an update, sent / texts() show what the bot sent, buttons(chat_id) and press(chat_id, index) work the inline buttons, edits and answers record message edits and callback answers, fail(method, *errors) scripts network losses and refusals.

Guard

The owner guard (ADR 0008) decides who may use tau's guarded surfaces right now. tau-core ships the seam and imports no camera library; the camera, the state machine and the /guard/* routes come from the tau-guard hub service, and the lock screens from the channels. An install without a guard is unchanged: resolve_guard(config) returns None.

  • Active when <TAU_DATA_DIR>/guard/enrolled exists (JSON {"v", "enrollment", "model", "created"}, written by tau-guard) or [component.guard] enabled = true. While the marker exists, enabled = false is ignored. A non-bool enabled, an unknown key in [component.guard] or any platform but macOS gives a LockedGuard that refuses everything; otherwise resolve_guard returns a HubGuard. A found guard is memoized per (home, data_dir); None is not, so a process started before tau guard setup is guarded from its next build on.
  • Admission. build_agent(guard=DISCOVER, client_id=None) resolves the guard unless the caller passes one; TauAgent keeps channel, guard and client_id (random per process). stream() asks guard.admit(GuardTurn(channel, session_id, client_id)) inside its lock before anything else; a refused turn yields Notice(t("guard.refused.<reason>")) and TurnEnd("locked"), emits guard {"action": "refused", "channel", "reason"} and changes no history. ask() raises GuardLocked(reason), summarize() raises CompactionLocked (a CompactionError and a GuardLocked) before any model call. A raising guard, a timeout or an unknown reason refuses (admit_turn, a hard bound: a late answer is dropped).
  • HubGuard(config) talks to the hub through HubClient. Only a proven unlocked answer (allowed: true from POST /guard/admit) whose enrollment equals the local marker's admits; off, 404, 403, 5xx, a timeout, bad JSON, a missing proof or another enrollment mean unavailable. admit has a 3 s bound, all HTTP runs in daemon threads, and unlock/step_up post /guard/verify, long-poll GET /guard/status?since=&attempt= for their own attempt (calling on_progress on every step) and post /guard/cancel when cancelled. Every verification carries the client's weakening-sensitive knobs (step_up, unlock_liveness, unlock_fallback) with the values it uses: a knob its tau.toml leaves out goes as its default (face, challenge, touch_id), so the hub's stricter-of-two merge never reads silence as agreement with a weaker hub value. lock logs an error when the hub did not confirm it. The route contract is in the tau_core.guard module docstring.
  • Touch ID fallback (issue 114). While the hub offers it, a locked GuardStatus carries fallback (touch_id or touch_id_or_password, the stricter of the hub's and this client's [component.guard] unlock_fallback; a client's none hides it): after FALLBACK_AFTER (2) failed face unlocks in one attempt window (FACE_FAILURES) or any verdict that says the face check cannot work (FALLBACK_NOW: camera_*, unavailable, model_changed). A channel then offers it (guard.fallback.<mode>, with its own key hint as {how}) and calls unlock(method="touch_id"); HubGuard sends fallback: "touch_id" with /guard/verify, and the hub runs LocalAuthentication itself, so the sheet appears on the Mac's screen and no route accepts a Touch ID result. A passing fallback unlocks with method = "touch_id". FakeGuard(..., fallback="touch_id") plays the offer (offered(), unlock_methods).
  • Identified in-hub channels opt out in code: Telegram and network turns pass guard=None, and a repo rule refuses guard=None anywhere else. No flag, variable or config key selects a channel.
  • Reports. /status adds guard_status_line(status, language) when a guard is active; tau doctor adds guard_doctor_check(config, hub_status).
  • Channels (issue 113). tau chat resolves the guard once (ReplState.guard, resolved again before every build and prompt while it is None) and passes it to every build. While the guard is not unlocked it shows a lock prompt (locked> ): entering it writes \x1b[2J\x1b[3J\x1b[H (screen and scrollback; a terminal app that ignores \x1b[3J, or one with its own scrollback outside the terminal such as tmux's copy mode, keeps what was there) and takes readline's line history out until the unlock, so the Up arrow at locked> recalls nothing the owner typed. Enter on an empty line (or /guard unlock) unlocks and prints every challenge step, Ctrl-C during an attempt cancels it and the REPL goes on, and only ALLOWED_WHILE_LOCKED (/help, /lang, /guard, /quit) run; /help lists only those. While the status offers the Touch ID fallback (GuardStatus.fallback, issue 114) the prompt prints "The face check is not working? Use Touch ID instead: type t and press Enter." when the offer appears and again after each failed attempt; t + Enter (TOUCH_ID_KEY, any case) runs unlock(method="touch_id"), prints "Confirm in the macOS prompt on the Mac's screen." and the hub's touch_id step, and Ctrl-C cancels it. A pass unlocks with "Unlocked (Touch ID). …"; without an offer t is a line that is not sent. The state is read again before every prompt and before a command that is not allowed while locked. After a refused turn the guard's state decides, as in the TUI: anything but unlocked returns to the lock prompt. A recheck refusal runs the passive check and resends the prompt; a failed or cancelled check keeps you> (the guard still reports unlocked) and says the prompt was not sent. Step-up steps print from the agent's guard events. A guard found after the agent was built (a window opened before tau guard setup) rebuilds the agent on the same session and role at the unlock and before its next turn or /compact; when that rebuild fails nothing is sent. Limit: the REPL reads the guard only between lines. While it waits at you> or at an approval prompt, or while a turn streams, it cannot see a lock: the conversation stays on screen and in the scrollback until the next line, and a running turn goes on (the guard still refuses its tier-1 and tier-2 calls and every later turn, but the reply keeps printing). The TUI watches the guard and locks at once; its lock screen is described in the tau-tui README; Telegram's /guard under Telegram.

Public API: Guard (protocol: status, watch, admit, unlock, cancel, lock, step_up; every method fails closed), GuardState, GuardStatus, GuardVerdict, GuardProgress, GuardTurn, Admission, GuardLocked, LockedGuard, HubGuard, resolve_guard, VERDICT_REASONS, ADMISSION_REASONS, LOCK_TRIGGERS, PROGRESS_STEPS, ALLOWED_WHILE_LOCKED from tau_core; DISCOVER, ENROLLED_MARKER, GUARD_CONFIG_KEYS, LOCKED_NOTE, STEP_UP_NOTE, COUNTED_FAILURES, admit_turn, guard_status_line, progress_text, verdict_text, method_text (the channels' shared texts), guard_doctor_check, read_enrollment and reset_guards from tau_core.guard; HubClient, HubUnavailable, HubError from tau_core.hub. The guard's messages (guard.*, status.guard, doctor.guard.*) register themselves with register_messages. Tests use tau_core.testing.FakeGuard(state, verdicts=..., step_ups=..., progress=..., admissions=...), which replays scripted answers and never approves anything.

Routines

Routines are rules like "lamps on at 07:00 on weekdays": a trigger plus one tool call with fixed arguments, and no LLM (ADR 0015 §4). The hub runs them without a model, without the internet and without the owner at the Mac. This is the reflex engine of Principle 1. It is a built-in component (tau_core.routines), on by default:

Group Name What it adds
tau.hub_services routines the engine that fires routines inside tau hub run
tau.commands routine tau routine add|edit|list|pause|resume|remove|log
tau.tools routines add_routine (tier 2), list_routines (tier 0), remove_routine (tier 1)

Triggers

Trigger Fires
daily 07:00 every day at 07:00
daily 07:00 weekdays Monday to Friday. weekends, mon,wed,fri and mon-fri work too
once 2026-10-05 07:00 once, at that local date and time
every 15 minutes every 15 minutes after registration, for 5 to 10080 minutes
  • Time zone. Times are wall-clock times in the hub's local zone: TZ, otherwise the zone /etc/localtime names. [component.routines] timezone overrides it.
  • Missed slots. Slots missed while the hub was off run once if they are at most grace_minutes old (default 10). Older ones are logged as missed.

One approval at registration

Every way of registering is a tier-2 approval. That covers tau routine add (the terminal's approver), the add_routine tool (the approver of the chat) and the web app's form (issue 184). The approval shows the whole definition in plain words, through the tool's tau_describe (see effect_for in tau_core.tiers), and the owner guard applies as on any tier-2 call.

The approval is pinned to the definition. The routine is stored with approved_hash, the SHA-256 of [id, tool, arguments]. A change of the trigger, the tool or the arguments asks again. Renaming, pausing, resuming and removing ask nothing. Without an interactive terminal tau routine add is refused.

Firing

The engine calls the tool through a TierGate built for that one call:

  • guard=None. The engine is an in-hub caller identified by its approved registration, so it is in the repo rule's allowlist (tau_core/routines/engine.py).
  • A RefusingApprover. Nobody is asked at firing time.
  • An exemption. It covers only the routine's own tool, with arguments that hash to approved_hash.

What happens around a firing:

  • Before the tool runs, the slot is recorded, so a crash never repeats a physical action.
  • A failure is logged, and three failures in a row pause the routine.
  • Every outcome is a routine agent event on RoutinesService.events.
  • Never does the engine build a model or open a connection.
tau routine add "daily 07:00 weekdays" lamp_set "name=desk" "on=true" --name "Lamps on"
tau routine list
tau routine edit "Lamps on" "name=living-room"     # asks again
tau routine pause "Lamps on"
tau routine log "Lamps on" -n 50
tau routine remove "Lamps on" --yes

State lives in TAU_DATA_DIR/routines/:

  • routines.json is written atomically under a lock, with mode 0600.
  • log.jsonl is rotated at 512 KiB, and three old files are kept.
[component.routines]
timezone = "Europe/Istanbul"   # an IANA zone; default: the hub's local zone
grace_minutes = 10             # 0 to 1440

Simulated devices

The first device is a simulated lamp, a stand-in for hardware that is not on hand (ADR 0015 §5). tau, the web app and routines drive it with the same tools and tiers a real lamp will have. Nothing talks to hardware, and every answer and card says the lamp is simulated.

It is a built-in component (tau_core.sim) and opt-in: its tools and hub service stay off until you enable it.

[component.sim]
enabled = true
lamps = ["desk", "living-room"]   # default ["desk"]; lower-case slugs

The component has three parts:

  • Tools. lamp_list (tier 0) lists the lamps. lamp_set(name, on) (tier 2) switches one, and every call asks for approval, as a real lamp will.
  • Hub service. The sim service implements DeviceSource (tau_core.devices):
    • devices() returns sim:lamp:<name> with {"on": …};
    • subscribe reports every change, including one made from another process.
  • Command. tau sim lamps shows the state.

The state is in TAU_DATA_DIR/sim/lamps.json, written atomically under a lock. A real lamp will arrive later as an MCP node with the same tool names, and routines that call lamp_set keep working.

Opt-in components

A tool or a hub service factory can carry tau_opt_in = "<name>". tau then loads it only with [component.<name>] enabled = true (Config.opted_out). Until then, tau components lists it as off, with the reason. This is how a built-in component that most installs do not want stays out of the agent's tool list.

Components

Every pluggable part of tau is a component: it reaches tau through a built-in seed (PROVIDERS, SESSION_BACKENDS), an entry point in a tau.* group declared by any installed distribution, or an in-process registration (register_provider, register_session_backend), and tau.toml decides which discovered components are on (ADR 0005). tau_core.registry loads them all with one rule: an entry point that fails to load, or loads the wrong kind of object, is logged with its distribution name and skipped; it never breaks the CLI.

group entry point value picked by
tau.tools an AgentTool with a tier, a list of them, or a zero-argument factory build_agent (all, minus tools.disabled)
tau.commands a click.Command, mounted as tau <name> the tau command
tau.providers factory(RoleConfig, env) -> Model; optional required_env = [["VAR", ...], ...] on the factory (credential alternatives) a role's provider in tau.toml
tau.sessions factory(Config) -> SessionStore [sessions].backend
tau.context factory(Config) -> ContextSource (a callable returning {"nodes": ..., "budget": ...}) build_agent when no context_sources are passed
tau.hub_services factory(Config) -> HubService tau hub run, after the control API

tau.channels stays reserved for hub-hosted channels. A plugin package declares what it ships:

[project.entry-points."tau.tools"]
mine = "my_package:TOOLS"              # tools carry a tier (tau_tool); untiered ones are skipped

[project.entry-points."tau.commands"]
mine = "my_package.cli:command"        # `tau mine`

[project.entry-points."tau.providers"]
example = "my_package.models:make_model"  # make_model(role_cfg, env) -> Model; make_model.required_env = []

[project.entry-points."tau.sessions"]
cloud = "my_package.sessions:make_store"  # make_store(config) -> SessionStore

[project.entry-points."tau.context"]
nodes = "my_package.mesh:make_source"     # make_source(config) -> callable returning {"nodes": [...]}

[project.entry-points."tau.hub_services"]
voice = "my_package.voice:make_service"   # make_service(config) -> HubService

A seed name wins over an entry point with the same name (an installed package cannot replace bedrock silently); register_provider / register_session_backend replace anything, since they are explicit code. Config.load validates provider and backend names without loading a single entry point, so tau version never imports a provider SDK.

Switch discovered components on or off in tau.toml without uninstalling them:

[components]
context = []                 # enabled tau.context sources; empty = every discovered one
[components.tools]
disabled = ["demo_physical_action", "dist:tau-example-tool"]   # tool names or dist:<distribution>
[components.hub]
services = []                # enabled tau.hub_services; empty = every discovered one

[component.telegram]         # a component's own settings: config.component("telegram")
poll_timeout_seconds = 50

tools.disabled can only remove tools; it never adds one, gives one a tier or changes a tier, and an explicit build_agent(tools=[...]) bypasses it. Any other top-level table in tau.toml is logged once and ignored, never a failure. Nothing in [components] can replace the tier gate, the approver or the catalog: a tool that reaches the Strands agent behind the catalog's back (through a Plugin, for instance) is refused as unknown.

Three commands show, check and set up an installation (English, --json where useful):

tau components          # every component in every group: distribution, version, enabled/disabled, load error
tau doctor              # config, per-role provider + credentials (no client built), the budgets (one line
                        # with every role's spend), session store, persona files, .env names vs .env.example,
                        # component load errors, [components] names that no installed component provides,
                        # control API reachable, Telegram poller state; exit 1 when a check fails ("warn"
                        # for a planned provider, a budgeted role whose model has no price, and the
                        # unknown names)
tau init                # tau.toml from the shipped template (provider: anthropic > openai > gemini > bedrock
                        # > fake, by the credentials present in the environment or <home>/.env), persona/persona.md,
                        # user.example.md, user.md (from the example unless it exists) and .env.example;
                        # asks the UI language unless --language or --yes
tau init --home ~/.tau --provider anthropic --language tr --profile home --yes   # non-interactive

tau setup (tau_core.setup, ADR 0014) sits on top of them: a checklist of the features in templates/features.toml, one uv tool install --force tau-core==<this version> --with … for the missing packages and a re-execution in the new environment, then tau init, "about you", each feature's steps (tau subcommands run as subprocesses; tau-core never imports tau-guard, tau-net or tau-sub) and tau doctor. tau setup status|install|run --json is the line protocol the Mac app drives; docs/reference/setup.md in the repository specifies it.

Remote access (tau_core.remote, issue 173) is a feature of check steps: tau remote tailscale, ssh and phone report a state and what only the owner can do, and tau remote status says whether it is set up. The one command that needs sudo, setup-all.sh, is shown and never run. It comes from infra/remote in a checkout; tau-core ships a copy of that directory as package data (tau_core/remote/kit/, a test keeps it identical) and writes it to $TAU_DATA_DIR/remote when an installed tau needs to show the command.

tau init rewrites the provider and model_id of brain and fast to the provider it picks, with the ids of templates/models.toml (anthropic renders the template as shipped). The budget tables stay, since tau enforces them for every provider, and local stays on Ollama (qwen3.5:9b, failover = "fast").

tau init never overwrites an existing tau.toml or persona.md without --force and never overwrites user.md or .env.example (the latter is the list of names tau doctor checks .env against; add your own names there); --profile appends TAU_PROFILE to <home>/.env when it is not set there. The shipped templates equal the repository's tau.toml, persona/persona.md and persona/user.example.md (a test keeps them identical); the .env.example template is the repository's file without its L0 remote-access section. A tau.providers entry point that is declared but does not load is reported as Model provider 'x' from <distribution> failed to load: ... by tau doctor, tau chat and tau components alike; only a name no distribution declares is an Unknown model provider.

Logging

configure_logging(logs_dir, verbose=False) sends WARNINGs (or INFO with verbose) to stderr as one-line messages and, when logs_dir is given, INFO and up with tracebacks to <logs_dir>/tau.log (tau chat and tau hub run use config.logs_dir); it returns the log file path. reset_logging() removes those handlers again. Both are part of the public API so a channel in another package (the TUI, Telegram) writes to the same file; a full-screen UI that owns the terminal passes console=False to keep stderr silent while the file log still receives every record.

Development

uv sync --all-packages --group dev
uv run pytest -q
uv run ruff check . && uv run ruff format .
uv build --package tau-core

License: MIT.

Metadata

Release files for tau-core 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tau-core 0.5.0
File Size Uploaded
tau_core-0.5.0.tar.gz 542.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tau-core 0.5.0
File Interpreter ABI Platform
tau_core-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 924.0 kB

Release files / tau_core-0.5.0.tar.gz

Download URL tau_core-0.5.0.tar.gz
Size 542.3 kB
Tags Source
SHA-256 checksum
How to use checksums
538ccf76ddf0795d7a374c0899c96ce091890a15659b0af6e92faa787dfd351b
BLAKE2b-256 checksum
How to use checksums
6caa8f76a82dd9ed8d758dd7cfdd187cd79102b3475d61743782df86cfbe8a35
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release files / tau_core-0.5.0-py3-none-any.whl

Download URL tau_core-0.5.0-py3-none-any.whl
Size 381.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5ce78265e2fd5b1f85a32c7290e79dd373d04d85b66d09bf7d1cfa43d3f3358a
BLAKE2b-256 checksum
How to use checksums
7d0abaa0b922d288ead14cdde9681f9220794ff2289b4a39c5e6dc14a8049f53
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page