{"openapi":"3.1.0","info":{"title":"OnnTrack: collector API","version":"1.0.0","description":"How a collector (the Claude Code hooks on the laptop, an OpenClaw instance, the `onntrack` CLI) sends what it saw and reads back the day.\n\nA device key (`trk_…`) belongs to one device of the one owner; everything it sends is recorded under that owner. `GET /me` says which device you are.\nEvents are raw and append-only: `POST /events` only creates, an event with an id that exists is a duplicate and is ignored. The server resolves the repo of an automatic event from its `remote` (any spelling) or `cwd`; a repo it has not seen gets a project of its own.\nHours are computed, never stored: `GET /today` and `GET /days/{day}` run the same `blocksFor()` as the app.\n\n**What leaves a machine:** timestamps, the remote URL, the branch, a session id, a commit sha, the working directory and the host name. Never prompt text, code or commit messages.\n\n**Limits:** 500 events per request, a note 500 characters, `GET /events` 62 days at a time. Anything over is refused with 400, never cut off.\n\nThe same guide in plain text, written for an agent to read: `GET /api`."},"servers":[{"url":"/api"}],"security":[{"deviceKey":[]}],"tags":[{"name":"Guide","description":"What any holder of a key reads first; no key needed."},{"name":"Collect","description":"Sending events and seeing what the server knows."},{"name":"Day","description":"Blocks and totals, computed from the events."},{"name":"Timer","description":"The manual timer, for work without an agent or a commit."}],"paths":{"/":{"get":{"tags":["Guide"],"operationId":"getGuide","summary":"The agent guide","description":"The guide (docs/AGENT.md) as Markdown: what to send, how often, and what to do with the answers. Readable without a key.","security":[],"responses":{"200":{"description":"The guide.","content":{"text/markdown":{"schema":{"type":"string"}}}}}}},"/openapi.json":{"get":{"tags":["Guide"],"operationId":"getOpenApi","summary":"This document","security":[],"responses":{"200":{"description":"The OpenAPI document.","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/docs":{"get":{"tags":["Guide"],"operationId":"getDocs","summary":"Swagger UI","security":[],"responses":{"200":{"description":"A page that shows this document.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/me":{"get":{"tags":["Collect"],"operationId":"getMe","summary":"Who you are","description":"The device the key belongs to, the owner's settings (time zone, idle margin, the e-mail address to pass to `git log --author`) and how much the server already holds.","responses":{"200":{"description":"The device and its owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Me"}}}},"401":{"$ref":"#/components/responses/NoKey"}}}},"/events":{"post":{"tags":["Collect"],"operationId":"postEvents","summary":"Send events","description":"A list of up to 500 events, as a JSON array or as `{ \"events\": [...] }`. Idempotent: an event whose id already exists is counted under `duplicates` and left as it is, so sending the same thing twice is harmless. An event with a `remote` or a `cwd` is tied to a repo; a repo the server had not seen is created, with a new project, and listed under `newRepos`. Manual kinds (`manual_start`, `manual_stop`, `exclude`) never get a repo and keep the `projectId` they carry. One bad event refuses the whole request with 400 and `error` names it (`events[3]: …`).","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"type":"array","items":{"$ref":"#/components/schemas/EventInput"},"maxItems":500},{"$ref":"#/components/schemas/EventList"}]}}}},"responses":{"200":{"description":"What was done with them.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostEventsResult"}}}},"400":{"$ref":"#/components/responses/BadInput"},"401":{"$ref":"#/components/responses/NoKey"}}},"get":{"tags":["Collect"],"operationId":"getEvents","summary":"Raw events in a period","description":"The stored events with `at` in the period, oldest first. For checking what arrived and for export; the day views are `GET /today` and `GET /days/{day}`.","parameters":[{"name":"from","in":"query","required":false,"description":"A local day, `yyyy-mm-dd`, in the owner's time zone (`GET /me`). An ISO 8601 instant is taken as is. Default: today. A day starts six hours early, so what ran into the night is in.","schema":{"type":"string"},"example":"2026-10-20"},{"name":"to","in":"query","required":false,"description":"A day (inclusive) or an ISO 8601 instant (exclusive). Default: the same day as `from`. At most 62 days after `from`.","schema":{"type":"string"},"example":"2026-10-23"}],"responses":{"200":{"description":"The events, oldest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Events"}}}},"400":{"$ref":"#/components/responses/BadInput"},"401":{"$ref":"#/components/responses/NoKey"}}}},"/repos":{"get":{"tags":["Collect"],"operationId":"getRepos","summary":"The known repos","description":"Every repo the server has seen, with the project it points at and when it last saw a commit (`lastCommitAt`) or anything at all (`lastEventAt`). Scan git from `lastCommitAt` minus a week.","responses":{"200":{"description":"The repos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Repos"}}}},"401":{"$ref":"#/components/responses/NoKey"}}}},"/today":{"get":{"tags":["Day"],"operationId":"getToday","summary":"Today's blocks and totals","description":"The blocks per project of today (in the owner's time zone), the totals, and the projects to read the ids with. The check after a round of posting.","responses":{"200":{"description":"The day.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Day"}}}},"401":{"$ref":"#/components/responses/NoKey"}}}},"/days/{day}":{"get":{"tags":["Day"],"operationId":"getDay","summary":"A day's blocks and totals","description":"The same as `GET /today`, for any day.","parameters":[{"name":"day","in":"path","required":true,"description":"A local day, `yyyy-mm-dd`, in the owner's time zone (`GET /me`).","schema":{"type":"string","format":"date"},"example":"2026-10-23"}],"responses":{"200":{"description":"The day.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Day"}}}},"401":{"$ref":"#/components/responses/NoKey"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/timer":{"get":{"tags":["Timer"],"operationId":"getTimer","summary":"The running timer","description":"The manual timer that is running: a `manual_start` of the last 24 hours without a `manual_stop`. `timer` is `null` when none is.","responses":{"200":{"description":"The timer, or none.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Timer"}}}},"401":{"$ref":"#/components/responses/NoKey"}}}},"/timer/start":{"post":{"tags":["Timer"],"operationId":"startTimer","summary":"Start the timer","description":"Starts a manual timer for a project, by `projectId` or by `project` (its name, in any case). Without either the time is unassigned. Refused with 409 while a timer is running. Manual time is exact: no idle margin.","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartTimer"}}}},"responses":{"200":{"description":"The timer that now runs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Timer"}}}},"400":{"$ref":"#/components/responses/BadInput"},"401":{"$ref":"#/components/responses/NoKey"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/TimerRunning"}}}},"/timer/stop":{"post":{"tags":["Timer"],"operationId":"stopTimer","summary":"Stop the timer","description":"Stops the running timer with a `manual_stop` event and says how long it ran.","responses":{"200":{"description":"Stopped.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimerStopped"}}}},"401":{"$ref":"#/components/responses/NoKey"},"404":{"$ref":"#/components/responses/NotFound"}}}}},"components":{"securitySchemes":{"deviceKey":{"type":"http","scheme":"bearer","description":"The device key (`trk_…`), made in the app under Instellingen → Apparaten. It is shown once and belongs to one device."}},"responses":{"BadInput":{"description":"The request could not be understood; `error` says why.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NoKey":{"description":"The device key is missing, wrong or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"There is no such day, project or running timer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"TimerRunning":{"description":"A timer is already running; stop it first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}},"Source":{"type":"string","enum":["claude","openclaw","git","manual"],"description":"Who saw it: the Claude hooks, an OpenClaw instance, a git scan, or a person."},"Kind":{"type":"string","enum":["session_start","session_end","turn_start","turn_end","commit","manual_start","manual_stop","exclude"],"description":"`session_start`/`session_end`: a Claude session opened or closed. `turn_start`/`turn_end`: a prompt was given, the answer finished. `commit`: a commit by the owner. `manual_start`/`manual_stop`: the timer. `exclude`: time to take away (needs `until`)."},"EventInput":{"type":"object","required":["source","kind","at","ref"],"properties":{"id":{"type":"string","maxLength":200,"pattern":"^[A-Za-z0-9_.:-]+$","description":"Optional. A commit's sha or a transcript message's uuid; otherwise the server derives one from source, kind, ref and at."},"source":{"$ref":"#/components/schemas/Source"},"kind":{"$ref":"#/components/schemas/Kind"},"at":{"type":"string","format":"date-time","description":"ISO 8601, any offset.","example":"2026-10-23T09:15:00+02:00"},"ref":{"type":"string","maxLength":200,"description":"Session id, commit sha or timer id: what ties a start to its end."},"remote":{"type":"string","maxLength":500,"description":"The git remote of the working directory, in any spelling; the server normalises it.","example":"git@gitlab.com:onn-software/tools/onntrack.git"},"branch":{"type":"string","maxLength":200},"cwd":{"type":"string","maxLength":1000,"description":"The working directory; the repo when there is no remote."},"host":{"type":"string","maxLength":100,"description":"The machine's name."},"until":{"type":"string","format":"date-time","description":"`exclude` only: the end of the interval, after `at`."},"note":{"type":"string","maxLength":500,"description":"Manual events: what the time was for."},"projectId":{"type":["string","null"],"maxLength":200,"description":"Manual events and corrections only: the project directly."}},"example":{"source":"claude","kind":"turn_start","at":"2026-10-23T09:15:00+02:00","ref":"a1b2c3d4-session","remote":"git@gitlab.com:onn-software/procedura.git","branch":"main","cwd":"/Users/hardy/Development/Procedura","host":"macbook"}},"EventList":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/EventInput"},"maxItems":500}}},"Event":{"allOf":[{"$ref":"#/components/schemas/EventInput"},{"type":"object","required":["id","repoId","projectId","deviceId","receivedAt"],"properties":{"id":{"type":"string"},"repoId":{"type":["string","null"],"description":"Resolved by the server from `remote` or `cwd`; the repo points at the project. Null for manual events."},"projectId":{"type":["string","null"]},"deviceId":{"type":"string","description":"The device whose key sent it, or `app` for the web app."},"receivedAt":{"type":"string","format":"date-time"}}}]},"PostEventsResult":{"type":"object","required":["accepted","duplicates","newRepos"],"properties":{"accepted":{"type":"integer","description":"Stored."},"duplicates":{"type":"integer","description":"Already there (or sent twice in this request); left as they were."},"newRepos":{"type":"array","description":"Repos this request made the server create; each got a project of its own.","items":{"type":"object","required":["id","remote","path","projectId"],"properties":{"id":{"type":"string","example":"git:gitlab.com__onn-software__procedura"},"remote":{"type":["string","null"],"example":"gitlab.com/onn-software/procedura"},"path":{"type":["string","null"]},"projectId":{"type":"string"}}}}}},"Events":{"type":"object","required":["from","to","events"],"properties":{"from":{"type":"string","format":"date-time","description":"The start of the window that was read."},"to":{"type":"string","format":"date-time","description":"The end of the window (exclusive)."},"events":{"type":"array","items":{"$ref":"#/components/schemas/Event"},"description":"Oldest first."}}},"Me":{"type":"object","required":["device","owner","authorEmails","counts"],"properties":{"device":{"type":"object","required":["id","name","kind","createdAt","lastSeenAt"],"properties":{"id":{"type":"string"},"name":{"type":"string","example":"OpenClaw VM"},"kind":{"type":"string","enum":["claude-mac","openclaw","cli"]},"createdAt":{"type":"string","format":"date-time"},"lastSeenAt":{"type":["string","null"],"format":"date-time"}}},"owner":{"type":"object","required":["email","timeZone","idleMinutes"],"properties":{"email":{"type":"string","format":"email"},"timeZone":{"type":"string","example":"Europe/Amsterdam"},"idleMinutes":{"type":"integer","description":"The idle margin: minutes of \"still at it\" after an event.","example":15}}},"authorEmails":{"type":"array","items":{"type":"string"},"description":"The addresses to pass to `git log --author`."},"counts":{"type":"object","required":["repos","projects","events"],"properties":{"repos":{"type":"integer"},"projects":{"type":"integer"},"events":{"type":"integer"}}}}},"Repo":{"type":"object","required":["id","kind","remote","path","name","namespace","projectId","projectName","firstSeenAt","lastSeenAt","lastCommitAt","lastEventAt"],"properties":{"id":{"type":"string","example":"git:gitlab.com__onn-software__procedura"},"kind":{"type":"string","enum":["git","path"]},"remote":{"type":["string","null"],"description":"The canonical remote, or null for a path.","example":"gitlab.com/onn-software/procedura"},"path":{"type":["string","null"],"description":"The working directory, for a repo without a remote."},"name":{"type":"string","example":"procedura"},"namespace":{"type":"string","description":"What comes before the name; a hint for grouping.","example":"gitlab.com/onn-software"},"projectId":{"type":["string","null"]},"projectName":{"type":["string","null"]},"firstSeenAt":{"type":"string","format":"date-time"},"lastSeenAt":{"type":"string","format":"date-time"},"lastCommitAt":{"type":["string","null"],"format":"date-time","description":"The latest commit the server knows of; scan from a week before it."},"lastEventAt":{"type":["string","null"],"format":"date-time"}}},"Repos":{"type":"object","required":["repos"],"properties":{"repos":{"type":"array","items":{"$ref":"#/components/schemas/Repo"}}}},"Block":{"type":"object","required":["projectId","day","start","end","minutes","live","sources","eventIds","notes"],"properties":{"projectId":{"type":["string","null"],"description":"Null for time whose repo has no project."},"day":{"type":"string","format":"date"},"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"},"minutes":{"type":"number"},"live":{"type":"boolean","description":"Still running now."},"sources":{"type":"array","items":{"$ref":"#/components/schemas/Source"}},"eventIds":{"type":"array","items":{"type":"string"},"description":"The events the block is made of."},"notes":{"type":"array","items":{"type":"string"},"description":"The notes of the manual events in it."}}},"Totals":{"type":"object","required":["perProject","wallClock","parallelism"],"properties":{"perProject":{"type":"object","additionalProperties":{"type":"number"},"description":"Minutes per project id; unassigned time is under `\"\"`."},"wallClock":{"type":"number","description":"Minutes of the union of all blocks: how long the day was."},"parallelism":{"type":"number","description":"The sum per project divided by the clock; 1 means nothing ran in parallel."}}},"Project":{"type":"object","required":["id","name","customerId","customerName","billable","idleMinutes","rate","archived","auto","createdAt"],"properties":{"id":{"type":"string"},"name":{"type":"string","example":"Procedura"},"customerId":{"type":["string","null"]},"customerName":{"type":["string","null"]},"billable":{"type":"boolean"},"idleMinutes":{"type":["integer","null"],"description":"Overrides the owner's idle margin when set."},"rate":{"type":["number","null"],"description":"Overrides the customer's hourly rate when set."},"archived":{"type":"boolean"},"auto":{"type":"boolean","description":"Made by the server from a repo it had not seen; the owner renames or merges it."},"createdAt":{"type":"string","format":"date-time"}}},"Day":{"type":"object","required":["day","timeZone","blocks","totals","projects"],"properties":{"day":{"type":"string","format":"date"},"timeZone":{"type":"string","example":"Europe/Amsterdam"},"blocks":{"type":"array","items":{"$ref":"#/components/schemas/Block"},"description":"By start, then project."},"totals":{"$ref":"#/components/schemas/Totals"},"projects":{"type":"array","items":{"$ref":"#/components/schemas/Project"},"description":"All projects, to read the ids with."}}},"Timer":{"type":"object","required":["timer"],"properties":{"timer":{"oneOf":[{"type":"object","required":["ref","projectId","note","startedAt"],"properties":{"ref":{"type":"string","example":"tm_Ab3dEf9hIjKl"},"projectId":{"type":["string","null"]},"note":{"type":["string","null"]},"startedAt":{"type":"string","format":"date-time"}}},{"type":"null"}]}}},"TimerStopped":{"type":"object","required":["timer","minutes"],"properties":{"timer":{"type":"null"},"minutes":{"type":"integer","description":"How long the timer ran, rounded."}}},"StartTimer":{"type":"object","properties":{"projectId":{"type":"string","description":"The project's id (`GET /today` lists them)."},"project":{"type":"string","description":"The project's name, in any case; used when `projectId` is absent."},"note":{"type":"string","maxLength":500,"description":"What the time is for."}},"example":{"project":"Procedura","note":"Overleg kaartinteracties"}}}}}