Documentation

Command Line (CLI)

Building on Malleable? Visit the Dev Portal →

Installation & Setup

The Malleable CLI ships as @malleable-cloud/cli on npm. The package exposes two binaries: the long form malleable and the short alias mal. Both run the same entrypoint; examples in these docs use mal for brevity.

Requires Node 18 or newer. Install globally:

npm install -g @malleable-cloud/cli

mal --version
# 0.2.1

Top-level commands

Once installed, mal --help lists every subcommand. The currently-shipped surface is:

mal login | logout | whoami
mal today | tomorrow | week
mal buckets list|create|delete
mal events  add | ls | rm
mal tasks   add | ls | move | done | rm
mal notes   add | ls | attach | rm
mal time    start | stop | status
mal free                       # solo free slots
mal free-with <email-or-uuid>  # mutual free slots
mal nl "..."                   # natural-language escape hatch
mal collab  ls | create | join | leave | show | tail
mal config  --get | --set | --list
mal schedule [...]             # deprecated alias

Configuration file

Non-secret config is persisted via conf (a JSON file in the OS config dir). The API key itself is stored separately, see Authentication. View safe values with:

mal config --list
mal config --get apiUrl
mal config --set apiUrl=https://malleable.cloud

Settable keys: apiUrl, email, timezone, defaultBucketId, outputFormat. The keys apiKey and userId are written by mal login and cannot be set directly.

Claude Code Skill (mal skill install)

The CLI package bundles a /malleable skill for Claude Code. Installing it lets Claude drive your board and calendar through the same verbs as the CLI, inside a normal Claude Code session.

mal skill install
mal skill path

mal skill install copies the bundled skill into ~/.claude/skills. It is an explicit, one-time opt-in, nothing runs or installs automatically when you run npm install. Re-run it after upgrading the CLI to pick up any changes to the skill.

mal skill path prints the bundled skill directory, useful if you want to inspect it or symlink it into a different Claude Code skills path.

Once installed, invoking /malleable in a Claude Code session gives Claude the same board and calendar verbs documented on this page. Those verbs are deterministic API calls, zero Malleable AI credits; only mal nl touches the scheduling agent.

Authentication & API Keys

Every CLI request is authenticated as Authorization: Bearer <api-key> against the V1 API at https://malleable.cloud/api/v1/*. There are no session cookies, the CLI is built for headless use.

Logging in

Two flows are supported. Browser-based is recommended; it prints a clickable link to malleable.cloud/cli-auth, you click Authorize this device in the browser, and the CLI claims a freshly-minted key by polling /api/cli-auth/verify, no copy-pasting.

# Interactive (browser click-to-authorize, or paste a key)
mal login

# Non-interactive — paste an API key minted at malleable.cloud/settings/api-keys
mal login --api-key mk_live_QBv9...hNss

Where the key is stored

The CLI prefers the OS keychain via keytar (macOS Keychain, Windows Credential Vault, libsecret on Linux). When the keychain is reachable, the JSON config only stores a presence flag __keychain__ so sync auth checks still work without async hops. If keytar fails to load (headless server, missing libsecret), the key falls back to the conf JSON store unencrypted, an explicit second-best, not a default.

whoami

mal whoami calls GET /api/v1/whoami and surfaces the user, the key's fingerprint (first 12 + last 4 chars), the granted scopes, and where the key lives.

$ mal whoami

Current User:

  Email:    ryan.organically@gmail.com
  User ID:  9b1f...
  API URL:  https://malleable.cloud
  Key:      mk_live_QBv9...hNss · scopes: calendar:read, calendar:write
  Storage:  OS keychain

If the granted key carries both calendar:write and tasks:write, the CLI prints a yellow advisory suggesting you mint narrower per-use-case keys at malleable.cloud/settings/api-keys.

logout

mal logout deletes the key from both the OS keychain and the conf store, then clears userId and email from config. It prompts for confirmation.

Events: add, ls, rm

The events subcommands are direct V1 CRUD against /api/v1/events, there is no LLM in the path. Use these when you have structured input. For free-form natural language, see mal nl.

events add

Creates a single event. The four required flags are --title, --date, --start, and --end.

--title <string>        (required) Event title
--date  <YYYY-MM-DD>    (required) Local date
--start <HH:MM>         (required) 24-hour start
--end   <HH:MM>         (required) 24-hour end
--bucket <uuid>         Bucket id (color-codes the event)
--description <string>  Body text
--meet                  Attach a Google Meet link
--location <string>     Free-form location
--json                  Emit { ok, data } JSON envelope

Smoke-test invocation against production:

$ mal events add \
    --title "Ralph CLI demo · Centsure" \
    --date 2026-05-01 \
    --start 15:00 \
    --end 15:30 \
    --bucket 9c449c4c-1080-451b-b6ce-d8482f763c0f

  Event created

  Ralph CLI demo · Centsure
  Friday, May 1
  3:00PM - 3:30PM
  id: 3649b9fb-04f2-40a7-8d26-55a8f78a2628

With --json, the success envelope is:

{
  "ok": true,
  "data": {
    "event": {
      "id": "3649b9fb-...",
      "title": "Ralph CLI demo · Centsure",
      "date": "2026-05-01",
      "start_time": "15:00",
      "end_time": "15:30",
      "meetLink": "https://meet.google.com/..."
    }
  }
}

If the user's Google grant is expired or revoked, the DB write still succeeds and the response includes gcal_warning: the event is on Malleable but not mirrored to Google Calendar.

events ls

Lists events in a date window. --from defaults to today; --to defaults to --from (single day). The --bucket filter is client-side: the API returns the full window and the CLI prunes by bucket_id.

mal events ls --from 2026-05-01 --to 2026-05-01

  Events 2026-05-01

  ● Ralph CLI demo · Centsure
    3:00PM - 3:30PM  2026-05-01
    id: 3649b9fb...

  1 event

events rm

Deletes an event by id. The CLI tries DELETE /api/v1/events/:id first and falls back to the legacy DELETE /api/events/:id if the V1 route is unavailable.

mal events rm 3649b9fb-04f2-40a7-8d26-55a8f78a2628
# Event 3649b9fb… deleted

today / tomorrow / week

Three convenience readers over the same V1 endpoint with preset date windows. week spans Monday → Sunday and groups events by day. All three accept --json.

mal today      # alias: mal t
mal tomorrow
mal week       # alias: mal w
# 8 events (11.4 hours)

Tasks: kanban via CLI

Task commands are kanban-aware. Valid stages are backlog, todo, in_progress, and ready_for_review. All five subcommands accept --json.

tasks add

--title <string>     (required) Task title
--bucket <uuid>      Bucket id
--stage  <stage>     backlog | todo | in_progress | ready_for_review
--priority <p>       low | medium | high
--est <minutes>      Estimated duration in minutes
--json
mal tasks add \
  --title "Wire up V1 PATCH for tasks" \
  --bucket 9c449c4c-1080-451b-b6ce-d8482f763c0f \
  --stage in_progress \
  --priority high \
  --est 90

tasks ls

Lists tasks. --bucket is server-side via the bucket_id query parameter; --stage is filtered client-side after fetch.

mal tasks ls --stage todo
mal tasks ls --bucket 9c449c4c-1080-451b-b6ce-d8482f763c0f

tasks move

Moves a task to a different kanban stage via PATCH /api/v1/tasks/:id.

mal tasks move 7d8e... --stage ready_for_review

tasks done / tasks rm

tasks done <id> patches { is_completed: true }; tasks rm <id> issues DELETE /api/v1/tasks/:id.

mal tasks done 7d8e9f10-...
mal tasks rm   7d8e9f10-...

JSON envelope

# Success
{ "ok": true, "data": { "task": { "id": "...", "title": "...", "kanban_stage": "todo" } } }

# Error
{ "ok": false, "error": { "code": "CLI_ERROR", "message": "Invalid --stage. Must be one of: backlog, todo, in_progress, ready_for_review" } }

Time tracking: start, stop, status

The time-tracker subcommands wrap /api/v1/time-tracker/* and require the time:read / time:write scopes on the API key. Only one timer can be active at a time per user.

time start

--bucket <uuid>         Optional bucket
--task   <uuid>         Optional task to attribute against
--description <string>  Free-form description
--json
mal time start \
  --bucket 9c449c4c-1080-451b-b6ce-d8482f763c0f \
  --description "Pairing on V1 sweep"

time stop

Stops the active timer. The response surfaces the rolled-up duration in minutes.

$ mal time stop
  Timer stopped
  duration: 47m

time status

Reads GET /api/v1/time-tracker/active and shows the running entry with elapsed time. If no timer is active the call still succeeds (data is null).

$ mal time status

  Active timer

  ● Centsure
  Pairing on V1 sweep
  1h 12m  since 2026-05-01T14:18:03.000Z
  id: e8a1...

JSON envelope

{
  "ok": true,
  "data": {
    "entry": {
      "id": "e8a1...",
      "bucket_id": "9c449c4c-...",
      "task_id": null,
      "description": "Pairing on V1 sweep",
      "started_at": "2026-05-01T14:18:03.000Z"
    }
  }
}

Notes: add, ls, attach

Note commands cover bucket-scoped notes plus task attachment. Required scopes: notes:read and notes:write.

notes add

--bucket <uuid>     (required) Owning bucket
--title  <string>   (required) Note title
--content <string>  Body content (defaults to empty)
--json
mal notes add \
  --bucket 9c449c4c-1080-451b-b6ce-d8482f763c0f \
  --title "Sweep checklist" \
  --content "All 16 V1 routes flipped to admin client."

notes ls

With --bucket, the CLI hits the bucket-scoped endpoint /api/buckets/:id/notes. Without it, it falls back to the V1 bucket-notes index at /api/v1/notes.

mal notes ls
mal notes ls --bucket 9c449c4c-1080-451b-b6ce-d8482f763c0f

notes attach

Attaches an existing note to a task via POST /api/tasks/:taskId/notes. Both ids are positional: task first, then note.

mal notes attach <task-id> <note-id>

notes rm

The notes rm command is wired up but the V1 delete endpoint is not finalized. Today the command emits a CLI_ERROR with message "TODO: V1 note delete endpoint not implemented yet." Use the web UI to delete.

Find time with another user

Two commands surface availability. mal free is solo and reads /api/v1/events/availability. mal free-with is mutual and posts to /api/v1/availability/overlap.

mal free

-d, --duration <minutes>   Slot length, default 60
    --date <YYYY-MM-DD>    Date to scan, default today
    --json
$ mal free -d 30 --date 2026-05-02

  Available time slots:

  • 09:00 - 09:30
  • 11:30 - 12:00
  • 14:00 - 14:30

mal free-with

Takes a target positional that may be either a Malleable user UUID or an email. Emails are resolved via GET /api/v1/contacts?search=<email>, a hit must have linked_user_id populated, otherwise the CLI errors with "No contact with email X linked to a Malleable user".

# Email
mal free-with sam@malleable.cloud -d 45

# UUID
mal free-with 9b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d --date 2026-05-02

The overlap response includes a dropped_user_ids array, users who couldn't be included because they don't share a bucket with the caller. The CLI prints a yellow note when this is non-empty so you know the result excludes them.

{
  "ok": true,
  "data": {
    "mutual_free_slots":  [{ "start": "10:00", "end": "11:00" }],
    "suggested_slots":    [{ "start": "10:00", "end": "11:00" }],
    "participating_user_ids": ["9b1f...", "a2c3..."],
    "dropped_user_ids": []
  }
}

Natural language (mal nl)

mal nl is the explicit escape hatch into the scheduling agent. It posts your free-text prompt to POST /api/v1/agent/schedule and handles the multi-turn clarification flow if the agent needs more info. This is the only CLI command that touches the LLM, everything else is direct CRUD.

mal nl [message...]

-b, --bucket <uuid>    Pre-assign to a bucket
    --no-interactive   Disable follow-up prompts on clarifying responses
mal nl "schedule lunch with sam tomorrow at noon"
mal nl "block 90m of focus on Friday afternoon" -b 9c449c4c-...

The agent returns one of three states:

{ "state": "completed",  "event":   { "id": "...", "title": "...", "meetLink": "..." } }
{ "state": "clarifying", "message": "What time on Tuesday?" }
{ "state": "noop",       "message": "..." }

On clarifying, the CLI prompts you for a follow-up reply and re-invokes itself with the combined message, unless you passed --no-interactive.

Deprecated: mal schedule

The old mal schedule command is still present as a deprecation shim. If you pass the four structured flags (--title, --date, --start, --end) it forwards to events add; otherwise it forwards to nl and prints a yellow notice. Move existing scripts off it.

Collab rooms (mal collab)

Collab rooms are shared spaces backed by /api/v1/collab/rooms/*. Required scopes: collab:rooms for room CRUD, collab:sync for the message tail. Sending messages from the CLI is intentionally out of scope for v1.

collab ls

$ mal collab ls

  Collab rooms

  ABC123  Standup — daily kickoff
  XYZ987  Centsure pairing

  2 rooms

collab create

--name <string>         (required) Room name
--description <string>  Optional description
--json
$ mal collab create --name "Standup" --description "daily kickoff"

  Created room: Standup
  Code: ABC123
  Share this code so others can run `mal collab join ABC123`

collab join / leave

Both take a positional room code. Codes are case-insensitive, the CLI uppercases before posting.

mal collab join  ABC123
mal collab leave ABC123

collab show

Prints room metadata, participants (with role + status), the most recent files attached, and the last 5 messages.

mal collab show ABC123

collab tail

--limit  <n>    How many messages to fetch (max 100, default 50)
--follow        Poll every 5s for new messages, print only new ones
--json          Emit one { ok, data } batch per poll for stream parsing
# One-shot: latest 50 messages chronologically
mal collab tail ABC123

# Live tail with 5s polling
mal collab tail ABC123 --follow

# Stream parseable: pipe into jq, etc.
mal collab tail ABC123 --follow --json | jq '.data[].content'

In follow mode the CLI dedupes by message id and prints only deltas. Ctrl+C cleanly clears the interval and exits 0.

JSON output & scripting

Every read and write command (except the legacy interactive shims) accepts --json. The envelope is uniform across the CLI:

# Success
{ "ok": true, "data": <command-specific payload> }

# Error
{ "ok": false, "error": { "code": "CLI_ERROR" | "NOT_AUTHENTICATED", "message": "..." } }

When --json is set the CLI suppresses all chrome: no spinners, no chalk colors, no banner. Stdout is a single line of JSON; stderr stays empty on success. Process exit code is 0 on { ok: true } and 1 on { ok: false }, so shell pipelines work cleanly.

Error codes

NOT_AUTHENTICATED   No API key on file — run `mal login`
CLI_ERROR           Validation, missing flags, or upstream API error

Upstream V1 API errors are unwrapped: the API's own { error: { code, message } } body becomes the message string under CLI_ERROR. To see the raw status code, drop --json and read stderr.

Scripting recipes

# Today's events as a jq pipeline
mal today --json | jq '.data[] | { title, start_time }'

# Bail out of a shell script if not logged in
mal whoami --json >/dev/null || { echo "not logged in"; exit 1; }

# Create an event and capture its id
EVENT_ID=$(mal events add \
  --title "Daily standup" --date 2026-05-04 --start 09:00 --end 09:15 \
  --json | jq -r '.data.event.id')

# Move every "todo" task in a bucket to "in_progress"
mal tasks ls --stage todo --bucket "$BUCKET" --json \
  | jq -r '.data[].id' \
  | xargs -I {} mal tasks move {} --stage in_progress --json

CI / headless usage

On servers without a desktop keychain, install with npm install -g @malleable-cloud/cli and authenticate non-interactively:

mal login --api-key "$MALLEABLE_API_KEY"
mal whoami --json

The CLI will detect that keytar is unavailable and fall back to the conf JSON store automatically, no manual flag required. Persist the conf dir between job runs to avoid re-login on every CI step.