Now liveBe one of the first 50 founders of Malleable. Founding members keep a lifetime discount.

Documentation

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-scopes.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
tasks:write        // POST/PATCH/DELETE tasks
buckets:read       // GET buckets
time:write         // start/stop/active timer
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

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, INTERNAL_ERROR.

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

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 (todo / in_progress / done), priority (low / medium / high / urgent), limit, offset. Each task includes a kanban_stage field (one of backlog, todo, in_progress, or ready_for_review) that drives the kanban view.

// Response
{
  tasks: Array<{
    id: string;
    title: string;
    status: "todo" | "in_progress" | "done" | "completed";
    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, bucket_id, is_completed (boolean, mapped to the status column), and estimated_duration. Returns the updated task. Empty body returns NO_FIELDS (400).

DELETE/api/v1/tasks/{id}

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

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. The V1 API exposes a read-only listing so agents can resolve names to IDs before posting other resources.

GET/api/v1/buckets

Scope: buckets:read. Returns all buckets owned by the caller, ordered by name.

// Response
{
  buckets: Array<{
    id: string;
    name: string;
    color: string | null;
    description: string | null;
    icon: string | null;
    type: string | null;
    created_at: string;
  }>;
}

Time Tracker

Three endpoints (start, stop, active) for driving the time-tracking timer from outside the web app. All three require the time:write scope (read access to the active timer is considered part of the same surface).

POST/api/v1/time-tracker/start

Body: { bucket_id?, description? }. Returns 201 with the new entry. If a timer is already running, returns TIMER_ALREADY_RUNNING (400) with the active_entry_id of the running entry so callers can stop it first.

// Response
{
  entry: {
    id: string;
    started_at: string;        // ISO timestamp
    bucket_id: string | null;
    description: string | null;
    is_active: true;
  };
}

POST/api/v1/time-tracker/stop

No body. Stops the currently running timer and stamps ended_at and duration_seconds. Returns NO_ACTIVE_TIMER (400) if nothing is running.

GET/api/v1/time-tracker/active

Returns { entry, timer_running }. When a 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 and timer_running is false. This is a 200 response, not an error.

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" }'

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.