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, version, plus plugin commands (tui, ...)
tau init # create ~/.tau/tau.toml and the persona files from the templates
tau doctor # check config, credentials, sessions, persona, .env, the hub
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:
bedrock, anthropic (with an optional per-role fallback) and fake; a tau.providers
component adds more (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 = "bedrock"
model_id = "..."
max_tokens = 4096
cache = { strategy = "auto", ttl = "1h" } # optional: Strands CacheConfig (bedrock, anthropic)
[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=...))
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, or only dots for a short
value) or a set / not set flag. 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); the fake provider ignores 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 |
/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 |
/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 today: unknown
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 $ / 1 $"}, # today's spend (L2)
],
)
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.
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, TauBar) 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. - 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), 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 |
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 |
cloud |
D1 through the tau-cloud Worker API | issue 018; 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 and whether a hub answers onhttp://127.0.0.1:<control_port>/health(thetau doctorprobe, one second, information only)./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). Requests with a
non-loopback Host header get 403.
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 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 one hub service, telegram (see Telegram).
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 (yours: 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
Without a token, or without an allowed chat, 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), 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.
State lives under TAU_DATA_DIR/telegram/: offset is the next update_id to ask for, written
after each update is handled, so a restart neither re-processes nor skips an update (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 and /lang 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: until inline-button approvals land (issue 017), RefusingApprover rejects every tier-2
call made from Telegram. The refusal the model reads carries the approver's refusal_note
(the gate appends it), which points the user at a trusted channel (the TUI or tau chat).
Nothing is ever auto-approved.
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, fail(method, *errors) scripts
network losses and refusals.
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"]
ollama = "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), 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 such as ollama and for the unknown names)
tau init # tau.toml from the shipped template (provider: bedrock > anthropic > 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 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.3.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.3.0.tar.gz | 228.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tau_core-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 384.5 kB
Release files / tau_core-0.3.0.tar.gz
| Download URL | tau_core-0.3.0.tar.gz |
|---|---|
| Size | 228.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cf2f6be5bdf9acee18de84c3a3c699503a2b63b4a227b1194c37c318c0e57ca9
|
|
BLAKE2b-256 checksum How to use checksums |
2cc8919a07a85ee849cedc2a8280191b84f3411298d93c633279846849b97a0e
|
| 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 Sep 29, 2026.
Transparency logRelease files / tau_core-0.3.0-py3-none-any.whl
| Download URL | tau_core-0.3.0-py3-none-any.whl |
|---|---|
| Size | 155.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ed95829455fbd99f8a5472641102de0fd9fcce4eef2c5140ecad6585a98c9425
|
|
BLAKE2b-256 checksum How to use checksums |
6261ab4125c881f5ba29d02dcb3a0942b81b93ba6ae519144950a7354ad099fa
|
| 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 Sep 29, 2026.
Transparency log