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
RoleModelreads the role's spend in the current period from the ledger. At the cap it answers withdegrade_to(the turn goes on and yieldsNotice(notice.budget_degraded):The brain budget is spent until 01.10.2026 00:00; fast answered instead.) or raisesBudgetExhausted, which ends the turn with stop reasonerrorandnotice.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_alsowhen the stand-in is spent too).BudgetExhaustedis aModelNotice: one sentence in the UI language, a warning in the log, never a trace. - After a call the answer's Strands
usageis priced withtau_core.pricesand recorded intau_core.spend.Ledger, a SQLite file atTAU_DATA_DIR/spend.sqlite3(mode0600, WAL) shared by every process on the hub: time, role, provider, model id, token counts and dollars, never text. Prices come fromtemplates/prices.toml($ per million tokens:input,output,cache_read,cache_write, keyed by model id) and[models.prices."<model_id>"]intau.toml(inputandoutputrequired, the cache prices default toinput);ollamaandfakeare free. Anthropic and Bedrock count cached tokens apart from the input tokens, OpenAI and Gemini include them;costhandles both. A call on an unpriced model is recorded without a cost (it counts as $0) andtau doctorwarns about a budgeted role on one. - Failover: when the provider cannot be reached before anything streamed (connection refused
or timed out, a 5xx;
is_unavailablewalks the exception chain, throttling excluded), thefailoverrole answers, the turn yieldsNotice(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.mdis 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.mdholds the facts about the user and how to address them. It is gitignored; start from the made-up template withcp 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.mdmakesbuild_agentraiseConfigError. 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 readsnone), 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 anerroragent event. - Tier 0 and 1: allowed.
- Tier 2: allowed when an exemption matches, otherwise the channel's
Approverdecides. Anything butDecision.APPROVED(a rejection, a timeout, an approver that raises or answers something invalid) refuses the call, and the model readsThe 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_callagent event (tool,tier,arguments,status,result_summary,approval,duration_ms,tool_use_id); every tier-2 question emits anapprovalevent. The log gets tier 0 atDEBUG, tiers 1 and 2 atINFO(with arguments, result summary and approval;tau chatwritesINFOand up to<data_dir>/logs/tau.log), unknown tools atWARNING.
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,
AutoRejectApproverrejects every tier-2 call. No approver approves on its own; tests script decisions withtau_core.testing.ScriptedApprover([Decision.APPROVED])and useUnansweredApproverfor 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 ony,yes,eorevetwhatever the language; anything else, Ctrl-D included, rejects. It has no timeout: a blockedinput()cannot be cancelled.- Timeouts:
TierGate(timeout_seconds=...), else the approver's owntimeout_seconds, else none. When it runs out the question is cancelled and the decision istimeout; 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;recheckgoes on), and an approved tier-2 call then needs the guard's step-up (anything but a literalTrueturns it intorejected, note "The guard could not confirm the user's identity."; theapprovalevent gainsstep_up: passed | failed); after a passed step-up the guard must admit once more (a lock while the step-up ran refuses with approvallocked). The guard can only refuse; exempt calls reach no check. EveryTierGate(...)in the repo passesguard=explicitly (a repo rule). - Exemptions let registered routines (#037) skip per-call approval:
build_agent(exemptions=[fn])oragent.gate.add_exemption(fn)(returns an undo function).fn(tool_name, arguments)sees a copy of the arguments and must returnTrue; it is asked only for tier-2 calls to known tools, one that raises does not exempt, and the call is still recorded withapproval: "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:
/sessionslists the last 10 earlier sessions that have messages (n. id date channel title (N messages))./resumeresumes the most recent earlier session and says which one;/resume <n>resumes the n-th of the last/sessionslisting (without a listing, of the one/sessionswould print now);/resume <id>resumes that session.tau chat --resume [ID]does the same at start-up./newstarts a fresh session;/sessionshows the current id, title, message count and backend;/clearerases the screen (an ANSI clear, then the ready banner again) and nothing else: the session and the agent go on, and--resumeshows the history again./statusprints the profile, the session (id, title, stored message count, role), the model (role → provider, whether the role'sfallbackis 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 installedtau-*versions, whether a hub answers onhttp://127.0.0.1:<control_port>/health(thetau doctorprobe, 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 …))./modellists the roles oftau.tomlwith their providers and marks the current one;/model fastrebuilds 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 theroleit last ran on in its metadata (build_agentwrites it next tochannelandprofile), so/resumeandtau chat --resumecome back on that role, also for sessions opened by/newand/compact.tau chat --role ROLEpins 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.roleis 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:
tau_core.compaction.summarize(agent, focus=...)runs a throwaway StrandsAgenton the agent's own model over a deep copy ofagent.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;focusis appended asFocus on: .... An empty history, a failed model call or an empty reply raiseCompactionErrorand nothing changes.seed_messages(summary, previous_session_id)is the two-message history the new session starts with: a user messageSummary of the previous conversation (session <id>):plus the summary, and the assistant'sUnderstood. I will continue from this summary..build_agent(seed_messages=..., session_id=None)opens the new session with that history. The seed goes in as the StrandsAgent(messages=...); the SDK'sRepositorySessionManagerwrites 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).- The new session's metadata gets
title(the old title, or its first user line) andcompacted_from = <old id>; the old session getscompacted_to = <new id>and keeps every message./sessionslists 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/enrolledexists (JSON{"v", "enrollment", "model", "created"}, written by tau-guard) or[component.guard] enabled = true. While the marker exists,enabled = falseis ignored. A non-boolenabled, an unknown key in[component.guard]or any platform but macOS gives aLockedGuardthat refuses everything; otherwiseresolve_guardreturns aHubGuard. A found guard is memoized per(home, data_dir);Noneis not, so a process started beforetau guard setupis guarded from its next build on. - Admission.
build_agent(guard=DISCOVER, client_id=None)resolves the guard unless the caller passes one;TauAgentkeepschannel,guardandclient_id(random per process).stream()asksguard.admit(GuardTurn(channel, session_id, client_id))inside its lock before anything else; a refused turn yieldsNotice(t("guard.refused.<reason>"))andTurnEnd("locked"), emitsguard {"action": "refused", "channel", "reason"}and changes no history.ask()raisesGuardLocked(reason),summarize()raisesCompactionLocked(aCompactionErrorand aGuardLocked) 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 throughHubClient. Only a provenunlockedanswer (allowed: truefromPOST /guard/admit) whoseenrollmentequals the local marker's admits;off, 404, 403, 5xx, a timeout, bad JSON, a missing proof or another enrollment meanunavailable.admithas a 3 s bound, all HTTP runs in daemon threads, andunlock/step_uppost/guard/verify, long-pollGET /guard/status?since=&attempt=for their own attempt (callingon_progresson every step) and post/guard/cancelwhen cancelled. Every verification carries the client's weakening-sensitive knobs (step_up,unlock_liveness,unlock_fallback) with the values it uses: a knob itstau.tomlleaves 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.locklogs an error when the hub did not confirm it. The route contract is in thetau_core.guardmodule docstring.- Touch ID fallback (issue 114). While the hub offers it, a locked
GuardStatuscarriesfallback(touch_idortouch_id_or_password, the stricter of the hub's and this client's[component.guard] unlock_fallback; a client'snonehides it): afterFALLBACK_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 callsunlock(method="touch_id");HubGuardsendsfallback: "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 withmethod = "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 refusesguard=Noneanywhere else. No flag, variable or config key selects a channel. - Reports.
/statusaddsguard_status_line(status, language)when a guard is active;tau doctoraddsguard_doctor_check(config, hub_status). - Channels (issue 113).
tau chatresolves the guard once (ReplState.guard, resolved again before every build and prompt while it isNone) and passes it to every build. While the guard is notunlockedit 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 atlocked>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 onlyALLOWED_WHILE_LOCKED(/help,/lang,/guard,/quit) run;/helplists 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) runsunlock(method="touch_id"), prints "Confirm in the macOS prompt on the Mac's screen." and the hub'stouch_idstep, and Ctrl-C cancels it. A pass unlocks with "Unlocked (Touch ID). …"; without an offertis 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 butunlockedreturns to the lock prompt. Arecheckrefusal runs the passive check and resends the prompt; a failed or cancelled check keepsyou>(the guard still reports unlocked) and says the prompt was not sent. Step-up steps print from the agent'sguardevents. A guard found after the agent was built (a window opened beforetau 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 atyou>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/guardunder 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/localtimenames.[component.routines] timezoneoverrides it. - Missed slots. Slots missed while the hub was off run once if they are at most
grace_minutesold (default 10). Older ones are logged asmissed.
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
routineagent event onRoutinesService.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.jsonis written atomically under a lock, with mode0600.log.jsonlis 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
simservice implementsDeviceSource(tau_core.devices):devices()returnssim:lamp:<name>with{"on": …};subscribereports every change, including one made from another process.
- Command.
tau sim lampsshows 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)
| File | Size | Uploaded | |
|---|---|---|---|
| tau_core-0.5.0.tar.gz | 542.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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