What is sent
After a turn ends, at most once every five minutes per project:
- Project name — the
namefrompackage.json,pyproject.toml,go.mod,Cargo.tomlorcomposer.json; otherwise the folder name - Language — from the same file
- Git remote URL and branch — if the folder is a repository
- Session count, total tokens, total cost — cumulative
- CLI version
What is never sent by project tracking: session transcripts, file contents, tool output, your API keys. Session sync is a separate opt-in below.
The figures are not recomputed for the wire — they come from the same
collectStats() that titah stats reads. One ruler, not two: a second
calculation drifts, and nobody can tell which number is right.
The path is hashed
A project is identified by sha256 of its absolute normalised path, using the
same projectKey() that groups sessions locally. /home/ada/secret would
otherwise hand the folder name to anyone with database access, and folder names
are often the most sensitive string in the record.
The server only ever sees the hash and the label you set. One consequence: the same folder opened from another machine is the same row.
Turning it off
{ "tracking": { "enabled": true, "exclude": [], "git": true } }
Four ways, widest first:
| Scope | |
|---|---|
titah logout | Nothing is sent without an account, whatever the config says |
"enabled": false in the global config | Every project |
"enabled": false in a project’s ./titah.json | That project — and it travels with the repo |
"exclude": ["~/clients/*"] | Named paths, for folders you will not put a file in |
The third is free: Titah already merges the global config with each
project’s ./titah.json, so per-project opt-out needed no new machinery. It
also moves with the repository, which a central list of globs does not.
The fourth exists for the case the third does not cover — a sensitive folder you
would rather not put a titah.json into at all.
exclude uses the same matcher as permission.allowlist — one glob dialect
in the whole config, not two. There * already crosses /, so one star covers
a whole subtree. ~ is expanded first.
git: false
Drops the remote URL and the branch; everything else still goes. A remote URL frequently names a client, and that leaks considerably more than “there is a folder called api”.
On by default, once you sign in
This is a deliberate exception to Titah’s rule that every new axis ships off. Signing in is itself the opt-in. Demanding a second step leaves the dashboard empty for someone who already took the only step that looks like consent.
The guarantee that matters is kept: with no account nothing is sent, and there
is no way to switch it on without one. "enabled": true on a machine that is
not signed in does nothing.
It never says anything
Succeeding or failing, the heartbeat writes nothing to stdout or stderr.
That is not tidiness. titah run --output-format json promises that nothing
human touches stdout; one “heartbeat sent” line breaks the caller’s
JSON.parse, and the caller has no way to guess the line is at fault rather
than the data. There is no retry either — the next heartbeat carries the same
cumulative figures, so a lost one loses nothing.
What there is instead:
cat ~/.config/titah/tracking.log # one line per attempt
titah doctor # the Tracking section
Tracking
on — this folder is reported to your dashboard
last sent: 2026-08-20T02:14:07.000Z
log: /Users/ada/.config/titah/tracking.log
doctor names which of the four switches is in effect, because “nothing was
sent” without a reason is a state nobody can fix.
Why the debounce lives in SQLite
titah run is one process per turn. An in-memory window would never fire there
— and that is exactly where a script invokes it a hundred times. The last-sent
timestamp is a row, so the next process sees it.
Viewing it
/dashboard/projects/ lists every folder, with last active time, session count,
tokens, cost, language and git remote. /dashboard/analytics/ aggregates across
all of them, with CSV and JSON export.
!!! note “Cost needs a declared price”
Titah ships no price table, so cost reads 0 for models with no price in
the config. The tokens are still counted. See
Cost & limits.
Session sync
Off by default, and it takes two switches:
{ "tracking": { "sync": true } }
…plus the per-project toggle on /dashboard/projects/<id>/. Both, because a
dashboard toggle is remote policy: anyone who reaches your account can turn
it on, and they cannot edit a file on your machine. It is the same reason
external_directory is evaluated when the config loads and never at runtime — a
structural boundary must not become a runtime question.
Defaulting off is deliberate, and different from enabled above. The argument
that makes the heartbeat default on — signing in is the opt-in — does not
stretch this far. Signing in is consent to be counted, not consent to be
read.
What goes up
Your prompts, the model’s answers, and the order of the tool names that ran between them:
USER
fix the failing tests
ASSISTANT
▸ bash
▸ read
▸ edit
Two tests failed because the timeout was 100ms. Raised to 500ms.
What never does
| Why | |
|---|---|
| Tool output | That is where secrets live — read .env, bash env, grep -r password, all ordinary calls in ordinary work. Filtering automatically is not an option: a secret filter you can rely on does not exist, and one that catches AKIA… while missing an internal token is worse than none, because it manufactures confidence |
| Tool arguments | Subtler, and still no. edit carries oldString and newString, which is code; write carries a whole file. ▸ edit says something was edited; the arguments say what it said |
| Reasoning | Not for its size. Titah separates it from text because text is the answer and this is the road to it — the longest and least re-read material is the worst candidate to leave the machine |
What is lost is real: the transcript cannot tell you what the agent read. What is not lost is the local session, which is still complete. The dashboard is a view, not the record.
Limits, and they are announced
32 KB per message — the same number Titah already uses for tool output, so one number rather than two that can drift — and 512 KB per transcript. Over that the oldest messages are dropped and a marker says how many.
A silently truncated transcript looks complete, and somebody will draw a conclusion from a conversation that was not all of it.
Only from here on
Sessions that get a turn after you switch it on are uploaded. Older ones do not appear unless you work in them again.
There is no backfill, deliberately: seventy sessions arriving at once because somebody flipped one toggle is a surprise in the wrong direction.
How the CLI knows
From the heartbeat response, which already returned sync_enabled. Without
that the only way to find out would be attempting an upload and being refused —
one wasted request per turn, for the life of a project that never turns it on.
A 403 also switches the stored flag off, so it stops trying until a later
heartbeat says otherwise. The server is the authority on its own toggle.
Still placeholders
/dashboard/sessions/ and /dashboard/agents/ query nothing at all. Synced
transcripts appear under their project, at
/dashboard/projects/<id>/sessions/<id>/.
Endpoints
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/api/projects/heartbeat/ | POST | Bearer | What the CLI sends (upsert) |
/api/sessions/sync/ | POST | Bearer | Opt-in session sync |
/api/projects/ | GET | Bearer / session | List projects |
/api/projects/export/ | GET | Session | Download CSV or JSON |
Bearer tokens are the ones titah login obtains — see
Account & sign-in.