Three endpoints (start, stop, active) for driving time tracking from outside the web app. A user can run several timers at once, for example one per repo or terminal. All three require the time:write scope (read access to the running timers is considered part of the same surface).
POST/api/v1/time-tracker/start
Body: { bucket_id?, description?, source? }. Always starts a new timer and returns 201 with the new entry, even when other timers are already running: that no longer produces an error.
source is an optional object — { surface?, cwd?, host?, client_session? }, all strings — recording where this timer was started from. It is advisory provenance only: nothing reads it to change behavior today, it just gives a later lookup (a human, or mal time reconcile) something to attribute a running timer to instead of guessing. Unknown fields inside source are dropped and every value is trimmed and capped at 200 characters server-side; omit it entirely and the route still works exactly as before.
The response also carries timer_count (how many timers are now running, including this one) and warnings, an array of { code, message, session_ids }. PARALLEL_TIMERS_RUNNING is informational. BUCKET_ALREADY_BILLING means another running timer already bills the bucket this one just started against, so the overlapping wall clock is being counted against that bucket twice.
// Response
{
entry: {
id: string;
started_at: string; // ISO timestamp
bucket_id: string | null;
description: string | null;
is_active: true;
};
timer_count: number;
warnings: Array<{
code: "PARALLEL_TIMERS_RUNNING" | "BUCKET_ALREADY_BILLING";
message: string;
session_ids: string[];
}>;
}
POST/api/v1/time-tracker/stop
Body: { session_id?, all? }, both optional. With session_id, stops that specific timer. Omitted (and all not set), it stops the most recently started running timer rather than refuse for being ambiguous, so older callers that never send session_id keep working unchanged. Stamps ended_at and duration_seconds on the stopped session, and the response also carries still_running, an array of { session_id, title, bucket_id, started_at } for every timer still running after this one stopped. Returns NO_ACTIVE_TIMER (400) if nothing is running.
Stopping every timer: all: true
{ all: true } stops every running session for the caller in one request instead of chaining N single stops — the correct shape for "clock out." It loops server-side over each running session with its own try/catch, so one failing target never aborts the rest, then re-queries the active-timer set fresh to compute fully_stopped — that field is never inferred from what the loop believes it did, only from what the server observes immediately after. Stopping when nothing is running is a successful no-op, not an error.
// Request
{ "all": true }
// Response
{
all: true;
stopped: Array<{
session_id: string;
title: string | null;
bucket_id: string | null;
started_at: string;
ended_at: string;
duration_seconds: number;
duration_minutes: number;
entries_created: number;
}>;
stopped_count: number;
already_stopped: string[]; // session ids another surface stopped first (race, not a failure)
failed: Array<{ session_id: string; code: string; message: string }>;
still_running: Array<{ session_id: string; title: string | null; bucket_id: string | null; started_at: string }>;
fully_stopped: boolean; // still_running.length === 0 && failed.length === 0
}
fully_stopped is the field every caller (CLI, MCP, the dashboard chat agent) is expected to key off of before claiming "you're clocked out" — it is derived from a fresh post-stop re-query, not the per-session outcomes above, so it stays true even if the request landed after some other concurrent process had already stopped a session.
GET/api/v1/time-tracker/active
Returns { entry, timers, timer_count, timer_running }. timers is every running timer, newest-started first, each in the same shape as entry. entry is defined as the most recently started running timer, so timers[0] === entry. When at least one timer is running, entry includes elapsed_seconds (computed server-side from started_at) plus the bucket name. When no timer is running, entry is null, timers is an empty array, and timer_running is false. This is a 200 response, not an error.
A single timer session can itself bill more than one bucket at once, so each entry in timers (and entry) also carries bucket_ids and billed_buckets, the buckets on that session's open interval, resolved to { id, name, color, elapsed_seconds, duration_basis, since }. Test membership in bucket_ids to ask whether a given bucket is being billed by a session; the scalar bucket_id is the session row's own value and is kept only for clients written against the older shape. This per-session multi-bucket billing is a separate axis from running multiple timer sessions at once: timers lists the sessions, bucket_ids lists what one session bills.
duration_basis says whether a per-bucket time exists. "lap" means every lap carrying that bucket carried only that bucket, so elapsed_seconds is a real figure. "co_attributed" means it shares a lap with other buckets under a single start time, so elapsed_seconds is null — there is no split to report, and returning the session total once per bucket would not be one.
curl -X POST https://malleable.cloud/api/v1/time-tracker/start \
-H "Authorization: Bearer mk_live_..." \
-H "Content-Type: application/json" \
-d '{ "description": "Deep work on docs" }'
The dashboard's /time-tracker page renders this same data as the Weave: every session drawn as a thread on a shared time axis, rising off the rail at its exact start and dropping back at its exact end. Run several timers at once and they read as parallel plateaus over the same stretch of axis rather than one line, which is the whole reason it exists.
GET/api/v1/time-tracker/entries
Lists completed entries (rows in time_entries, not the currently-running sessions /active returns). Scope: time:read or time:write (either is accepted — this route only reads). Query params, all optional:
id Return exactly this time_entries id (ownership-checked). Present ->
every other filter below is ignored; this is a direct lookup, not a scan.
since ISO timestamp or YYYY-MM-DD. Entries STARTING at or after this.
Default: 14 days ago.
tag Entries whose tags array contains this exact value (e.g. "auto-reaped").
min_minutes Entries whose duration_ms is at least this many minutes.
tag and min_minutes AND-combine when both are given. Expressing "tag OR over-threshold" (what mal time reconcile's default scan actually wants) is the caller's job: issue this route twice and merge/dedupe by id, same as reconcile does — this route deliberately stays a single, simple filter rather than growing an ad hoc OR grammar for one caller.
// Response
{
entries: Array<{
id: string;
title: string | null;
start_time: string;
end_time: string;
duration_ms: number;
bucket_id: string | null;
bucket_ids: string[];
session_id: string | null;
tags: string[] | null;
}>;
count: number;
}
curl "https://malleable.cloud/api/v1/time-tracker/entries?tag=auto-reaped&since=2026-08-01" \
-H "Authorization: Bearer mk_live_..."
Auto-reap: sessions capped at 20 hours
A server-side sweep runs every 6 hours and stops any session that has been continuously active for more than 20 hours — a forgotten mal time start left running for days should not keep billing indefinitely. A reaped entry carries the tag "auto-reaped" in its tags array (queryable via GET /api/v1/time-tracker/entries?tag=auto-reaped), and the underlying session row gets a reaped_at timestamp. The reap instant is not a real measurement of when work actually stopped — it is just when the sweep caught it — so a reaped entry's duration is almost always an overestimate. Correct it with PATCH /api/v1/time-tracker/sessions/{id}/entry (what mal time edit calls) or delete the entry outright; mal time reconcile automates finding and proposing the correction from local evidence.
DELETE/api/v1/time-tracker/entries/{id}
Discard a completed time_entries row so it bills nothing — a hard delete, not a correction. Companion to PATCH /api/v1/time-tracker/sessions/{id}/entry (that route fixes a stopped entry's numbers; this one removes the row entirely). {id} is the time_entries id, not a session id. Also deletes the source lap (time_entry_intervals row) when one exists, best-effort — a failed lap cleanup never fails the discard, since the entry itself is already gone. Owner-only, requires time:write. There is no undo.
// Response
{
discarded: true;
entry: {
id: string;
bucket_id: string | null;
bucket_ids: string[] | null;
duration_ms: number;
start_time: string;
end_time: string;
description: string | null;
};
interval_deleted: boolean;
}
POST/api/v1/time-tracker/sessions/{id}/discard
Kill a tracking session — running or already stopped — so it bills nothing. Deletes the session's laps (best-effort), deletes every time_entries row it already produced (a just-stopped or auto-reaped session can still have billed entries;entries_deleted reports how many rows were removed so the caller can tell "nothing to un-bill" from "already at zero"), then deactivates the session (is_active: false) and stamps stopped_at. Owner-only, requires time:write. There is no undo — this is the route mal time discard falls back to when its argument isn't a time_entries id.
// Response
{
discarded: true;
was_active: boolean;
entries_deleted: number;
}