# OnnTrack: collector guide

OnnTrack keeps the hours of Onn Software: which project David worked on, when, for how long, with parallel projects each counted in full. It does not watch anything itself. Collectors tell it what happened, as **events**: a Claude Code session started in a repository, a prompt was given, an answer finished, a commit was made. OnnTrack turns those events into blocks of work per project. Hours are never stored; they are computed from the events, so sending the same event twice is harmless and sending late is fine.

You are such a collector: an OpenClaw instance, a script, or an assistant on David's own machine. This page is the whole contract. If you can run `git` and `curl` you can do this job; nothing here needs anything else.

The same API as an OpenAPI document: `GET /openapi.json`; for people there is a page at `/docs`.

## The one rule

**Send when and where, never what.** An event carries timestamps, the git remote, the branch, the working directory, a session id, a commit sha and a host name. It never carries prompt text, code, diffs, file contents or commit messages. The repositories you read belong to clients; what is in them stays where it is. Reading them is allowed, writing to them is not: put nothing in a client repository, not a hook, not a config file, not a note.

## Keys and addresses

All paths below are relative to the URL of this guide (`https://track.onn.software/api`). Every request except this guide, `/openapi.json` and `/docs` needs

```
Authorization: Bearer <device key>
```

A device key looks like `trk_<device>_<secret>` and belongs to one collector. David makes one in the app (Instellingen → Apparaten) and gives it to you once; keep it as a secret (for OpenClaw: `~/.openclaw/secrets/onntrack.key`), never in a repository, never in a log. A lost key is replaced, not recovered.

Start with `GET /me`. It tells you who you are, which address to look for in git (`authorEmails`), the owner's time zone and idle margin, and how much is known already:

```json
{
  "device": { "id": "…", "name": "Rutger (VM)", "kind": "openclaw", "createdAt": "…", "lastSeenAt": "…" },
  "owner": { "email": "david.hardy@onn.software", "timeZone": "Europe/Amsterdam", "idleMinutes": 15 },
  "authorEmails": ["david.hardy@onn.software"],
  "counts": { "repos": 14, "projects": 9, "events": 2310 }
}
```

## Events

`POST /events` takes a JSON array of events (or `{ "events": [...] }`), at most 500 per request:

```json
[
  { "source": "git", "kind": "commit", "at": "2026-10-22T14:54:07+02:00", "ref": "45114701a7…",
    "id": "45114701a7…", "remote": "git@gitlab.com:quattro/ui3.git", "branch": "staging", "host": "vm" }
]
```

| Field | | |
| :- | :- | :- |
| `source` | required | `claude`, `openclaw`, `git` or `manual` |
| `kind` | required | see the table below |
| `at` | required | when it happened, ISO 8601 with offset or `Z` |
| `ref` | required | what ties a start to its end: the session id, the commit sha, the timer id |
| `id` | optional | your own stable id: the commit sha, the transcript message's `uuid`. Without it the server derives one from `source`, `kind`, `ref` and `at`. Letters, digits, `_ . : -` |
| `remote` | optional | the git remote of the working directory, in any spelling; the server normalises `git@gitlab.com:a/b.git` and `https://x@gitlab.com/a/b` to one repo |
| `cwd` | optional | the working directory; used when there is no remote |
| `branch`, `host` | optional | for the record |
| `until` | `exclude` only | the end of the excluded interval |
| `note`, `projectId` | manual only | what the time was for, and which project |

| `kind` | Meaning | Counts as |
| :- | :- | :- |
| `session_start` | a Claude Code session opened or resumed | the moment plus the idle margin |
| `turn_start` | a prompt was given | from here to the matching `turn_end` (same `ref`) plus the margin; open turns run until now, at most two hours |
| `turn_end` | the answer finished | closes the turn |
| `session_end` | the session closed | the moment plus the margin |
| `commit` | a commit by the owner | the margin before `at` |
| `manual_start` / `manual_stop` | a timer the owner started and stopped | exactly that interval |
| `exclude` | a correction from the app | taken out of whatever it overlaps |

Every event has to say which repository it is about, through `remote` or `cwd`; the server maps the repository to a project. A repository it has not seen becomes a project of its own; David links it to the right one in the app. You do not have to know the projects.

The answer says what happened:

```json
{ "accepted": 12, "duplicates": 38, "newRepos": [ { "id": "git:gitlab.com__quattro__ui3", "remote": "gitlab.com/quattro/ui3", "path": null, "projectId": "…" } ] }
```

`duplicates` are events already known by their `id`: expected, not an error. A `400` carries `{ "error": "events[3]: at is not a date (…)" }`: fix that event and send again. `401`: the key is missing, wrong or revoked. `5xx`: try again later; nothing is lost by retrying.

## Job 1: commits

A commit is proof of work just before it. Every ten minutes:

1. `GET /repos` lists the repositories OnnTrack knows, each with `remote` (canonical, such as `gitlab.com/onn-software/tools/onntrack`), `projectId` and `lastCommitAt`. Scan those you can reach, and any other repository David told you about; a new one is welcome.
2. For each: keep a clone or a bare mirror in your own working directory (not in David's checkouts). `git fetch --all --prune`, then

   ```
   git log --all --author=<each of authorEmails> --since=<lastCommitAt minus 7 days, or 90 days ago the first time> --format='%H%x09%aI'
   ```

   Branches that were force-pushed or deleted may lose commits; that is why the window overlaps. For a repository David runs locally the sha of a commit that is not pushed yet simply arrives later.
3. One event per line: `source: "git"`, `kind: "commit"`, `id` and `ref` the sha, `at` the author date, `remote` the URL you fetched from, `branch` the first branch that contains it if that is cheap (`git branch -r --contains`), otherwise leave it out. **Not the message.**
4. `POST /events` in batches of up to 500.

## Job 2: Claude Code transcripts

Claude Code writes a transcript of every session to `~/.claude/projects/<folder>/<session id>.jsonl` on the machine it runs on. If you are on that machine (David's Mac), or can read it over SSH, those files hold every turn of the last months: the backfill, and the repair of any hook that failed.

Each line is a JSON object. The ones that matter have `type` `"user"` or `"assistant"`, and carry `timestamp`, `sessionId`, `cwd`, `gitBranch` and `uuid`. Skip lines with `isSidechain: true` (sub-agents, already inside a turn) and `isMeta: true`. A `"user"` line is a prompt when `message.content` is a string, or an array that contains a `text` part and no `tool_result` part; a `"user"` line that only carries tool results is not a prompt.

For each transcript changed since your last round (keep the modification times in a state file of your own):

- every prompt → `{ "source": "claude", "kind": "turn_start", "id": <uuid>, "ref": <sessionId>, "at": <timestamp>, "cwd": <cwd>, "branch": <gitBranch> }`
- the last `"assistant"` line before the next prompt, or before the end of the file → `turn_end`, same fields, its own `uuid` as `id`.
- `remote`: run `git -C <cwd> remote get-url origin` when the directory exists; otherwise send `cwd` alone, the server makes a path-based repo of it.

Read only those fields. The text of the messages stays in the file.

## Job 3: the queue

The hook on David's Mac posts each event as it happens. When that fails (no network, laptop asleep) it appends the event to `~/.onntrack/queue.jsonl`, one JSON object per line. If you can see that file: post its lines, and on a `2xx` answer empty the file. The hook also retries the queue itself on its next run, so there is no harm in both of you doing it.

## Cadence

Every ten minutes is right; every hour is acceptable. After a round, `GET /today` shows the blocks and totals the owner sees:

```json
{ "day": "2026-10-23", "timeZone": "Europe/Amsterdam",
  "blocks": [ { "projectId": "…", "day": "2026-10-23", "start": "…", "end": "…", "minutes": 75, "live": false, "sources": ["claude","git"], "eventIds": ["…"], "notes": [] } ],
  "totals": { "perProject": { "…": 75 }, "wallClock": 75, "parallelism": 1 },
  "projects": [ { "id": "…", "name": "Procedura", "customerName": "Procedura B.V.", "billable": true, "…": "…" } ] }
```

Use it as a check that what you sent arrived, not as something to act on: the hours are David's to read.

## Setting up a new instance

1. David makes a device key in the app and stores it on the instance as a secret.
2. The instance knows: this URL, the key, which repositories it may fetch and with what read-only credentials (a deploy token or an SSH key with read access), and whether it can reach `~/.claude/projects` and `~/.onntrack/queue.jsonl`.
3. Schedule the three jobs above every ten minutes. Log counts (`accepted`, `duplicates`, `newRepos`), never event bodies with paths you would not want in a log, and never the key.
4. The first round backfills; later rounds are small.

## A timer for a person's assistant

An assistant on David's own machine may run the manual timer for him: `GET /timer` (the open timer, if any), `POST /timer/start` with `{ "project": "Quattro", "note": "Overleg kaartinteracties" }` (or `projectId`), `POST /timer/stop`. The timer is exact: no margin, and it runs until stopped.

## Routes

| | |
| :- | :- |
| `GET /` | this guide (markdown, no key) |
| `GET /openapi.json`, `GET /docs` | the OpenAPI document, Swagger UI (no key) |
| `GET /me` | the device, the owner's settings, `authorEmails`, counts |
| `POST /events` | a list of events (≤ 500) → `accepted`, `duplicates`, `newRepos` |
| `GET /repos` | known repositories with `projectId`, `lastCommitAt`, `lastEventAt` |
| `GET /events?from=&to=` | raw events in a window (ISO; default today; at most 62 days) |
| `GET /today`, `GET /days/{yyyy-mm-dd}` | blocks, totals and projects of a day |
| `GET /timer`, `POST /timer/start`, `POST /timer/stop` | the manual timer |

Reference implementations of jobs 1 and 2 are in the repository (`collector/commits.mjs`, `collector/transcripts.mjs`): plain Node, no dependencies, print without `--post`, send with it. The code that computes the blocks is the same in the app and behind `GET /today`; the design is in the repository (`docs/ONTWERP.md`) for whoever wants to know why a block is as long as it is.
