Skip to content
titah

Getting started

Project tracking

Signed in, Titah reports metadata about each folder you work in — name, language, git, and the same figures `titah stats` prints. Session sync is separate, off by default, and uploads only prompts, answers, and tool-name order. No file contents or tool output.

What is sent

After a turn ends, at most once every five minutes per project:

  • Project name — the name from package.json, pyproject.toml, go.mod, Cargo.toml or composer.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 logoutNothing is sent without an account, whatever the config says
"enabled": false in the global configEvery project
"enabled": false in a project’s ./titah.jsonThat 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 outputThat 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 argumentsSubtler, 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
ReasoningNot 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

EndpointMethodAuthPurpose
/api/projects/heartbeat/POSTBearerWhat the CLI sends (upsert)
/api/sessions/sync/POSTBearerOpt-in session sync
/api/projects/GETBearer / sessionList projects
/api/projects/export/GETSessionDownload CSV or JSON

Bearer tokens are the ones titah login obtains — see Account & sign-in.