Command Line (CLI)

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.3.0

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 | link | unlink | current | repos
mal events  add | ls | rm
mal tasks   add | ls | move | due | done | schedule | rm
mal board   ls | show | timeline | move | tag | attach | comment
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.

Bundled since CLI 0.8.0, mal skill install also drops a /timer slash command into ~/.claude/commands/, a shorthand for the time-tracker verbs specifically: ask Claude to check, start, or stop your Malleable timer without leaving the session. It wraps the same mal time start / stop / status calls documented below under Time tracking, parallel timers included — it can name a specific session or bucket rather than assuming there is only one timer to act on.

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.

Buckets: link, unlink, current

Linking a bucket to the repo you're standing in is what lets tasks add, tasks ls, and board ls auto-detect the right bucket from your current directory instead of requiring --bucket on every call.

buckets link

mal buckets link <bucket-id> [--path <dir>] [--events <csv>] [--subpath [dir]] [--local]

--path <dir>      Directory to link (defaults to cwd)
--events <csv>    Tracked event families: commits,pushes,merges,prs,issues (default: all)
--subpath [dir]   Claim only this subdirectory of the repo (bare flag = the current directory)
--local           Link on THIS MACHINE ONLY — skip the shared bucket tags

Without --local, linking a repo with a GitHub remote registers a bucket_repo_links row on the bucket itself: the git remote is identical on every machine, so the connection resolves for every teammate with zero local setup, not just the person who ran the command. This is the source of truth and the point of the command.

--local opts out of that: it writes only to this machine's own link map (in the CLI's config file) and skips the shared bucket tags entirely. Use it to override resolution on your machine alone — for example, two buckets legitimately claim the same repo and you want a personal default without changing what anyone else sees. It's also written automatically (without needing the flag) when the directory has no GitHub remote to register.

# Shared: registers the repo on the bucket, resolves for every collaborator
mal buckets link 9c449c4c-1080-451b-b6ce-d8482f763c0f

# This machine only: overrides resolution here without touching the shared tag
mal buckets link 9c449c4c-1080-451b-b6ce-d8482f763c0f --local

# Narrow the claim to one directory of a monorepo
mal buckets link 9c449c4c-1080-451b-b6ce-d8482f763c0f --subpath cli

buckets repos

Lists every repo linked to a bucket via GET /api/v1/buckets/:id/repos, one line per link showing the subdirectory it claims (if any), which event families it tracks, and its webhook status. bucket-id is optional — omitted, it resolves the bucket for cwd, the same lookup buckets current uses.

mal buckets repos [bucket-id]

$ mal buckets repos

  Repos — My Project

  malleable-cloud/cli  (whole repo)
    events: commits, pushes, merges, prs, issues
    webhook: active

  malleable-cloud/cli  /docs
    events: commits, prs
    webhook: active

  2 repos

buckets unlink

Drops this machine's local link for a directory. It only touches the local link map — a bucket's shared repo tag (written by a plain buckets link) is owner-managed and untouched by unlink; remove that from the dashboard, or by an owner re-running buckets link to a different bucket.

mal buckets unlink [--path <dir>]

$ mal buckets unlink
  Unlinked /Users/ryan/dev/my-project from My Project
  (local link map only — shared bucket tags untouched)

buckets current

Resolves which bucket (if any) the current directory belongs to, and says how it matched. Every command that auto-scopes to cwd (tasks add, tasks ls, board ls) uses the same resolution.

$ mal buckets current

  ● My Project
    ID: 9c449c4c-1080-451b-b6ce-d8482f763c0f
    matched via: the repo tag on the bucket (shared — resolves for everyone)

The matched via line names one of three tags: the repo tag on the bucket (shared, the source of truth), this machine's local override (an explicit --local link), or the bucket's deprecated local_path. If a subpath claim is in play it also prints claims subdirectory: <dir>, and if two buckets claim the same repo at the same specificity it prints a yellow ambiguity warning naming the other bucket rather than resolving silently. Pass --json for a scriptable { ok, data } envelope.

Resolution order

Given the current working directory, resolution checks these in order and stops at the first match:

1. Explicit local override    mal buckets link --local (this machine only)
2. github_repos / repo tag    the bucket's shared, server-side GitHub remote claim
3. Legacy local map           older, non-explicit entries from before repo tags existed
4. local_path                 the bucket's deprecated server-side absolute-path fallback,
                               for directories with no GitHub remote at all

Step 2, the shared repo tag, is the source of truth: a git remote is identical for every user on every machine, so once a repo is registered on a bucket it resolves for everyone with zero local setup. Steps 1, 3, and 4 exist for the cases the shared tag can't cover — a deliberate personal override, an old config from before repo tags shipped, or a directory with no GitHub remote — and are never load-bearing for a repo that already has a registered remote. When a repo has multiple link rows, the most specific subpath claim wins over a whole-repo claim for a cwd inside it.

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

mal tasks add [title] [--title <string>] [--bucket <uuid>] [--stage <stage>]
              [--priority <p>] [--est <minutes>] [--due <YYYY-MM-DD>] [--json]

[title]              Task title, as a bare positional argument
--title <string>     Task title, as a flag (use one or the other, not both)
--bucket <uuid>      Bucket id
--stage  <stage>     backlog | todo | in_progress | ready_for_review
--priority <p>       low | medium | high
--est <minutes>      Estimated duration in minutes
--due <YYYY-MM-DD>   Due date
--json

The title can be passed positionally instead of via --title, whichever reads better in a given command:

mal tasks add "Wire up V1 PATCH for tasks" --stage in_progress --priority high --est 90

# Equivalent, with the flag form
mal tasks add \
  --title "Wire up V1 PATCH for tasks" \
  --bucket 9c449c4c-1080-451b-b6ce-d8482f763c0f \
  --stage in_progress \
  --priority high \
  --est 90

Without --bucket, the CLI tries to resolve one from the repo you're standing in (the same lookup buckets current uses, see Buckets above) before falling back to creating the task with no bucket. Resolution is best-effort: failure never blocks task creation, it just creates the task bucketless, and the CLI warns that an unbucketed ticket is off every board.

tasks ls

Lists tasks. --stage is filtered server-side via the kanban_stage query parameter (and re-applied client-side, so it still holds against an older server that ignores the param). --bucket is server-side via the bucket_id query parameter.

Without --bucket, tasks ls auto-scopes to the bucket linked to the repo you're standing in — the same cwd resolution buckets current and tasks add use — and says so ("Scoped to <bucket> (this repo)"), rather than silently returning every bucket's tasks capped at the server's newest-100 window, which used to read indistinguishably from "this project has no tickets." Pass --all to opt out and list every bucket you can see.

cd ~/dev/my-project
mal tasks ls                    # scoped to my-project's linked bucket
mal tasks ls --stage todo       # same scope, filtered to the todo lane
mal tasks ls --all              # every bucket, skip the cwd auto-scope
mal tasks ls --bucket 9c449c4c-1080-451b-b6ce-d8482f763c0f   # explicit bucket wins over cwd

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 priority

Sets a task's priority after creation via PATCH /api/v1/tasks/:id. Valid levels: low, medium, high.

mal tasks priority 7d8e... --level high

tasks edit

Edits a task's title and/or description, or reassigns it to another bucket, via PATCH /api/v1/tasks/:id. Provide at least one of --title, --desc or --bucket. --bucket takes a bucket name or id (name matching is case-insensitive and refuses when ambiguous) and moves the ticket to that board with its comments, timeline and commits intact.

mal tasks edit 7d8e... --title "New title"
mal tasks edit 7d8e... --desc "Updated description"
mal tasks edit 7d8e... --bucket "Acme Corp"

tasks reorder

Moves a task to an exact slot in its lane (or another lane) via POST /api/v1/tasks/:id/reorder. Exactly one anchor: --before <id>, --after <id>, --top, or --bottom. --stage only combines with --top/--bottom for cross-lane moves; a before/after anchor already names its lane. The server computes the lane rank against the same order the dashboard shows — never hand-set lane_position.

mal tasks reorder 7d8e... --after 557296ab
mal tasks reorder 7d8e... --top --stage todo

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

Board: the kanban board from the terminal

Where mal tasks operates on one ticket at a time, mal board works with the board as a whole and carries the repo-facing verbs: branches, commits, and comments. Every task id below accepts a unique id prefix, not just a full UUID.

board ls

Prints the board, one column per stage on a wide terminal and stacked otherwise. When you run it inside a repo that's linked to a bucket, it scopes to that bucket automatically (and says so) rather than printing every ticket you own.

--bucket <uuid>   One bucket AND its sub-buckets
--all             Every bucket (skip the cwd auto-scope)
--stage  <stage>  backlog | todo | in_progress | ready_for_review | done
--full            Add branch / commit count / progress under each card
--json
cd ~/dev/my-project
mal board ls                    # scoped to my-project's bucket
mal board ls --all --full
mal board ls --stage ready_for_review --json

board show / board timeline

board show prints one ticket in full: stage, bucket, branch, commit count, step progress, team, and the ticket body. board timeline prints its stage history newest-first — creation, every move, and the terminal done / failed / reopened event, each with who did it. A marks a backward move, which is how a review send-back shows up.

mal board show 7d8e
mal board timeline 7d8e

  2h ago       todo → in_progress · Ryan
  3d ago       backlog → todo · Ryan
  3d ago       created in backlog · Ryan

board move

Real stages patch kanban_stage; the virtual done lane patches { is_completed: true }, since done is a completed flag rather than a real lane.

mal board move 7d8e ready_for_review
mal board move 7d8e done

board tag / board attach

Tie repo work to a ticket. board tag defaults to the branch you are standing on, and the branch does not need to exist on GitHub yet: a local-only branch tags fine, and commit sync picks it up once it is pushed. --commit takes the full 40-character SHA (not a prefix) and is repeatable or comma-separated. If a commit stays unresolved it usually just hasn't GitHub-synced yet; if it never resolves, check whether the repo was renamed after being linked — a rename breaks ingest until the project's repo link is refreshed.

mal board tag 7d8e --target main                    # tags the current branch
mal board tag 7d8e --branch feat/timeline --target main
mal board attach 7d8e --commit 5453924aa1f3...

board comment

Posts a plain-language update onto the ticket — the intended way to explain repo work to non-technical teammates watching the same board.

mal board comment 7d8e "Timeline shipped — every stage change is now recorded."

Time tracking: start, stop, status, reconcile

The time-tracker subcommands wrap /api/v1/time-tracker/* and require the time:read / time:write scopes on the API key. You can run several timers at once, for example one per repo or terminal; the commands below list, target, and stop them individually.

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"

Starting a timer never fails because another one is already running, it always starts. The response carries timer_count (how many timers are now running, including this one) and a warnings array. The CLI prints an informational note for PARALLEL_TIMERS_RUNNING and a stronger one for BUCKET_ALREADY_BILLING, which means another running timer already bills the same bucket, so the overlapping wall clock is being counted against it twice.

time stop

--session <uuid>       Stop this specific timer
--bucket <name-or-id>  Stop every running timer billing this bucket
--all                  Stop every running timer
--json

With exactly one timer running, bare mal time stop still stops it, no flag needed. With several running and none of --session, --bucket, or --all passed, the CLI refuses, lists every running timer with its id, and exits 1 rather than guess which one you meant. --session <id> stops one by id, --bucket <name-or-id> stops every running timer that bills the named bucket (membership in its billed_buckets, matched by id or case-insensitive name — an exact name match wins, a substring match is accepted only when it's unique, otherwise the CLI refuses with AMBIGUOUS_BUCKET and names the candidates), and --all stops every running timer. The response surfaces the rolled-up duration in minutes plus a still_running list of any timers left going after this stop.

$ mal time stop
  Timer stopped
  duration: 47m
$ mal time stop

  Multiple timers are running, pass --session or --all:

  e8a1...  Centsure         1h 12m
  a92f...  Roanoke docs     22m

Clocking out with --all

mal time stop --all stops every running timer in one command instead of chaining N single stops — the correct verb for "clock out." It prints one outcome line per timer (stopped, already stopped, or failed) and always ends on a definitive final state line, re-checked against the server after the loop rather than inferred from it: either every timer is confirmed stopped, or the still-running ones are listed by name. The process exits non-zero unless that final state is fully clocked out, so a script can test the exit code instead of parsing the printed hours.

$ mal time stop --all
✔ 2 timers processed
  duration: 47m
  billed to ● Centsure
  id: e8a1...
  duration: 22m
  billed to ● Roanoke docs
  id: a92f...

  0 timers running — fully clocked out.

If a target can't be stopped, the final line instead lists what's still running (e.g. "1 timer still running — mal time status.") and the process exits 1. A target that another surface stopped first (a race) is reported as already stopped, not a failure — the goal state already holds.

time status

Reads GET /api/v1/time-tracker/active and lists every running timer, not just one. When you run it inside a repo linked to a bucket, the timer billing that bucket is sorted first; the rest follow. If no timer is active the call still succeeds (the list is empty).

The CLI also resolves which bucket the directory you're standing in belongs to — always, whether or not a timer is running — and reports it as cwd_bucket_status, one of three states: linked (cwd_bucket names it), unlinked (checked, and this directory genuinely has no bucket — a timer started here would be unattributed), or unknown (the check itself failed — offline, not logged in, no git — which is not a statement about linkage and must never be read as one). This three-way split replaced an older shape where a bare null meant both "no bucket" and "didn't check," which made an unattributed timer look identical to a skipped check.

$ mal time status

  2 timers running

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

  ● Roanoke docs
  22m  since 2026-05-01T15:08:11.000Z
  id: a92f...

JSON envelope

mal time status --json mirrors the API shape, with the cwd resolution folded in: entry is the timer billing the bucket you're standing in, if any, else the most recently started running timer (timers[0] === entry); timers lists all of them, cwd-first, each carrying billed_buckets (see Buckets on one session below); and cwd_bucket / cwd_bucket_status report what the current directory resolves to, independent of whether anything is running.

{
  "ok": true,
  "data": {
    "timer_count": 2,
    "cwd_bucket": { "id": "3f21...", "name": "Roanoke docs", "color": "#f97316" },
    "cwd_bucket_status": "linked",
    "entry": {
      "id": "a92f...",
      "bucket_id": "3f21...",
      "task_id": null,
      "description": null,
      "started_at": "2026-05-01T15:08:11.000Z",
      "billed_buckets": [
        { "id": "3f21...", "name": "Roanoke docs", "color": "#f97316", "elapsed_seconds": 1320, "duration_basis": "lap" }
      ]
    },
    "timers": [
      {
        "id": "a92f...",
        "bucket_id": "3f21...",
        "task_id": null,
        "description": null,
        "started_at": "2026-05-01T15:08:11.000Z",
        "billed_buckets": [
          { "id": "3f21...", "name": "Roanoke docs", "color": "#f97316", "elapsed_seconds": 1320, "duration_basis": "lap" }
        ]
      },
      {
        "id": "e8a1...",
        "bucket_id": "9c449c4c-...",
        "task_id": null,
        "description": "Pairing on V1 sweep",
        "started_at": "2026-05-01T14:18:03.000Z",
        "billed_buckets": [
          { "id": "9c449c4c-...", "name": "Centsure", "color": "#6366F1", "elapsed_seconds": 4320, "duration_basis": "lap" }
        ]
      }
    ]
  }
}

billed_buckets is the same per-session multi-bucket set documented under V1 REST API → Time Tracker — a single timer session can bill more than one bucket at once, so test membership in it rather than comparing against the scalar bucket_id. For a visual view of every session as a concurrent thread on a shared time axis (the "Weave"), see the dashboard's /time-tracker page — several timers running at once draw as parallel plateaus rather than one line.

time reconcile

Corroborates a timer that ran long, or a completed entry the server's reaper auto-closed, against local evidence: git commit activity in the entry's linked repo, this machine's Claude Code session transcripts, a grep for an issued (but maybe lost) mal time stop, and recent /tmp session activity, then proposes a corrected end time. No LLM call, so it costs nothing. With no evidence at all it proposes nothing rather than invent a number — the same discipline the sessions route's end_basis field already enforces server-side.

mal time reconcile [--apply] [--hours <n>] [--entry <id>] [--json]

--apply          Stop (if running) and correct every candidate with evidence
--hours <n>      Threshold in hours for a candidate to be flagged (default 3)
--entry <id>     Reconcile one specific completed time_entries id directly, skipping the scan
--json

Without --entry, candidates are every currently running timer past --hours, plus every completed entry from the last 14 days that is either tagged auto-reaped (see the auto-reap note under V1 REST API → Time Tracker below) or itself exceeds the threshold. Without --apply it only reports; with it, an evidenced running candidate is stopped (pinned by session id, same as time stop) and every evidenced candidate's duration is corrected through the same call time edit uses.

$ mal time reconcile

  Reconcile — threshold 3h
  ────────────────────────────────────────────────

  ● RUNNING  ● Centsure
    started 2026-08-16T14:18:03.000Z
    current: 412m (elapsed so far)
    repo path(s): /home/ryan/dev/centsure
      git last activity:      2026-08-16T18:42:11-04:00
      claude session mtime:   2026-08-16T18:44:02.000Z
      stop issued (grep):     none
      /tmp session activity:  2026-08-16T18:44:02.000Z

    proposed end: 2026-08-16T18:49:02.000Z
    proposed duration: 271m  (-141m vs current)
    not applied.

  re-run with --apply to stop/correct the ones with evidence above.

A candidate with no local evidence prints "no local evidence — proposing nothing" instead of a proposed end time. Running against a server that predates GET /api/v1/time-tracker/entries falls back to running-timer candidates only, and says so.

Ambient timer in your Claude Code statusline

Claude Code can render a custom command's output as a persistent status line at the bottom of every session. The CLI bundles a statusline script that reads mal time status --json so a running Malleable timer becomes ambient: visible in every terminal, in every repo, without ever asking. That is the whole point — a timer left running for 271 hours is what this is for.

Setup by platform

The status bar and the skill install the same way everywhere; the difference is the shell the statusline script runs in. Pick your route.

Route A — macOS, Linux, WSL

npm install -g @malleable-cloud/cli
mal login
mal skill install          # /malleable skill + /timer  -> ~/.claude
mal statusline install     # -> ~/.claude/statusline-mal.sh, prints the settings.json snippet
cd /path/to/repo && mal buckets current

Paste the printed statusLine block into ~/.claude/settings.json and restart the session. WSL counts as Linux here: install the CLI inside the distro, not on the Windows side, so mal, the skill, and the statusline all live in the same home directory that Claude Code reads.

Route B — Windows native (PowerShell / cmd)

npm install -g @malleable-cloud/cli
mal login
mal skill install          # -> %USERPROFILE%.claudeskillsmalleable
mal statusline install     # -> %USERPROFILE%.claudestatusline-mal.sh

The skill and the /timer command work as-is. The bundled statusline script is bash, so on native Windows it needs Git Bash (ships with Git for Windows); the printedstatusLine.command is already wrapped asbash "C:/Users/you/.claude/statusline-mal.sh" with forward slashes. No Git Bash? Skip the statusline for now: a native PowerShell / Node version is on the roadmap, and Claude Code can write you a small Node status-line script that callsmal time status --json in the meantime (keep it outside the skill folder somal skill install upgrades never touch it).

mal statusline install

Installs the bundled script to ~/.claude/statusline-mal.sh and chmod +xs it. A locally-customized copy that differs from the bundled one is never overwritten silently — it's backed up to .bak first. The command never edits ~/.claude/settings.json itself — wiring a statusline changes how Claude Code behaves, so that step stays yours — it just prints the exact snippet to paste in, or confirms you're already wired up if it detects a matching statusLine entry.

$ mal statusline install
  installed /home/ryan/.claude/statusline-mal.sh

Statusline script installed. One manual step left — add this to ~/.claude/settings.json:

  "statusLine": {
    "type": "command",
    "command": "/home/ryan/.claude/statusline-mal.sh",
    "refreshInterval": 10
  }

This command never edits settings.json for you: wiring a statusline changes
how Claude Code behaves, so that step stays yours.
refreshInterval: 10 repaints the bar every 10s even when the session is idle —
without it, a stopped/still-running timer only repaints on the next keystroke.
You can also ask Claude Code itself: "wire up my statusline per mal statusline install".
  script: /home/ryan/.claude/statusline-mal.sh
Requires mal >= 0.8.0 on PATH (uses `mal time status --json`).

refreshInterval: 10 is the piece added most recently: without it Claude Code only repaints the statusline on the next keystroke or tool call, so a timer that just stopped (or one still quietly running) could sit stale on screen through an entire idle stretch. With it, the bar redraws on its own every 10 seconds even in a fully idle window. On Windows the printed command wraps the script in an explicit bash invocation with forward slashes (a bare path won't execute a .sh by itself), and the whole snippet is JSON-encoded so a raw backslash path can never corrupt settings.json.

Once wired, the bar shows the model, the current directory, and — when a timer bills the bucket linked to this repo — the bucket name (colored to the bucket's own hex) and elapsed time in place of the plain directory name. It also renders a standalone warning segment for any timer anywhere that has run past a configurable threshold without a linked repo showing recent activity, so a forgotten clock in a different terminal doesn't stay invisible. The full time status --json shape it reads is documented above under Time tracking.

Start a new Claude Code session (or restart the current one) after adding the snippet and the timer shows up at the bottom of the screen, refreshing on its own. Stop the timer from the same session with mal time stop, or, if you've installed the bundled /timer slash command (mal skill install, see above), ask Claude directly.

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.