# CLI (tt)

> Reference for the tt command line: install, authentication, every command and option, output formats and exit codes.

URL: https://timetracking.tech/developers/tools/cli
Last updated: 2026-10-24

`tt` is the command line for the TimeTracking REST API (`@timetracking/cli`). It covers the weekly close (overview, conflicts, approval, export), master data, leave, expenses, rosters and the inbox, plus a raw `tt api` escape hatch for every other endpoint.

Everything `tt` does goes through the public API at `https://app.timetracking.tech/api/v1`. It has no extra permissions: what an API key cannot do over HTTP, `tt` cannot do either.

## Install

```sh
npm i -g @timetracking/cli      # installs the `tt` binary
npx @timetracking/cli week      # or run without installing
tt --version
```

Requires Node.js 18 or later (uses the built-in `fetch`).

## Authenticate

Create an API key as an admin in the dashboard under "Regels & koppelingen" (Rules & integrations) → "API-sleutels". Tick "Mag ook schrijven" (may also write) for a key with the `write` scope; leave it unticked for a read-only key. A key with the `admin` scope (needed for `tt lock`) can only be created over the API by an admin user: `POST /api-keys {"name":"…","scopes":["read","admin"]}` with the admin's own access token; no key can create keys. See [Authentication](https://timetracking.tech/developers/api/authentication).

```sh
tt login --key tt_live_...                 # verifies the key with GET /org/week, then saves it
tt login --key tt_live_... --url http://localhost:3001 --skip-verify
tt whoami                                  # shows URL, masked key, where each came from, current week
tt logout                                  # removes the stored key (URL is kept)
```

Configuration file: `$XDG_CONFIG_HOME/timetracking/config.json`, default `~/.config/timetracking/config.json`, written with mode `0600`:

```json
{ "apiKey": "tt_live_...", "url": "https://app.timetracking.tech" }
```

Precedence, highest first:

1. Flags `--key KEY` and `--url URL` (on any command)
2. Environment variables `TT_API_KEY` and `TT_API_URL`
3. The config file
4. Default URL `https://app.timetracking.tech` (there is no default key)

`--url` / `TT_API_URL` is the app origin, not the `/api/v1` path.

### What a key can do

| Key scope | Behaves like | Works | Fails with 403 |
|---|---|---|---|
| `read` | accountant (read-only) | all list/read commands: `tt week`, `tt conflicts`, `tt approve --dry-run`, `tt people list`, `tt placements list`, `tt clients list`, `tt leave list`, `tt expenses list`, `tt shifts`, `tt inbox`, `tt whoami`; via `tt api` every GET | every write, and `tt export` (even preview: it is a POST, answered `API key is read-only`) |
| `read` + `write` | office user | everything above plus writes, preview/commit export | `tt lock` (`Admin role required`) |
| `admin` (implies `write`) | admin + office | everything, including `tt lock` and admin endpoints via `tt api` (webhooks, kiosk devices, listing/revoking keys) | creating API keys or changing office roles (`Not available with an API key`) |

`tt lock` needs an `admin`-scope key. With a write key, lock in the dashboard ("Export" → "Periode vergrendelen") as an admin. See [Periods and locking](https://timetracking.tech/developers/concepts/periods-and-locking).

## Global options

| Option | Meaning |
|---|---|
| `--json` | Print raw JSON (the unwrapped `data` of the API response) instead of tables |
| `--key KEY` | API key; overrides `TT_API_KEY` and the config file |
| `--url URL` | API origin; overrides `TT_API_URL`; default `https://app.timetracking.tech` |
| `-h`, `--help` | Help for a command: `tt <command> --help`, `tt help <command>` |
| `--version`, `-v` | Print version |

Input conventions:

- Dates are `YYYY-MM-DD`. `--week` takes any day in the ISO week (Mon–Sun); `today` and `last-week` are also accepted.
- Durations accept `7.5`, `7,5`, `7:30`, `7u30` and `450m`. Output durations are `h:mm`.
- Clock times are `HH:MM`.

## Exit codes

| Code | Meaning |
|---|---|
| `0` | OK |
| `1` | API error (HTTP error from the server, network error). Message format: `error: <message> [<code> <status>]`, followed by one line per validation issue (`  path: message`) |
| `2` | Usage error (unknown command or option, missing argument, unparseable value, missing API key) |

Example API error on stderr:

```text
error: This day is exported or locked; book a correction in an open period [locked 423]
```

## Commands

### `tt login`

Store an API key (and API URL) in the config file.

```text
tt login --key tt_live_… [--url https://app.timetracking.tech] [--skip-verify]
```

- Keys must start with `tt_`; otherwise exit 2.
- Without `--skip-verify` the key is tested with `GET /org/week` before saving.

### `tt logout`

Remove the stored API key. `tt logout`.

### `tt whoami`

Validate the key and show the connection: URL and its source (`flag`, `env`, `config`, `default`), masked key and its source, current week, number of placements and open conflicts. Calls `GET /org/week`.

```sh
tt whoami --json
```

### `tt week`

Hours per placement for one week, one column per day, with status.

```text
tt week [--week DATE] [--client NAME] [--json]
```

- `--week`, `-w`: any day in the ISO week. Default: current week.
- `--client`: only rows whose client name contains NAME (case-insensitive, filtered client-side).
- Cells are `h:mm`; `!` marks a day with an open conflict.
- API: [`GET /org/week`](https://timetracking.tech/developers/api/reference#get-org-week). `--json` prints the full response (`weekStart`, `weekEnd`, `rows[]`, `conflicts[]`, `kpis`).

### `tt hours add`

Record hours for a placement as an office entry (claim source `manual_office`).

```text
tt hours add --placement ID --date DATE (--hours 7.5 | --start 07:00 --end 15:30 [--break 30]) [--note TEXT]
```

| Option | Meaning |
|---|---|
| `--placement`, `-p` | Placement UUID (from `tt placements list`) |
| `--date`, `-d` | Work date |
| `--hours` | Net worked duration (`7.5`, `7,5`, `7:30`, `7u30`, `450m`). Not combinable with `--start/--end` or `--break` |
| `--start`, `--end` | Clock times; both required together. Overnight shifts: end earlier than start |
| `--break` | Break in minutes (`30`) or as a duration (`0:30`); only with `--start/--end` |
| `--note` | Free text, max 1000 characters |

More than 24 hours in a day is rejected locally. The claim is matched against other sources for the same placement and day; a disagreement creates a conflict (the output says `CONFLICT — see tt conflicts`). Exported or locked days return `423 locked` and nothing is stored. API: [`POST /claims`](https://timetracking.tech/developers/api/reference#post-claims). See [Sources and conflicts](https://timetracking.tech/developers/concepts/sources-and-conflicts).

```sh
tt hours add -p 00000000-0000-4000-8000-0000000000e4 -d 2026-12-01 --start 07:00 --end 15:30 --break 30
# Recorded 8:00 on 2026-12-01 → day status draft
```

### `tt approve`

Approve all submitted, conflict-free days of a week.

```text
tt approve --week DATE [--client NAME] [--dry-run]
```

- Reads the week with `GET /org/week`, collects `submitted` days without a conflict, and posts them to [`POST /days/approve`](https://timetracking.tech/developers/api/reference#post-days-approve).
- Days of clients with approval mode `client` or `both` stay waiting until the client has approved too (`waitingForClient`).
- `--dry-run` prints which days would be approved and changes nothing (works with a read-only key).

Output: `Approved 2 day(s) · 0 waiting for client · 0 skipped`.

### `tt conflicts`

List open conflicts between hour sources for a week.

```text
tt conflicts [--week DATE] [--json]
```

One row per candidate: conflict id, date, person, client, claim id, source, hours. Data comes from `conflicts` in `GET /org/week`.

### `tt conflicts resolve`

Resolve a conflict by picking the winning claim.

```text
tt conflicts resolve CONFLICT_ID --claim CLAIM_ID [--note TEXT]
```

API: [`POST /conflicts/{id}/resolve`](https://timetracking.tech/developers/api/reference#post-conflicts-id-resolve). Resolving an already resolved conflict returns `409 conflict`.

### `tt export`

Preview (default) or commit a payroll export.

```text
tt export --week DATE [--mapping preset-sdworx] [--commit] [-o file.csv]
tt export --from DATE --to DATE ...
```

| Option | Meaning |
|---|---|
| `--week`, `-w` | Any day in the ISO week (Mon–Sun) |
| `--from`, `--to` | Explicit inclusive range; both required together |
| `--mapping`, `-m` | Export mapping id. Default `preset-sdworx`. Other built-in: `preset-generic`. See [Presets](https://timetracking.tech/developers/data-out/presets) |
| `--commit` | Commit instead of preview. **Irreversible**: included days move to `exported` |
| `--output`, `-o` | Output file for `--commit`. Default `tt-export-START_END.csv`; `-o -` writes the file content to stdout |

Without `--commit`, nothing changes. The preview shows rows, `issues` (block a commit), `warnings` (rule-pack compliance hints such as `break_too_short`), the number of approved days included and `pendingCount` (draft/submitted days that are NOT included).

With `--commit`, the file content is written and the command prints the run id, row count and SHA-256 content hash. The same range cannot be committed twice within one minute (409); a second commit with nothing newly approved returns `422 Nothing approved to export in this period`. API: [`POST /exports`](https://timetracking.tech/developers/api/reference#post-exports).

```sh
tt export --week 2026-11-30 --mapping preset-generic
tt export --week 2026-11-30 --mapping preset-generic --commit -o week49.csv
```

### `tt lock`

Lock a period after export (admin; irreversible).

```text
tt lock --week DATE --yes [--kind week]
tt lock --from DATE --to DATE --kind month --yes
```

- Kinds: `week`, `four_weeks`, `semi_month`, `month`. Default `week`.
- `--yes` is required; without it the command exits 2.
- API: [`POST /periods/lock`](https://timetracking.tech/developers/api/reference#post-periods-lock).
- Needs an `admin`-scope key; a write key gets `Admin role required [forbidden 403]`.

### `tt people list`

```text
tt people list [--all] [--json]
```

Active people by default; `--all` includes inactive people. Columns: employee number, name, email, app access (`yes`, `invited`, `no`), active placements, id. API: `GET /people`.

### `tt people add`

Create a person, or update one with `--id`.

```text
tt people add --first NAME --last NAME [--email E] [--number EMPLOYEE_NO] [--phone P] [--locale nl] [--id ID]
```

API: `POST /people`. **Update replaces the whole record**: optional fields you omit (email, phone, locale, employee number) are cleared, and `active` resets to `true`. Pass every field you want to keep. Read the current values with `tt people list --json` first.

To create or update a person by your own system's id, use the external-ID upsert through `tt api` (see [v1.1 endpoints via `tt api`](#v11-endpoints-via-tt-api)). There is no `tt` command for it yet.

### `tt placements list`

```text
tt placements list [--client NAME] [--all] [--json]
```

Columns: id, ref, person, client, role, from, until. API: `GET /placements`.

### `tt clients list`

```text
tt clients list [--json]
```

Columns: id, name, approval mode (`office`, `client`, `both`), number of active placements. API: `GET /clients`.

### `tt import`

Import a master data CSV (people, clients, placements).

```text
tt import FILE.csv [--commit]
```

Without `--commit` this is a dry run that reports what would change. Format and header aliases: [CSV import](https://timetracking.tech/developers/data-in/csv-import). API: [`POST /import`](https://timetracking.tech/developers/api/reference#post-import).

### `tt leave list`, `tt leave approve`, `tt leave reject`

```text
tt leave list [--status requested|approved|rejected|cancelled] [--person ID] [--from DATE] [--to DATE] [--json]
tt leave approve ID [--note TEXT]
tt leave reject ID [--note TEXT]
```

APIs: `GET /leave`, `POST /leave/{id}/decide`. Needs a write-scope key (office role) even for `list`.

### `tt expenses list`, `tt expenses approve`, `tt expenses reject`

```text
tt expenses list [--status submitted|approved|rejected] [--person ID] [--from DATE] [--to DATE] [--json]
tt expenses approve ID [ID…]
tt expenses reject ID [ID…]
```

APIs: `GET /expenses` (default range: last 60 days), `POST /expenses/decide`. Needs a write-scope key.

### `tt shifts`

Roster (planned shifts) for a week, with planning issues (`leave`, `unavailable`, `overlap`, `rest`, `outside_placement`).

```text
tt shifts [--week DATE] [--json]
```

API: `GET /shifts?week=`. Needs a write-scope key.

### `tt inbox`

Mail/upload items that need review (status `needs_review` or `week_conflict`).

```text
tt inbox [--json]
```

API: `GET /inbox`. Needs a write-scope key. Apply or ignore items with `tt api` (below) or in the dashboard. See [Inbound email](https://timetracking.tech/developers/data-in/inbound-email).

### `tt api`

Raw API call for any endpoint.

```text
tt api METHOD PATH [JSON|@file.json|-]
```

- `METHOD`: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`.
- `PATH` is relative to `/api/v1`, including the query string.
- Body: inline JSON, `@file.json`, or `-` for stdin.
- Prints the unwrapped `data` as JSON.

```sh
tt api GET "/org/week?week=2026-10-19"
tt api POST /approval-links '{"clientId":"00000000-0000-4000-8000-0000000000c2","startsOn":"2026-11-30","endsOn":"2026-12-06"}'
tt api POST /inbox/items/ITEM_ID/apply '{"placementId":"…","workDate":"2026-10-20","minutes":480}'
cat claim.json | tt api POST /claims -
```

### v1.1 endpoints via `tt api`

API v1.1 added endpoints that have no dedicated `tt` command (the CLI itself is unchanged). Call them with `tt api`. Details: [API reference](https://timetracking.tech/developers/api/reference), [Pagination](https://timetracking.tech/developers/api/pagination), [Idempotency](https://timetracking.tech/developers/api/idempotency).

```sh
# Upsert a person by external ID (idempotent; response has "created": true|false)
tt api PUT /people/by-external/bullhorn/12345 '{"employeeNumber":"9910","firstName":"Eva","lastName":"Docs","email":"eva.docs@example.com"}'

# Upsert a client by external ID
tt api PUT /clients/by-external/bullhorn/C-77 '{"name":"Bakkerij Brood & Co","approvalMode":"office"}'

# Placement linked by the same source's external refs
tt api PUT /placements/by-external/bullhorn/P-501 '{"personExternalRef":"12345","clientExternalRef":"C-77","ref":"BH-P-501","startsOn":"2026-12-01"}'

# Read one record, deactivate a person (DELETE = active:false)
tt api GET /people/69a47995-bc6a-4bc9-9b9b-070935b974bb
tt api DELETE /people/69a47995-bc6a-4bc9-9b9b-070935b974bb

# Cursor pagination and change feeds (read-only key is enough)
tt api GET "/people?limit=100&updated_since=2026-10-23T00:00:00Z"
tt api GET "/days?from=2026-11-30&to=2026-12-13&status=approved,exported&limit=500"
tt api GET "/claims?from=2026-11-30&to=2026-12-06&source=mail"

# Batch (1–500 items; one bad row never aborts the rest)
tt api POST /claims/batch @claims.json

# Export runs and the exact committed file
tt api GET /exports/runs
tt api GET /exports/runs/239a1898-fab5-486c-a2be-92d939be90cc
```

`tt api` prints the unwrapped `data` as JSON, so a `{items, next_cursor}` page comes back as is; loop on `next_cursor` in your script. `GET /exports/runs/{id}/file` returns CSV text, not JSON; fetch it with curl instead. `tt api` sends no `Idempotency-Key` header (the API honours it on every authenticated write); use curl or your own client when you need one.

Admin endpoints (webhooks incl. deliveries, test, replay, rotate secret; period lock; kiosk devices; listing and revoking API keys) need an `admin`-scope key. Creating keys is never possible with a key.

```sh
tt api GET /webhooks/WEBHOOK_ID/deliveries          # admin key
tt api POST /webhook-deliveries/123/replay          # admin key
```

## Scripting patterns

```sh
# Fail a CI job when the week still has open conflicts
n=$(tt conflicts --week last-week --json | jq length)
[ "$n" -eq 0 ] || { echo "$n open conflicts"; exit 1; }

# Preview as JSON and stop on validation issues
tt export --week last-week --mapping preset-generic --json | jq -e '.ok' > /dev/null

# Placement id by reference
tt placements list --json | jq -r '.[] | select(.ref=="PL-2026-104") | .id'
```

## Related

- [MCP server](https://timetracking.tech/developers/tools/mcp) — the same operations as tools for AI assistants
- [Claude Skill](https://timetracking.tech/developers/tools/claude-skill) — teaches Claude the safe weekly-close workflow using `tt` or MCP
- [API reference](https://timetracking.tech/developers/api/reference), [Errors](https://timetracking.tech/developers/api/errors)
