V1 REST API

Overview & Base URL

The Malleable V1 REST API is the public surface that external agents, the MCP server, and third-party integrations use to read and write your calendar, tasks, notes, contacts, buckets, and time entries. Every route lives under a single base URL and authenticates with a single bearer token, so you can call it from any HTTP client (curl, fetch, your own scripts).

Base URL

https://malleable.cloud/api/v1

Resource groups

Events, tasks, notes, contacts, buckets, time tracker, availability, whoami, and the natural-language agent/schedule endpoint. Every mutating route also writes to an audit log so key owners can see exactly which client touched their data.

Rate limits

60 requests per minute, per API key. When exceeded the server returns HTTP 429 with error code RATE_LIMIT_EXCEEDED.

Authentication (Bearer mk_live_*)

Every V1 route (except the public /availability/[username] probe) requires a bearer token in the Authorization header. Malleable API keys are prefixed with mk_live_.

Authorization: Bearer mk_live_<your-key>

Minting a key

Open the Contacts page in the Malleable web app and use the contact slide-over to create a new API key. Pick the scopes you want the key to carry, you can only ever narrow scopes, never grant more than your account has. Copy the mk_live_* value immediately; it is shown once.

Auth errors

MISSING_AUTH (401) when the header is absent or malformed, INVALID_API_KEY (401) when the key is unknown or revoked, and INSUFFICIENT_SCOPE (403) when the key is valid but missing the scope the route requires. The 403 response includes required_scope and your_scopes fields so the caller can self-diagnose.

Scopes

Scopes are the permission units attached to each API key. The authoritative list lives in lib/api-key-types.ts as the VALID_SCOPES constant; the database CHECK constraint is kept in sync via migration 20260430000009_expand_api_scopes.sql.

calendar:read      // GET events, availability, overlap
calendar:write     // POST/DELETE events
tasks:read         // GET tasks, board, ticket timeline
tasks:write        // POST/PATCH/DELETE tasks
buckets:read       // GET buckets
buckets:write      // POST buckets, PATCH bucket settings (local_path / github_repos)
notes:read         // GET notes
notes:write        // POST/PATCH/DELETE notes
time:read          // read the active timer(s)
time:write         // start/stop/discard timers
projects:read      // GET projects
projects:write     // POST/PATCH/DELETE projects
goals:read         // GET goals
contacts:read      // GET contacts
agent:schedule     // POST /agent/schedule (NL scheduling)
collab:rooms       // CRUD on collab rooms
collab:sync        // join/leave/heartbeat in a collab room
campaigns:read     // GET campaigns
campaigns:write    // POST/PATCH/DELETE campaigns

whoami requires no specific scope (any valid key can call it), which makes it a handy probe to confirm a key works before calling any scoped route.

Error Format

Every V1 error response uses the same shape. code is a stable machine-readable identifier you can branch on; message is a human-readable string suitable for logs but not necessarily for end-users.

{
  "error": {
    "code": "MACHINE_CODE",
    "message": "Human-readable explanation"
  }
}

Common codes

MISSING_AUTH, INVALID_API_KEY, INSUFFICIENT_SCOPE, RATE_LIMIT_EXCEEDED, MISSING_FIELDS, INVALID_DATE, INVALID_TIME, CALENDAR_NOT_CONNECTED, FETCH_FAILED, CREATE_FAILED, UPDATE_FAILED, DELETE_FAILED, TASK_NOT_FOUND, BUCKET_NOT_FOUND, BUCKET_NOT_OWNED, INTERNAL_ERROR.

Some errors include extra context fields alongside code and message, for example INSUFFICIENT_SCOPE adds required_scope and your_scopes.

Events

Calendar events. The DB row is the source of truth; Google Calendar is a best-effort mirror on writes.

GET/api/v1/events

Scope: calendar:read. Query params: start_date, end_date (both YYYY-MM-DD), limit (default 100, max 100), offset.

// Response
{
  events: Array<{
    id: string;
    title: string;
    date: string;             // YYYY-MM-DD
    start_time: string;       // HH:mm:ss
    end_time: string;
    timezone: string;
    attendees: string[];
    description: string;
    location: string;
    meeting_link: string | null;
    bucket_id: string | null;
    created_at: string;
  }>;
  pagination: { limit: number; offset: number; hasMore: boolean };
}

POST/api/v1/events

Scope: calendar:write. Required: title, date (YYYY-MM-DD), start_time, end_time (HH:mm or HH:mm:ss). Optional: attendees, description, location, bucket_id, add_meet (boolean, requests a Google Meet link).

// Request body
{
  title: string;
  date: string;          // "YYYY-MM-DD"
  start_time: string;    // "HH:mm" or "HH:mm:ss"
  end_time: string;
  attendees?: string[];
  description?: string;
  location?: string;
  bucket_id?: string | null;
  add_meet?: boolean;
}

// Response (201)
{
  event: {
    id: string;
    title, date, start_time, end_time, timezone,
    attendees, description, location,
    meeting_link: string | null;
    gcal_event_id: string | null;
    gcal_link: string | null;
    bucket_id: string | null;
    created_at: string;
  };
  gcal_warning: string | null;
}

The DB write is never blocked on Google Calendar. If the GCal mirror fails (expired refresh token, revoked grant, GCal outage), the event is still created in Malleable and the response includes a non-null gcal_warning string with the underlying error message. Clients should surface this so the user knows to reconnect Google Calendar at malleable.cloud/settings/integrations. If the user has never connected GCal at all, the request fails up-front with CALENDAR_NOT_CONNECTED.

curl -X POST https://malleable.cloud/api/v1/events \
  -H "Authorization: Bearer mk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Design review",
    "date": "2026-05-10",
    "start_time": "14:00",
    "end_time": "15:00",
    "add_meet": true
  }'

DELETE/api/v1/events/{id}

Scope: calendar:write. Deletes the event row and best-effort deletes the GCal mirror. Returns { ok: true } on success or EVENT_NOT_FOUND (404) if the event is not owned by the caller.

Tasks

Personal tasks, the same backing rows as the dashboard task queue and kanban board.

GET/api/v1/tasks

Scope: tasks:read. Query params: status (queued / scheduled / completed), priority (low / medium / high / urgent), kanban_stage (backlog / todo / in_progress / ready_for_review / done — filters to that board lane; an invalid value returns 400), bucket_id, limit, offset. done is a virtual lane: it matches completed tasks (status: "completed"), and the four real lanes exclude them. Each task includes stage, the resolved board lane ("done" when completed) — render this one; the raw kanban_stage column keeps the lane the task was in before completion.

// Response
{
  tasks: Array<{
    id: string;
    title: string;
    status: "queued" | "scheduled" | "completed";
    stage: "backlog" | "todo" | "in_progress" | "ready_for_review" | "done";
    priority: "low" | "medium" | "high" | "urgent";
    estimated_duration: number | null;   // minutes
    bucket_id: string | null;
    scheduled_event_id: string | null;
    kanban_stage: "backlog" | "todo" | "in_progress" | "ready_for_review";
    project_id: string | null;
    created_at: string;
  }>;
  pagination: { limit, offset, hasMore };
}

POST/api/v1/tasks

Scope: tasks:write. Required: title (1–500 chars). Optional: priority, estimated_duration (minutes), bucket_id, kanban_stage.

curl -X POST https://malleable.cloud/api/v1/tasks \
  -H "Authorization: Bearer mk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Write the launch post",
    "priority": "high",
    "estimated_duration": 45,
    "kanban_stage": "todo"
  }'

PATCH/api/v1/tasks/{id}

Scope: tasks:write. Any subset of title, kanban_stage, priority (low / medium / highurgent only survives on legacy rows and is rejected on writes), bucket_id, is_completed (boolean, mapped to the status column), due_date (YYYY-MM-DD or null), and estimated_duration. Returns the updated task. Empty body returns NO_FIELDS (400).

POST/api/v1/tasks/{id}/reorder

Scope: tasks:write. Moves a task to an exact slot in its kanban lane (or another lane) without the caller computing ranks: name a slot and the server derives lane_position against the same effective order the dashboard renders. Exactly one anchor: before (task id), after (task id), or position ("top" / "bottom"). stage is optional and only combines with position for cross-lane moves — a before/after anchor already names its lane. Completed tasks and steps are rejected (Done keeps completion order; steps order by step_order).

curl -X POST https://malleable.cloud/api/v1/tasks/{id}/reorder \
  -H "Authorization: Bearer mk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "after": "557296ab-..." }'   // or { "position": "top", "stage": "todo" }

// Response
{
  task: { id, kanban_stage, lane_position },
  lane: Array<{ id, lane_position }>;   // the lane's resulting order
}

DELETE/api/v1/tasks/{id}

Scope: tasks:write. Returns { ok: true } or TASK_NOT_FOUND (404).

GET/api/v1/tasks/{id}/timeline

Scope: tasks:read. A ticket's full stage history, newest first: creation, every move between stages, and terminal events. Powers mal board timeline and the board_ticket_timeline MCP tool.

// Response
{
  events: Array<{
    id: string;
    from_stage: string | null;   // null = the ticket was CREATED
    to_stage: string;
    disposition: 'done' | 'failed' | 'reopened' | null;
    created_at: string;          // ISO 8601
    actor_user_id: string | null;
    actor: { name: string | null; avatar_url: string | null } | null;
  }>;
}

Reading a row: from_stage === null means the ticket was created in to_stage. When from_stage === to_stage the row is a terminal sentinel rather than a move, and disposition says which one (a legacy sentinel with a null disposition means done). Anything else is an ordinary stage move — and a move whose to_stage sits earlier in the lane order than its from_stage is a send-back.

GET/api/v1/tasks/pins

Scope: tasks:read. Lists the caller's pinned tasks — a personal focus queue, max 2, newest pin first. Mirrors the dashboard's Orders rail.

// Response
{
  pins: Array<{
    task_id: string;
    pinned_at: string;
    task: {
      id: string;
      title: string;
      kanban_stage: string | null;
      stage: string;             // resolved lane, "done" when completed
      status: string | null;
      bucket_id: string | null;
      bucket_name: string | null;
      due_date: string | null;
      priority: string | null;
    };
  }>;
}

POST/api/v1/tasks/{id}/pin

Scope: tasks:write. Pins a task as a personal "order" — max 2 per user. The task's creator, or anyone with (cascading) access to its bucket, can pin it — same visibility gate as reading the task. Idempotent: pinning an already-pinned task just returns its current pin_count. A 3rd pin returns PIN_LIMIT_REACHED (409) — unpin one first.

// Response
{ pinned: true; task_id: string; pin_count: number }

DELETE/api/v1/tasks/{id}/pin

Scope: tasks:write. Unpins a task. Idempotent: unpinning a task that was not pinned still returns 200 with was_pinned: false.

// Response
{ pinned: false; task_id: string; was_pinned: boolean }

POST/api/v1/tasks/bulk-move

Scope: tasks:write. Moves many tasks to a real kanban lane in one call — the per-ticket PATCH route doesn't scale to sweeping an entire lane. Body: { to_stage, task_ids?, from_stage?, bucket_id? }. Exactly one of task_ids (up to 1000 uuids) or from_stage (every non-completed task currently in that lane) is required; bucket_id optionally narrows a from_stage sweep to one project. to_stage/from_stage accept real lanes only (backlog / todo / in_progress / ready_for_review) — to_stage: "done" is rejected outright with a pointer at the complete verb (PATCH /api/v1/tasks/{id} with is_completed: true), since done is a virtual lane derived from status, not something this route writes. Writes and stage-transition log inserts go out in chunks of 200 so a large sweep never fires hundreds of sequential single-row statements.

curl -X POST https://malleable.cloud/api/v1/tasks/bulk-move \
  -H "Authorization: Bearer mk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "to_stage": "in_progress", "from_stage": "todo" }'

// Response
{
  moved: number;
  skipped: number;        // candidates already in to_stage
  to_stage: string;
  from_stage?: string;
  bucket_id?: string;
  task_ids: string[];     // every task actually moved — persist as a revert manifest
}

Notes

Notes are scoped to a bucket (bucket_notes table). Listing returns notes across every bucket the caller has access to, owned buckets, accepted-collaborator buckets, and the cascade descendants of either.

GET/api/v1/notes

Scope: notes:read. Query params: bucket_id to scope to a single bucket (must be accessible), limit (default 50, max 100), offset.

// Response
{
  notes: Array<{
    id: string;
    bucket_id: string;
    user_id: string;          // author
    title: string | null;
    content: string;
    created_at: string;
    updated_at: string;
  }>;
  pagination: { limit, offset, hasMore };
}

POST/api/v1/notes

Scope: notes:write. Required: bucket_id (uuid). Optional: title, content. Access is verified through the cascade-aware has_bucket_access RPC; non-accessible buckets return BUCKET_NOT_ACCESSIBLE (404).

curl -X POST https://malleable.cloud/api/v1/notes \
  -H "Authorization: Bearer mk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "bucket_id": "0c8a...",
    "title": "Kickoff notes",
    "content": "Decisions from the call..."
  }'

Contacts

Read-only access to your contact list. Useful for agents that need to look up an email or company before scheduling.

GET/api/v1/contacts

Scope: contacts:read. Query params: search (matches name / email / company), status, limit (default 50, max 100), offset.

// Response
{
  contacts: Array<{
    id: string;
    email: string | null;
    name: string | null;
    first_name: string | null;
    last_name: string | null;
    phone: string | null;
    company: string | null;
    role: string | null;
    location: string | null;
    timezone: string | null;
    tags: string[] | null;
    status: string | null;
    linked_user_id: string | null;
    created_at: string;
    updated_at: string;
  }>;
  pagination: { limit, offset, hasMore };
}

Buckets

Buckets are the top-level grouping for events, tasks, time entries, and notes. Agents typically list them to resolve names to IDs before posting other resources.

GET/api/v1/buckets

Scope: buckets:read. Returns every bucket the caller can access, ordered by name — owned buckets, buckets shared with them as an accepted collaborator, and the cascade descendants of either. This matches what the dashboard shows.

// Response
{
  buckets: Array<{
    id: string;
    name: string;
    color: string | null;
    description: string | null;
    icon: string | null;
    type: string | null;
    local_path: string | null;      // repo this bucket is linked to
    github_repos: string[] | null;
    parent_bucket_id: string | null;
    display_order: number | null;
    created_at: string;
  }>;
}

POST/api/v1/buckets

Scope: buckets:write. Creates a bucket. Body: { name, color?, description?, type?, icon?, parent_bucket_id? }. Pass parent_bucket_id to create it as a sub-bucket; nesting requires owning the parent or being an accepted admin collaborator on it, otherwise NO_PARENT_ACCESS (403). An unknown parent returns PARENT_NOT_FOUND (404), a name the caller already uses returns DUPLICATE_NAME (409), and color defaults to #6366F1. Returns 201 with the created bucket.

PATCH/api/v1/buckets/{id}

Scope: buckets:write. Sets local_path and/or github_repos — the repo linkage that lets the CLI resolve a bucket from your working directory. Owner-only: being an accepted collaborator is enough to see a bucket but not to change its settings, so a non-owner gets BUCKET_NOT_OWNED (403), while a caller with no access at all gets BUCKET_NOT_FOUND (404).

GET/api/v1/buckets/{id}/repos

Scope: buckets:read. Lists every bucket_repo_links row on the bucket — the per-repo GitHub links this bucket tracks, each with which event families it follows and its webhook status. This is the authoritative link table; github_repos on the bucket itself (returned by GET /api/v1/buckets above) is an older compat array kept in sync for readers that haven't migrated. Backs mal buckets repos.

// Response
{
  repos: Array<{
    id: string;
    bucket_id: string;
    repo_full_name: string;         // "owner/name"
    subpath: string | null;         // null = the whole repo
    github_repo_id: number | null;
    html_url: string;
    tracked_events: string[];       // subset of commits, pushes, merges, prs, issues
    webhook_id: number | null;
    webhook_status: string | null;  // "active" | "failed" | null
    created_at: string;
    updated_at: string;
  }>;
}

POST/api/v1/buckets/{id}/repos

Scope: buckets:write. Links a GitHub repo to the bucket, or updates an existing link. Upserts on (bucket_id, repo_full_name, subpath) — the same repo may be linked more than once under different subdirectories, which is what backs mal buckets link --subpath for monorepos. Required: repo_full_name ("owner/name"). Optional: subpath (a relative directory inside the repo; omitted or null claims the whole repo), github_repo_id, html_url (derived from repo_full_name when omitted), tracked_events (a non-empty array from commits, pushes, merges, prs, issues, default all five for a new link). Open to the bucket owner and to edit/admin collaborators — a git remote is identical on every machine, so this is shared team state, unlike local_path. A whole-repo link also syncs the legacy github_repos array on the bucket; a subpath link deliberately does not, since that field has nowhere to record a directory. Returns 201 on create, 200 on update.

curl -X POST https://malleable.cloud/api/v1/buckets/{id}/repos \
  -H "Authorization: Bearer mk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "repo_full_name": "malleable-cloud/cli",
    "tracked_events": ["commits", "prs"]
  }'

// Response (201)
{ "repo": { "id": "...", "bucket_id": "...", "repo_full_name": "malleable-cloud/cli", "subpath": null, "tracked_events": ["commits", "prs"], "webhook_status": null, ... } }

DELETE/api/v1/buckets/{id}/repos

Scope: buckets:write. Unlinks a repo from the bucket. Query params: repo (required, "owner/name"), subpath (optional — omitted targets the whole-repo link, matching POST; pass it to unlink one subdirectory claim instead). Returns { ok: true } or REPO_NOT_LINKED (404).

curl -X DELETE "https://malleable.cloud/api/v1/buckets/{id}/repos?repo=malleable-cloud/cli" \
  -H "Authorization: Bearer mk_live_..."

Time Tracker

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;
}

Availability & Find-Time

Three flavors of free/busy lookup: your own day, a public username, and a multi-user mutual overlap.

GET/api/v1/events/availability

Scope: calendar:read. Query params: date (required, YYYY-MM-DD), duration (minutes, default 60), workday_start / workday_end (HH:mm, default 09:00 / 17:00). Returns busy periods, free slots, and up to 5 suggested slots sized to the requested duration.

// Response
{
  date: string;
  duration_requested: number;
  workday: { start: string; end: string };
  busy_periods: Array<{ start: string; end: string }>;
  free_slots: Array<{ start: string; end: string }>;
  suggested_slots: Array<{ start: string; end: string }>;  // up to 5
}

GET/api/v1/availability/{username}

Public: no API key required. Rate-limited to 20 requests per minute per IP. Returns free/busy for any user who has either set availability_public on their profile or has at least one active booking page. Errors: USER_NOT_FOUND (404), AVAILABILITY_DISABLED (403).

curl "https://malleable.cloud/api/v1/availability/ryan?date=2026-05-10&duration=30"

POST/api/v1/availability/overlap

Scope: calendar:read. Find mutual free time between 2–5 Malleable users. Permission model: the caller may only include other user IDs they share at least one bucket with (owner or accepted-collaborator). Unrelated user IDs are silently dropped and listed under dropped_user_ids.

// Request body
{
  user_ids: string[];        // 2-5 ids; caller MUST be included
  date: string;              // YYYY-MM-DD
  duration_minutes?: number; // default 60
  workday_start?: string;    // default "09:00"
  workday_end?: string;      // default "17:00"
}

// Response
{
  date, duration_requested, workday,
  participating_user_ids: string[];
  dropped_user_ids: string[];
  mutual_free_slots: Array<{ start, end }>;
  suggested_slots: Array<{ start, end }>;   // up to 5
}

Whoami

Identity probe. Call it to confirm a key works and to read back the owner's profile. Requires a valid bearer token but no specific scope.

GET/api/v1/whoami

// Response
{
  user: {
    id: string;
    email: string;
    name: string | null;       // full_name from profile
    avatar_url: string | null;
  };
  key: {
    id: string;
    scopes: string[];          // the scopes attached to THIS key
  };
}
curl https://malleable.cloud/api/v1/whoami \
  -H "Authorization: Bearer mk_live_..."

Agent / NL Scheduling

The natural-language scheduling endpoint. This is the recommended surface for AI agents that want to schedule events from a free-form prompt: it runs the same agentic scheduler the dashboard uses (parsing, clarifying questions, and finally event creation).

POST/api/v1/agent/schedule

Scope: agent:schedule. Required: prompt (string, ≤2000 chars). Optional: context, the conversation context object returned by a previous call, so multi-turn clarifications can resume. Rate limit responses include Retry-After, X-RateLimit-Remaining, and X-RateLimit-Reset headers.

// Request body
{
  prompt: string;                  // <= 2000 chars
  context?: ConversationContext;   // pass back what was returned last turn
}

// Response — three shapes depending on state
// 1) Still parsing or asking for clarification:
{
  state: "parsing" | "clarifying";
  message: string;                 // assistant turn for the user
  context: ConversationContext;    // pass back on next call
  event?: Partial<Event>;          // tentative draft, may be incomplete
}

// 2) Completed — event created in DB and Google Calendar:
{
  state: "completed";
  message: string;
  event: {
    id: string;
    title, date, start_time, end_time, timezone,
    attendees, description, location,
    meeting_link: string | null;
    gcal_event_id: string | null;
    gcal_link: string | null;
  };
}

// 3) Error — e.g. calendar not connected:
{
  state: "error";
  error: { code: string; message: string };
  context: ConversationContext;
}
curl -X POST https://malleable.cloud/api/v1/agent/schedule \
  -H "Authorization: Bearer mk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Book a 30 min sync with Sam tomorrow afternoon"
  }'

Unlike the raw POST /api/v1/events route, this endpoint requires Google Calendar to be connected, it does not fall back to a DB-only write. If the user has not connected GCal, the response is CALENDAR_NOT_CONNECTED (400) and no event is created.