Skip to main content

luge-cli

CI PyPI Python

A command-line client for the Luge AI-employee platform — built to let an agent (Codex or Claude Code) drive Luge: work kanban boards, run scheduled tasks and workflows, chat in channels / DMs / with AI agents, manage artifacts, personal skills, and inbound webhooks, and read/update tenant settings.

Commands are grouped by noun: board and card (read + work a board), todo (your personal todo list) and plan (compose a day plan), schedule (recurring agent tasks), workflow + activity (run and inspect agent workflows and their runs), notification (your notification-center inbox, and notification send to drop a line into it from a script or an unattended run), channel / dm / agent / colleagues / presence (group chats, direct messages, talking to an AI, the people directory, and your own presence), artifact (conversation deliverables), skill (authored Luge skills), webhook (inbound webhook endpoints), settings (tenant config), auth (local credentials), and profile (switching between saved backends). No card deletion, no column management.

luge-cli mcp serves that same surface to an MCP client over stdio, with the tools generated from the CLI itself — see MCP server.

Install

From PyPI:

uv tool install luge-cli     # recommended — installs the `luge-cli` command globally
# or: pipx install luge-cli
# or: pip install luge-cli

Then install the bundled skill for the agent that will drive the CLI:

luge-cli claude skill install   # ~/.claude/skills/luge-platform
luge-cli codex skill install    # ${CODEX_HOME:-~/.codex}/skills/luge-platform
luge-cli hermes skill install   # ${HERMES_HOME:-~/.hermes}/skills/luge-platform
luge-cli kimi skill install     # ${KIMI_CODE_HOME:-~/.kimi-code}/skills/luge-platform
luge-cli openclaw skill install # ${OPENCLAW_STATE_DIR:-~/.openclaw}/skills/luge-platform
luge-cli qwen skill install     # ~/.qwen/skills/luge-platform
luge-cli cursor skill install   # ~/.cursor/skills/luge-platform
luge-cli opencode skill install # ${XDG_CONFIG_HOME:-~/.config}/opencode/skills/luge-platform
luge-cli cline skill install    # ~/.cline/skills/luge-platform
luge-cli kilo skill install     # ~/.kilo/skills/luge-platform (or ~/.kilocode, if that is where yours live)

With uv tool, make sure uv's bin dir is on your PATH (once): uv tool update-shell.

Then upgrade with luge-cli upgrade: it runs your own installer's upgrade (uv tool, pipx or pip — read off the receipt beside the interpreter, not guessed) and then rewrites every copy of the skill that already exists on the machine, which uv tool upgrade luge-cli alone does not. luge-cli upgrade --check reports what is installed and which copies are out of step; --skill-only refreshes the copies without fetching anything.

You do not have to remember to check. Every request carries the CLI's version (User-Agent: luge-cli/X.Y.Z … and X-Luge-Cli-Version), so a deployment that knows a newer release exists answers X-Luge-Cli-Latest on whatever call was being made — and the CLI prints one line on stderr, at most once a day, JSON under LUGE_CLI_JSON so an agent parses it. No index is polled and no third party is contacted; LUGE_CLI_UPGRADE_NOTICE=0 turns the line off.

Installed editable (make install-cli), the code is live but the version is not: luge-cli --version reads the package metadata written at install time, so it keeps reporting the old number until you re-run make install-cli (which make release now does for you). Only the reported version is stale — the commands themselves are whatever is on disk.

From source (development)

cd luge-cli
make install        # editable uv tool install + the Codex and Claude Code skills

--editable means git pull updates the command with no reinstall. make help lists every target.

Configure

Needs the Luge API base URL and a tenant API key (luge_…). The base URL is the full API base, including any deployment prefix (usually /api).

luge-cli auth init --url https://luge.example.com/api --token luge_xxx
luge-cli auth show    # verify: URL, masked token, and who the key speaks as

auth show asks the server whose key this is and prints the name, the organization (owner or member) and how many capabilities the key effectively holds; auth show --json lists them, so a preflight reads .identity.name and .capabilities from one call. A refused key or an unreachable server reads in the output — the config still prints, error says why — and the command exits 1.

Without a key to paste, pair the machine in a browser instead:

luge-cli auth login --url https://luge.example.com/api

It asks the server for a pairing and prints a link plus a short code — approve it in a browser (or enter the code from a phone) and the key the server mints for that approval is written into the profile. The key is scoped to a snapshot of your capabilities, shows up in auth key list under this machine's name, and revoking it there ends this CLI's access. Nothing is written if the pairing is refused or expires. A machine with no human at a browser — CI, an unattended agent — still uses auth init.

No Luge account yet is not a different command. Signed out, the same link asks you to sign in or to create an account, and the approval screen is where you land once you are through — a new account means signing up and going through the setup first, all in that one tab. The command keeps waiting for as long as the pairing lives, so there is nothing to come back and re-run.

Narrower keys are minted with auth key create --scope <capability>, and auth key capabilities lists the vocabulary those scopes are drawn from — grouped by domain, and needing an admin key (role.manage) since it is the role editor's endpoint. A misspelled scope earns a 400 naming the bad one and nothing else, so it is the listing that turns narrowing a key from guesswork into a lookup.

LUGE_URL / LUGE_TOKEN environment variables override the stored file. The config lives at ${XDG_CONFIG_HOME:-~/.config}/luge-cli/config.toml, written 0600 — never commit it.

A third variable, LUGE_EXTERNAL_TOKEN, carries an external collaborator's session instead of a member's API key — see Being the guest. It replaces the key rather than joining it, and it is sticky: while it is set, every command speaks as that guest. unset LUGE_EXTERNAL_TOKEN goes back to your own key, and auth show names whichever of the two will actually be sent.

Profiles

Credentials are stored under named profiles, so a dev and a prod backend can coexist and you switch instead of retyping:

luge-cli profile add dev  --url http://localhost:8000/api --token luge_dev_xxx
luge-cli profile add prod --url https://luge.example.com/api --token luge_prod_xxx

luge-cli profile list                     # all profiles, `*` marks the active one
luge-cli profile use prod                 # switch the persistent default
luge-cli --profile dev board list         # one-off override (flag goes before the subcommand)
LUGE_CLI_PROFILE=dev luge-cli board list  # per-shell/session override

Selection precedence: --profile > LUGE_CLI_PROFILE > profile use > the sole stored profile. LUGE_URL / LUGE_TOKEN still override whatever profile was selected, field by field. auth init stays the first-run bootstrap: it writes whichever profile the run targets (default on a fresh machine). A config file from before profiles existed keeps working — it reads as a profile named default and is rewritten in the new shape on the next write.

Output for agents and scripts

Every command that prints data takes --json. LUGE_CLI_JSON=1 turns it on for all of them at once, so an agent or a CI job sets it once instead of remembering a flag on every call:

export LUGE_CLI_JSON=1
luge-cli table row list Expenses      # JSON, no flag
luge-cli table row list Expenses --json   # same, explicit
LUGE_CLI_JSON=0 luge-cli table list # human view, whatever the environment says

This matters more than it looks: the human views are grids that fit themselves to the terminal and clip what does not fit — and outside a terminal that width is 80 columns, so a piped table row list silently ellipsizes long cells. --json is never truncated. If something reads the output rather than a person, give it JSON.

(It is LUGE_CLI_JSON, not LUGE_JSON: LUGE_* is the platform's own namespace — this only concerns the CLI's output.)

Saying an AI wrote it

A second switch belongs in the same export block when an agent is driving: LUGE_CLI_AS_AGENT=1 marks every message it sends as written by an AI. The message keeps the key holder's identity — same sender, same name — and the conversation records who composed it, so a colleague reading a DM or a channel sees an AI tag instead of assuming a person typed it.

export LUGE_CLI_AS_AGENT=1                        # a whole agent session
luge-cli dm send "Alex" "deployed, all green"     # marked AI-written
luge-cli dm send "Alex" "on my way" --as-human    # relaying Alex's colleague verbatim
luge-cli channel post general "build is red" --as-agent   # one message, no export

It is declared, never inferred: the platform does not read an API key as an AI (a key is as likely to belong to a deploy script), so a message carries no marker unless the sender asks for one. Nothing sent before this existed changes — no marker reads as human, which is what it always was. Reading back, dm show / channel messages tag those messages [AI], and --json carries author_kind.

luge-cli mcp turns it on by itself: only a model calls a tool on that transport. A host that drives the server from a script can say LUGE_CLI_AS_AGENT=0 and keep its own answer.

A second nicety is on by default: after any command that talked to the API, one line on stderr flags unread notifications (🔔 3 unread notification(s) — luge-cli notification list). In JSON mode — --json on the command or LUGE_CLI_JSON=1 — the bell matches the form: {"unread_notifications": 3, "hint": "luge-cli notification list --unread --json"}. The streams stay separate on purpose: stdout is the command's JSON document, stderr is the bell's — each parses on its own; don't merge them with 2>&1 before parsing. It rings only after successful, networked commands, never for the notification group itself (you are looking at the inbox), and any failure of the probe is silent — it can never break a command or pollute piped/--json stdout. Acknowledging (notification ack) is what stops the re-alert.

Turning it off, most specific wins: --no-notify (root flag, one run) > LUGE_CLI_NOTIFY=0 (shell/session) > luge-cli notification disable (persisted in the config file; notification enable restores the default).

Use

Commands are grouped by noun (board, card, auth, skill):

luge-cli board list                                  # boards you can access
luge-cli board show Roadmap                          # columns + cards (id or name substring)
luge-cli board show Roadmap --limit 50               # show more of each column
luge-cli board create Roadmap -d "Product roadmap"   # new board, seeded with default columns
luge-cli board create Feuille -l fr                  # seed the columns in French (À faire…)
luge-cli board create "Dev team" --prefix DEV        # the card prefix at creation (1-6 letters or digits)
luge-cli board update Roadmap --name "Q3 Roadmap"    # rename, describe, or set the card prefix
luge-cli board update Roadmap --prefix RDM           # only renumbers cards created from then on
luge-cli board update Roadmap --icon 🧲 --color amber # its identity: one emoji or hg:<name>, a palette colour
luge-cli board update Roadmap --icon "" --color ""     # back to the prefix monogram on its derived hue
luge-cli board history Roadmap                       # audit trail: the board's own facts + its cards'
luge-cli board overview --days 14                    # across every board: where to act, what moved, who moved it
luge-cli board activity --board Roadmap --kind card  # the feed, bursts folded ("assigned ×129")
luge-cli board archive Roadmap                       # freeze it read-only; the cards stay
luge-cli board list --archived                       # what is frozen (the default list hides it)
luge-cli board unarchive Roadmap                     # writable again, back in the active list
luge-cli board delete Roadmap                        # destroys the board and every card on it (asks first)
luge-cli board column list Roadmap                   # the stages, in rank order, with their WIP limit
luge-cli board column create Roadmap Next --position 1 --wip-limit 25   # a created column lands last unless placed
luge-cli board column create Roadmap Doing --auto-status in_progress    # the rule rides the create call itself
luge-cli board column update Roadmap Next --wip-limit 0   # 0 removes the cap; --color "" clears the colour
luge-cli board column update Roadmap Doing --auto-status ""   # removes the rule; the column stamps nothing
luge-cli board column move Roadmap Next 3            # neighbours shift; a rank past the end clamps
luge-cli board column delete Roadmap Next            # empty columns only (archived cards count), asks first
luge-cli card list Roadmap --status open --tag bug   # filter by tag/color/column/assignee/priority
luge-cli card show ROL-17                            # one card + comment thread + artifacts
luge-cli card history ROL-17                         # who did what to the card, newest first
luge-cli card mine                                   # cards assigned to you (one request, server-counted)
luge-cli card search "login" --status open           # whole words across your boards, ranked by the server (tags: --tag)

luge-cli card create Roadmap "In progress" "Fix login bug" --priority high --tag bug
luge-cli card create Roadmap "To do" "Sub-task" --parent ROL-17  # grouped at creation
luge-cli card comment ROL-17 "Picking this up"
luge-cli card move ROL-17 "In progress"
luge-cli card move-board ROL-17 "Roadmap"            # transfer to another board (card is renumbered)
luge-cli card set-parent ROL-18 ROL-17               # group ROL-18 under ROL-17 (same board)
luge-cli card remove-parent ROL-18                   # detach it again
luge-cli card reorder-children ROL-17 ROL-20 ROL-18  # set the sub-cards' manual order
luge-cli card update ROL-17 --status in_progress      # the state you declare (not the completed flag)
luge-cli card done ROL-17                            # sets the completed flag (not the column)
luge-cli card reopen ROL-17
luge-cli card archive ROL-17                         # off the board, nothing deleted (not the done flag)
luge-cli card list Roadmap --archived                # what is put away (the default list hides it)
luge-cli card unarchive ROL-17                       # back in the column and position it left
luge-cli card delete ROL-17                          # destroys it (sub-cards survive, detached); --yes skips the prompt

luge-cli card attach ROL-17 ./report.pdf             # upload a local file and link it to the card
luge-cli card attach ROL-17 --document loi25          # link a document already in the corpus (id or name)
luge-cli card detach ROL-17 3                        # detach attachment #3 (or its link id)
luge-cli card read ROL-17 1                          # read attachment #1's content
luge-cli card read ROL-17 1 -o ./rapport.pdf         # write it out; --force to overwrite

luge-cli <group> <command> --json                    # structured output for scripts/agents

A card is named by the display_id every listing prints (ROL-17) or its uuid — its board is worked out for you, so you can paste back what you just read. Add --board to name a card by a title substring instead (card show "login" --board Roadmap), or to settle the rare case of two boards issuing the same id.

card list and card create still take a board first: they act on a board, not on a card.

Boards, columns and (with --board) card titles are referenced by id or a unique name substring; an ambiguous reference is a loud error, never a silent guess.

A board is retired in one of two ways, both owner-or-manager only. board archive freezes it read-only and takes it out of the listings, cards kept and board unarchive undoing it. board delete is final: every card goes with the board — its comments, tags and attachment links included. Because a board is named by a substring, the board is read first so the prompt can name it and count its cards before you answer; --yes skips that prompt for scripts.

An archived board is out of board list, card mine and card search until it comes back — board list --archived is the view that shows it. unarchive and delete resolve a name against the archived boards too; every other command reads the active listing.

A card carries two states, and they are not derived from one another. --status (todo | in_progress | paused | done) is what the worker declares — "I finished" — and card done is the validation of that work, the completed flag. A card can sit at status=done for days before anyone signs it off, which is why card show prints them under their own names (completed=no status=done). A card list row marks the status only when it has left the default todo, spelled [status=done] so the declaration is never mistaken for the (done) of the flag beside it.

A column can stamp the status for you: board column update <board> <col> --auto-status in_progress sets the rule, and every card landing in that column is stamped with it. The cards already sitting there are not restamped — the rule applies on arrival — and --auto-status "" removes it. board column list is the only place a rule can be read: it shows on the cards, never on the stage that applied it. This is also why card create has no --status: the destination column's rule would overwrite the value at insertion.

A single card retires the same way: card archive ROL-17 takes it off the board without deleting it, keeping the column and position card unarchive ROL-17 puts it back into. card list <board> --archived is where it can still be read — the two views select rather than overlap, so a card is in one or the other and never both — and a listed archived card is marked (archived). Its display id keeps working throughout: card show ROL-17 answers for an archived card rather than denying it exists. Archiving is not card done: done leaves the card on the board with its completed flag set, archiving takes it off the board.

card delete ROL-17 is the irreversible one: the card, its comments and its tag links go, while its sub-cards survive detached — the prompt names them before you answer, and --yes skips it. It reaches an archived card too, which with card unarchive is all the archives accept.

Todos and day plans

todo is your personal todo list — the standalone kind, the same rows kanban cards are made of but not placed on a board (cards stay in the card group; todo list --cards is the one read that crosses over). todo tag curates the tenant-wide tag vocabulary those cards and todos share.

luge-cli todo list --open --mine              # your open todos (--done, --cards, --limit)
luge-cli todo show "bank"                     # id or title substring
luge-cli todo create "Call the bank" --priority high --tag admin --due 2026-08-05
luge-cli todo update "bank" --notes "ask for Mr Roy" --due ""    # "" clears the due date
luge-cli todo done "bank" ; luge-cli todo reopen "bank"
luge-cli todo delete "bank"                   # (asks to confirm; -y to skip)
luge-cli todo tag list                        # the tenant vocabulary, most-used first
luge-cli todo tag create bug --color red      # idempotent on the name
luge-cli todo tag update bug --color sky      # recolours it on every card that links it

plan composes a day plan — the Planification surface. A plan holds schedulable entries (todos and free --note blocks) plus materials (documents and tables, which carry no time slot). Days read as today / tomorrow / yesterday / YYYY-MM-DD; times read as HH:MM local wall clock on that day (the CLI converts to a timezone-aware timestamp for the API — a full ISO datetime also works).

luge-cli plan show                            # today's plan (or a day, or a shared plan's id)
luge-cli plan days --from today --to 2026-08-31   # the days that have a plan
luge-cli plan add --todo "bank" --start 09:00 --end 09:30    # schedule a todo today
luge-cli plan add tomorrow --note "Deep work" --notes "spec review" --start 13:00 --end 15:00
luge-cli plan add --todo ROL-17               # a kanban card is a todo too
luge-cli plan add --document loi25            # material: alongside the day, no slot
luge-cli plan propose tomorrow --entry "todo=ROL-17 start=09:30 end=12:00" \
    --entry "note='Deep work' start=13:00 end=15:00" --note "why these"   # park the day for validation
luge-cli plan propose tomorrow --file entries.json   # the API's own entry list (or - for stdin)
luge-cli plan update <entry-id> --start 14:00 --day tomorrow  # reschedule (HH:MM needs its day)
luge-cli plan update <entry-id> --unschedule  # clear the slot, keep the entry
luge-cli plan remove <entry-id>               # the todo/document itself is untouched

The first plan add on a day creates its plan; re-adding the same resource returns the existing entry instead of duplicating it. plan show prints each entry's id — that is the handle update / remove take — and, for a card, its board column and the run at work on it (status, title, and the execution_id that activity show takes as is).

plan propose writes nothing: the whole composed day is parked as one approval task in the HITL inbox, and approving it applies the entries — the propose mode of the daily-planning automation, from outside the platform. Each --entry is key=value tokens (shell quoting groups words): one source among todo=, document=, table=, note=<label>, then notes=, start=, end=; --file takes the API's own list (source_type is todo | document | dataset | note). Then stop: hitl show <task_id> follows the decision, and an approved plan needs no plan add.

The daily-planning automation materializes a personal copy of the planning skill and a daily scheduled task that composes your day (LUG-303 in the app):

luge-cli plan automation status
luge-cli plan automation enable --time 07:30 --mode propose   # propose = you validate each plan
luge-cli plan automation enable --mode direct                 # write the plan without asking
luge-cli plan automation disable                              # task kept, never deleted

Calendar

calendar events [day] reads the meetings of one day from the calendars connected to the account (Google, Microsoft), in local time — today by default, tomorrow, yesterday or YYYY-MM-DD:

luge-cli calendar events                   # today's meetings, local wall clock
luge-cli calendar events tomorrow --json   # the API's events, start/end in local ISO, beside day and providers

The CLI turns the day into the API's [start, end] window itself, so nothing upstream computes ISO bounds. An account with no connected calendar answers an empty list and says so — not an error.

Tables

A table is a small typed data store — the "Tables" surface in the app. table manages the table and its columns; table row reads and writes the data. The rows sit under the table rather than beside it because a row has no id you can paste on its own — the API addresses one as /datasets/{id}/rows/{row_id}, so every row command names a table. (A card earns a group of its own by naming itself: card show ROL-17. A row never can.)

luge-cli table list                                  # tables you can see
luge-cli table show Expenses                         # column spec + row count
luge-cli table create Expenses \
        --column 'label:text!' \
        --column 'amount:number|Amount (CAD)' \
        --column 'status:select!=open,paid' \
        --column 'due:date' \
        --column 'who:user=$user'
luge-cli table update Expenses --name Costs -d "Team costs"
luge-cli table delete Expenses                       # rows included (-y to skip the prompt)

luge-cli table row list Expenses                           # every row, paginated
luge-cli table row list Expenses -w 'status=open' -w 'amount>20' --sort amount --desc
luge-cli table row show Expenses 48b3e75d                  # one row in full, a cell per line
luge-cli table row create Expenses -s 'label=Taxi' -s 'amount=42.50' -s 'status=open'
luge-cli table row update Expenses 48b3e75d -s 'status=paid'   # short id, as printed
luge-cli table row update Expenses 48b3e75d --clear due        # empty an optional cell
luge-cli table row delete Expenses 48b3e75d

# Relational columns — pass the whole spec as JSON (link / lookup / rollup)
luge-cli table create Affaires --columns-json '[
  {"key":"titre","label":"Titre","type":"text","required":true},
  {"key":"entreprise","label":"Entreprise","type":"link","target_dataset_id":"<uuid>","multiple":false},
  {"key":"dom","label":"Domaine","type":"lookup","via":"entreprise","target_field":"domaine"}
]'
luge-cli table row create Affaires -s 'titre=Renouvellement' --link-by 'entreprise=nom:Acme'
luge-cli table row create Affaires -s 'titre=Onboarding' -s 'entreprise=["<row-uuid>"]'

A column is key:type, plus ! to make it required, =a,b,c for a select's options, =$variable to have Luge fill it in, and |Label to name it (the label defaults to a readable form of the key — unit_price → "Unit price"). Types: text, number, date, boolean, select, user.

spec means
title:text! required text
amount:number|Amount (CAD) optional number, explicit label
status:select!=open,paid required select (the ! rides on the type)
who:user=$user filled with whoever inserts the row
logged:date=$datetime filled with the insert time

Variable columns ($user, $display_name, $datetime, $email) are filled in by Luge, so table row create never asks for them.

Relational columns — link, lookup, rollup — carry three or four fields each, more than the compact grammar holds, so pass the whole spec as JSON with --columns-json '[…]' or --columns-file spec.json (the array the API validates, sent through untouched — so a column type added later works with no CLI change). The three column sources are mutually exclusive; each JSON form is the whole spec.

type JSON fields (beyond key / label / type)
link target_dataset_id (uuid), multiple (bool, default true)
lookup via (a link column's key), target_field (a key on the linked table)
rollup via, agg ∈ count|sum|avg|min|max|concat, target_field (unless agg=count)

A link cell is a list of target row ids. Write it with a bare id (-s 'entreprise=<uuid>') or a JSON array (-s 'entreprise=["<uuid>","<uuid>"]'), or name the target by a business value with --link-by 'entreprise=nom:Acme' — Luge matches that against the linked table (field == value) and errors if nothing matches. --link-by is one per link column; for several explicit targets use -s 'entreprise=["<uuid>","<uuid>"]'. lookup and rollup cells are computed by Luge, so --set never writes them. table row list / show print a link cell by the linked row's name (from the response's link_labels), and table show summarizes each relational column's wiring.

Cells are typed by that spec: --set amount=42 sends the number 42, and a value the column cannot hold is refused before the request goes out. --where takes key=value, key!=value, key~text (contains), and key>n / key>=n / key<n / key<=n on numbers and dates; filters AND together. Quote any filter using < or > so the shell does not read it as a redirection.

table row list is a grid, so it fits its cells to the terminal; table row show reads one row whole, a cell per line, without reaching for --json. A row is named by the short id the grid prints (a unique prefix is enough) and never by its position in a listing — a rank depends on the filters that produced it.

table update --column replaces the whole spec — pass every column you keep. A kept key keeps its type, and dropping a column hides its data (it returns if you re-add the key), so the CLI names what would be dropped and asks first. table show prints the current spec.

Reads cover your own tables plus tenant-visible and shared ones; writes act on tables you own. Sharing is managed in the app.

Scheduled tasks

A scheduled task is a recurring prompt that fires an agent run. Manage them under luge-cli schedule:

luge-cli schedule list [--enabled]                         # tasks you can see
luge-cli schedule show "Daily digest"                       # one task (id or name)
luge-cli schedule create "Daily digest" --prompt "Summarise open cards" \
        --every daily --at 09:00                            # see --every below
luge-cli schedule update "Daily digest" --every weekly:mon,fri --at 08:30
luge-cli schedule create "Nightly digest" --prompt "…" --every daily --retry 1   # never replayed
luge-cli schedule update "Daily digest" --retry 3 --backoff 60000     # the retry policy alone
luge-cli schedule toggle "Daily digest"                     # pause / resume
luge-cli schedule run "Daily digest"                        # trigger a one-off run now
luge-cli schedule delete "Daily digest"                     # (asks to confirm; -y to skip)

--every is a compact recurrence spec (the API uses structured recurrence, not cron): daily, hourly, weekly:mon,wed,fri, monthly:15, interval:4h, interval:2d, once:2026-08-01T18:00. --at HH:MM sets the time(s) of day (repeatable; defaults to 09:00). --retry <n> (1 to 10) and --backoff <ms> set the task's retry policy — how many attempts a failed run gets and the base delay between them; --retry 1 is the setting for a run that must never be replayed. schedule show says it in words (retry=none, retry=3 attempts, backoff 60s), and an update passing one knob keeps the other. A run's history/result isn't on the task — a triggered run surfaces under luge-cli activity (see below).

Workflows and activity

luge-cli workflow inspects, authors and runs multi-step agent workflows:

luge-cli workflow list [--trigger-type event] [--enabled]   # workflow definitions
luge-cli workflow show "veille-techno"                        # one definition (id or name)
luge-cli workflow create "Greeter" --agent "General Agent" --prompt "Say hi and the time"
luge-cli workflow create "Complex" --graph ./graph.json --trigger-type event
luge-cli workflow update "Complex" --graph ./graph.json      # only passed fields change
luge-cli workflow update "Relances" --enable                 # or --disable, --name, ...
luge-cli workflow delete "Greeter"                            # (asks to confirm; -y to skip)
luge-cli workflow trigger "veille-techno" --data topic=rust   # start a run (repeatable --data)
luge-cli workflow runs "veille-techno"                        # a workflow's run history
luge-cli workflow run "veille-techno" <run-id>                # one run in full, with its audit timeline
luge-cli workflow cancel "veille-techno" <run-id>             # cancel a running run
luge-cli workflow retry  "veille-techno" <run-id>             # retry a failed/cancelled run

create --agent <id|name> --prompt "..." builds a linear single-agent workflow (start → agent → end) for you; create --graph <file.json> posts an arbitrary roomkit-graph you supply (for branches, human-review, notifications, etc.). update patches only the fields you pass; --graph replaces the graph as a whole — workflow show <ref> --json, edit the graph object, pass it back.

luge-cli workflow node addresses a graph node by node, so changing one node's config no longer means round-tripping the whole graph:

luge-cli workflow node list "test table"                       # id, type, config keys of each node
luge-cli workflow node show "test table" function-1784         # one node whole (id or unique prefix)
luge-cli workflow node update "test table" function-1784 --set code=@snippet.js
luge-cli workflow node update "test table" function-1784 --set timeout_ms=5000  # typed by the node schema

node update patches only the keys you --set (repeatable): it fetches the graph, merges the change into the one node's config, and PUTs the graph back (the server validates it as a whole — there is no node endpoint). Each --set is typed against the node type's config schema the way a row's cells are typed against its column spec, so timeout_ms=5000 leaves as a number, not "5000". Use key=@file to read a value from a file — the way to carry a function node's multi-line code; an undeclared key travels as a plain string.

An event trigger fires the workflow off another surface. Pass --trigger-type event with a --trigger-config JSON that names the source_type (workflow, scheduled_task, webhook, board, or data_table) and, optionally, filters — e.g. fire on a new/updated row in a Luge table:

luge-cli workflow create "On new order" --agent "Ops" --prompt "Handle it" \
  --trigger-type event \
  --trigger-config '{"source_type":"data_table","source_data_table_id":"<table id>","events":["row_created"]}'

luge-cli activity is the cross-surface run inbox — every run (schedule, workflow, webhook, notetaker, …), for diagnosing what fired and what failed:

luge-cli activity list [--status failed] [--kind schedule] [--search "..."]
luge-cli activity show <id>       # a run's message thread, human-review steps + audit timeline
luge-cli activity retry <id>      # re-run a failed/cancelled run
luge-cli activity stats [--days 7]

show / retry take either id a run carries: the id from activity list (its chat room) or the execution_id — the one schedule run prints — so you can paste back whichever id you were given. Workflow runs have no chat room; inspect those with workflow runs instead.

Notifications

luge-cli notification reads your notification-center inbox — mentions, card assignments, run failures, meeting summaries, membership changes. The API key sees the inbox of the user who owns it.

luge-cli notification list                 # newest first; ● marks unread
luge-cli notification list --unread --json # what an agent should consume
luge-cli notification ack <id>             # mark one read (id from `list`)
luge-cli notification ack --all            # clear the whole inbox

The --unread filter applies within the fetched page (the API has no server-side unread filter) — raise --limit (max 100) or page with --offset to see more. The verb is ack, not read: card read already means display content, and acknowledging is a write. enable / disable persist the after-command bell switch (see above). The agent loop: bell on stderr → list --unread --json → handle → ack — acknowledging is what stops the re-alert.

Delivery channels — where results reach you

notification is the inbox; notify is the outbound side: the routes that carry an automation's result out to its owner (Telegram, email, Teams/Slack webhooks), the same ones configured under My Channels in the app.

luge-cli notify list                                 # the routes, and the destinations still unconfigured
luge-cli notify add telegram                         # a channel name or a label substring from that listing
luge-cli notify add email --approval                 # a human confirms before it sends
luge-cli notify add email --template "Short result"
luge-cli notify remove telegram

luge-cli notify template list
luge-cli notify template create "Short result" \
        --title "{{automation_name}}" --body "{{body}}"
luge-cli notify template update "Short result" --title "{{title}}"
luge-cli notify template delete "Short result"     # one in use cannot be deleted

Each route reports enabled, approval, and available. not connected means the route is configured but nothing backs it any more (Telegram unbound, the group left), so deliveries to it are being lost — worth acting on rather than working around. These are the user's own defaults, so treat them as settings: change them when asked, and say what changed.

A template overrides a route's title and body. The variables are a closed vocabulary — {{title}}, {{body}}, {{automation_name}}, {{date}}, {{user}} — and anything else is a 400 at write time. A placeholder that does not resolve is delivered verbatim on purpose, so drift stays visible.

Human-in-the-loop tasks

luge-cli hitl is the ask_human inbox — the questions and approvals agents park for a human. The CLI can ask and read; it can never answer or cancel: answering is the human's consent, and a CLI credential is routinely held by unattended automation. Answering happens in the Luge UI.

luge-cli hitl list [--status responded]      # what waits (with pending_count), or the history
luge-cli hitl show <id>                      # one task whole: prompt, options, response
luge-cli hitl create "Does the plan hold?" -t "Plan LUG-42" --wait 900 \
        --asked-by "luge-ticket LUG-42"      # park your own question, wait up to 15 minutes
luge-cli hitl create "Which one?" -t "Choice" --kind choice -o "A" -o "B"
luge-cli hitl show <id> --wait 1800 --json   # the run that comes back polls the same task
luge-cli hitl create "Server or mobile?" -t "MOB-185" --dedupe-key "<run id>:MOB-185"
luge-cli hitl hide <id> | hide --all         # drop the inline chat card; the task stays pending

An elapsed --wait is not a failure: the exit code stays 0 and the question stays live in the inbox. --dedupe-key makes the ask replayable, which is what an unattended run needs: the same key asks once, and a replay answers with the ask already parked (its answer included, if the human gave one) instead of filing the same question twice. The key never expires, so once an ask settles unanswered the question needs a new key. create and show answer one --json shape, {task_id, answered, status, task}, with or without --wait — gate on answered (true only when the human responded; a timeout or cancelled task keeps false beside its status) and read the task itself under task.

Exporting your own data

export takes a copy of everything the platform holds about the person whose key is in use — conversations, notes, todos, projects, tables, plans, meetings, memories, skills, preferences, and the rest — as one ZIP with a JSON file per category plus the real files (notes as .md, transcripts, attachments).

The build is asynchronous, and no command here waits on it:

luge-cli export request                    # opens a request; one per 24h
luge-cli export request --recordings       # bundle meeting and capture audio (large, off by default)
luge-cli export list                       # where each request is, and what it carries
luge-cli export show                       # the archive's categories and counts
luge-cli export download -o ~/Downloads    # the newest completed archive, streamed to disk

luge-cli export backup status              # the SCHEDULED cloud copy: cadence, destination, last run
luge-cli export backup run                 # trigger one now, with the saved settings

show answers "is my X in there?" without downloading anything. download takes an id prefix and defaults to the newest completed request; the file expires a few days after it is built, and an expired one is a fresh request away. Secrets never travel: credentials and destination addresses are projected out server-side, not filtered here.

export backup reads the scheduled cloud copy of that same archive, including the error when the last run failed. Choosing a destination is a folder picker and stays in the app; a backup aimed at a cloud the account is no longer connected to never runs, and the status says so.

Plan and usage

billing answers what the plan allows; usage answers what was actually spent.

luge-cli billing status            # plan, seats, and each gauge as used / limit + state
luge-cli billing plans             # every tier with its real limits, current one marked
luge-cli billing storage           # what fills your share of the quota, largest first
luge-cli usage summary --days 7    # requests, tokens, cache hit rate, cost
luge-cli usage daily --days 30     # the day-by-day curve
luge-cli usage by-model            # split by provider and model
luge-cli usage by-user             # split by the person it was spent for
luge-cli usage by-agent --all      # all agents, recorded models, and unattributed activity
luge-cli usage by-user --sort credits --limit 20
luge-cli usage by-model --agent <uuid> --export models.csv
luge-cli usage by-conversation --user <uuid> --all
luge-cli usage budget              # monthly limit, spend so far, alert state

A limit of null means the plan does not bound that axis — printed as unlimited, and its state is none. Only storage is enforced; the daily request rates warn without blocking. --days is bounded 1-365 by the API and refused outside that, never clamped.

All analytical verbs share --from / --to (inclusive/exclusive ISO timestamps with an offset), --timezone (IANA), --user, --agent, --conversation, --model (the visible model or level), --provider (the visible brand), and --access-type. Use unattributed for a missing identity. --sort chooses credits, tokens, requests, api_metered, subscription_equivalent or unknown; --direction, --limit (1–500) and --offset control the server ranking. The response keeps full totals and the subtotal outside the current page. --all and --export <file.json|file.csv> traverse every page on fixed server bounds, retain numeric precision, and fail if the dataset changes during the export. These require the server pagination contract. usage budget always reads its own calendar month; analytical filters do not change it. ACP reports remain whole-session observations, separately dated, never summed as window spend.

billing status / billing plans need tenant.manage; usage needs token_usage.view.

Settings

luge-cli settings reads and updates tenant configuration. Most of it needs an admin-role API key — a member key gets 403 on the memory/pii/compliance/ web-search slices and on credentials; the agent reads, providers and the tenant read are member-safe.

luge-cli settings get memory                      # memory | pii | compliance | web-search | tenant
luge-cli settings set web-search web_search_provider=brave   # partial patch (only keys you pass)
luge-cli settings set memory rag_max_chunks=8 summarization_enabled=true
luge-cli settings agent list                      # the tenant's agents (provider/model live here)
luge-cli settings agent show "General Agent"      # one agent's config (id or name)
luge-cli settings agent create "Scratch" provider=gemini_vertex
luge-cli settings agent set "General Agent" provider=gemini_vertex model=gemini-3.5-flash
luge-cli settings agent delete "Scratch" -y --json  # asks first unless -y; reports what it entailed
luge-cli settings agent list --mine               # the agents running on my own computer
luge-cli settings providers                       # AI provider/model catalogue
luge-cli settings models gemini_vertex location=northamerica-northeast1

set values are typed: true/false → bool, numbers → int/float, json:[...] or json:{...} for lists/objects, anything else a string.

An agent is configuration, so it is governed where the rest of the configuration is: settings agent is a group like settings credential and settings integration. Talking to an agent is the other thing entirely, and lives under agent send.

settings agent set patches one agent the way set patches a slice — only the keys you pass are written, under the API's own names (provider, model, system_prompt, max_output_tokens, region to pin where inference runs). A key the API does not know is ignored rather than refused, so read the agent back to confirm the write landed.

settings agent create takes the name and nothing else if you like — the API's defaults fill the rest, and the slug it requires is derived from the name unless you pass slug=. settings agent delete is destructive at a distance: the rooms that held the agent lose their binding and addressee, and a deleted default leaves its role unfilled — a default embedder takes document indexing and recall with it. Those consequences are named in the confirmation prompt (skip it with -y), and --json returns {deleted, effects}, where deleted is the agent as it was read a moment before, the API's 204 carrying nothing.

--mine on any of the five verbs swaps the tenant's fleet (agent.configure) for the agents running on your own computer (agent.manage_own): private to you, slug derived server-side, and a narrower set of settable keys. Narrower is not stricter — a key outside that set is dropped and the API still answers 200, so read the agent back here as well (provider= is the usual surprise: it is not settable on a computer agent). Which kind an agent is reads in the listing's Scope column, not in the command that produced it.

models asks a provider what it serves right now, where providers lists what Luge knows offline. The two differ for anything whose line-up is not global: a self-hosted server serves what was pulled onto it, and a Google Cloud region serves a subset of Gemini that the next region does not. Extra key=value arguments go through as query qualifiers (project=/location= for gemini_vertex, base_url= to preview a host before saving it).

Exposure profiles

What a role is to rights, an exposure profile is to the UI: which surfaces assignees see (surfaces, an opaque section-id map — workspace is the reserved key for the Home entry) and where they land at sign-in (home_section). Presentation only — a hidden section stays reachable by URL and capabilities still gate everything. CRUD and default need tenant.manage; assign answers to member.assign_role, the same gesture as changing a member's role, and takes effect on the assignee's next sign-in.

luge-cli settings exposure list                   # profiles + which is the tenant default
luge-cli settings exposure show Essentiel
luge-cli settings exposure create "Essentiel" home_section=agents \
    surfaces='json:{"workspace":"hidden","memories":"launcher"}'
luge-cli settings exposure set Essentiel home_section=json:null   # explicit null clears
luge-cli settings exposure default Essentiel      # tenant default (--clear to remove)
luge-cli settings exposure assign kim@acme.test Essentiel   # one member (--clear to unassign)
luge-cli settings exposure delete Essentiel -y    # assignees fall back, never refused

External-collaboration governance

external-collaboration is a slice like the others, gated on external_collaborator.manage rather than on the admin role — governing who from outside may collaborate is grantable without handing over the tenant configuration. It carries enabled and the two domain lists.

luge-cli settings get external-collaboration
luge-cli settings set external-collaboration enabled=false          # freeze the surface
luge-cli settings set external-collaboration blocked_domains=json:'["blocked.example"]'
luge-cli settings set external-collaboration blocked_domains=json:null   # lift the restriction

Absent and null are different answers: a key you do not pass is left alone, while json:null clears the list. allowed_domains=json:[] is stricter still — an empty allow-list admits nobody, and is kept meaningful on purpose. Disabling freezes rather than deletes: invitations are refused, entry pages 404 and live sessions are cut, but every grant stays and re-enabling restores them.

Credentials

Where a provider is pointed at — an ollama's base_url, an Anthropic api_key — and where an integration's token lives. One field at a time (credential.manage, i.e. an admin key's integrations scope).

luge-cli settings credential list [--type ollama]   # which fields are set; values never returned
luge-cli settings credential set ollama base_url http://localhost:11434
luge-cli settings credential set anthropic api_key -    # read the secret from stdin
luge-cli settings credential set anthropic api_key      # or be prompted, without echo
luge-cli settings credential delete ollama base_url --yes
luge-cli settings credential delete ollama --yes        # every field of the type

Prefer - or the prompt over typing a secret as an argument: an argument lands in the shell history and the process list. A secret is write-only — the API returns the value of descriptive fields (base_url, model) and null for anything secret, so a key can be replaced but never read back.

Nothing pings the provider: the value is stored as sent, and the first agent turn is what proves it. Deleting the last field an agent's provider needs takes that agent off the air at its next turn — these are read live, nothing is restarted for the breakage to happen.

Integrations: the tenant's MCP servers

The tenant's tool wiring — an external MCP server, a skill marketplace, an agent-context source. Every verb answers to integration.manage, so a member key gets a 403 on all of them.

luge-cli settings integration list                      # everything declared
luge-cli settings integration list --type mcp_server    # just the MCP servers
luge-cli settings integration add weather --url https://mcp.example/mcp
luge-cli settings integration add weather --url https://mcp.example/mcp \
    --auth-type bearer --token -        # the secret from stdin, not the shell history
luge-cli settings integration add weather --disabled    # turn it off; the stored URL is kept
luge-cli settings integration status                    # health of every mounted server
luge-cli settings integration status weather            # + its tools and skills
luge-cli settings integration reload weather            # re-mount one
luge-cli settings integration rm weather --yes

list and status answer different questions. list reads the table: what is declared. status reads the gateway's mounted proxies: what is running, with the tools each server actually exposes. A server declared --no-proxy, or one whose reload has not landed yet, is simply absent from the second — which is why an empty status is never proof that nothing is configured.

add upserts on the name, so it is also the edit — and it is a patch: an option you do not pass keeps its stored value, --url included (it is required only to declare a new server, so turning one off is --disabled alone and never a retyped URL), and so does every config key this CLI does not model. That last part matters more than it sounds. The write route replaces the whole config rather than merging into it, and the web form can set raw headers (a literal Authorization the gateway forwards) that the CLI has no flag for. So add reads the stored row first and patches it; without that, editing a description would delete the header and the server would 401 on its next mount, silently.

The secret is write-only: an omitted --token is not sent at all and the stored one survives. Its destination follows the auth type — the token for bearer and token, the password for basic, the client secret for oauth2. Pass - to read it from stdin, or export LUGE_CLI_MCP_TOKEN. Passing --auth-type is a reset: it rebuilds the auth block from the flags, so a leftover of the previous scheme (a username from a former basic setup) does not survive into the new one. Leaving it out patches the stored block, which is how --token alone rotates a secret.

Changing --url purges the secrets and OAuth grants held for that server. A credential is only valid for the upstream it was granted against, so a new URL is a replacement and everyone reconnects — the same reason rm takes the tenant's secrets, the OAuth client and grants, and each member's own stored credential with it.

Mounting is asynchronous. add and reload publish to the gateway and return; the tools appear on a later request. status a moment after is what says whether it worked, and reload is the fix for a server that was down when Luge last looked — the gateway keeps the failure until something tells it to try again.

--header key=value (repeatable) covers the servers whose only authentication is a raw header. It carries a weaker promise than --auth-type and says so: a header is stored in config unencrypted and comes back from every read, where an auth secret is extracted into encrypted storage and never returned. Reach for it when the server leaves no choice; --header X-Api-Key=- removes one.

Conversations: channels, DMs, agents

Sending a message has three distinct surfaces — one command group each — plus a colleagues directory to find who to DM.

Group chat — luge-cli channel participates in team channels. Reads and posts are plain; the AI answers only when a message @luge-mentions it.

luge-cli channel list ; luge-cli channel browse       # channels you can see / can join
luge-cli channel show general                          # one channel (id or name)
luge-cli channel messages general --limit 50           # message history (oldest-first)
luge-cli channel post general "ship it @luge"          # @luge to invoke the agent
luge-cli channel post general "build is red" --as-agent   # an AI wrote it, not you
luge-cli channel join general ; luge-cli channel leave general
luge-cli channel create team --display "Team" --public --agent <id>
luge-cli channel create daily --kind voice --display "Daily" --public   # a voice salon

A voice salon (--kind voice) carries no messages and no agent — post / messages / trace answer 409 there and --agent is refused by the server — and is entered from the app; channel list and channel show print each channel's kind.

Direct message — luge-cli dm sends a 1:1 message to a colleague (human↔human, never triggers the AI). dm send opens the DM (get-or-create) and posts in one go.

luge-cli dm send "Alex" "got a sec?"     # colleague by id, email, or name
luge-cli dm send "Alex" "deployed" --as-agent   # an AI wrote it on your behalf
luge-cli dm list                          # your DMs
luge-cli dm show "Alex" --limit 50        # read the thread

--as-agent on either send (or LUGE_CLI_AS_AGENT=1 for a whole session) keeps your identity on the message and marks it AI-written — see Saying an AI wrote it.

Cards — luge-cli chat-card puts an interactive or visual card in a channel (positional) or a DM (--dm <colleague>). It renders live for everyone and survives a reload. Three kinds: a poll, a feedback form (answers collected silently, revealed on close), and a content card (markdown, charts, tables).

luge-cli chat-card poll general -t "Which day?" -o Mon -o Tue -o Wed
luge-cli chat-card feedback general -t "Retro" --anonymous --ai-summary
luge-cli chat-card content --dm "Alex" -t "Report" -m "## Status\n**All green**"
luge-cli chat-card results general -c <cardId>     # the tally / who answered
luge-cli chat-card close general -c <cardId>       # reveals the result; creator only

Answering is the other half, and the one everyone but the author uses:

luge-cli chat-card vote general -c <cardId> --option 1        # opt-1, the slot `results` prints
luge-cli chat-card vote general -c <cardId> --option-text Tue # or by label
luge-cli chat-card answer --dm "Alex" -c <cardId> --text "ship it"

One vote per person, per revision: re-running the identical vote is a replay the server recognises (exit 0, nothing changed), while voting differently is refused — already_voted, like card_closed and stale_revision, is one line naming its code and a non-zero exit. A feedback answer always replaces yours. Only the creator can close (which reveals the result) or delete a card, and a posted content card is corrected with revise, in place — never re-posted.

Agent chat — luge-cli agent send talks to an AI agent, and the thread it opens is read under conversation, beside dm and channel.

luge-cli agent send "Summarise the open cards" [--agent <id|name>] [--conversation <id>]
luge-cli agent send "Any blockers?" --wait --json   # wait for the reply → {conversation_id, reply}
luge-cli conversation list                           # your agent conversations
luge-cli conversation messages <id|title> --limit 50 # a conversation + its messages
luge-cli conversation trace <id|title> --json        # the same thread, audited

One word, one meaning. agent holds send and nothing else: a conversation is read where the other conversations are, and the agents themselves are configuration — settings agent list|show|create|set|delete, see Settings. The spellings that used to straddle the two (agent list, agent show, agent trace, agent create|set|rm, settings agents, settings agent-*) are gone, not aliased: a script on one of them stops on No such command instead of being quietly served something else.

By default agent send is fire-and-forget: it invokes an agent and returns the conversation id immediately (read the reply later with conversation messages <id>). With --wait it polls until the agent's text reply arrives and prints it (intermediate tool-call steps are skipped; tune with --timeout / --interval) — the agent-friendly "ask → answer" form, especially with --json. --agent accepts an id or a name.

Why an agent answered that way — luge-cli conversation context <id> prints what the model actually received on its last turn: the verbatim system prompt, the tool schemas, the skills manifest, the injected blocks (workspace graph, document context, retrieved memories, running summary) and the final message list. It is the command-line twin of the app's chat debug panel.

luge-cli conversation context <id>                  # the shape of the turn + the skill states
luge-cli conversation context <id> --show prompt    # the system prompt, verbatim
luge-cli conversation context <id> --show skills    # catalogue / proposed / activated
luge-cli conversation context <id> --show blocks    # every injected block, in injection order
luge-cli conversation context <id> --json           # the whole snapshot, lossless

Only on a recent conversation: the snapshot is written per agent turn into Redis with a one-hour TTL and overwritten by the next turn. Past that you get "no snapshot" plus the DB-backed injection counts, which survive.

--show skills is the one that earns the command. A skill can be in the catalogue, proposed to the model (a <skill_recommendation> block, which sits inside the messages) or activated by it — and "the skill didn't fire" covers two opposite causes, triggers that never matched and a description the model ignored, with opposite fixes. Needs token_usage.view (an admin key's compliance scope) plus access to the conversation itself.

React — luge-cli message acts on a single message by its id (the chat "Copy ID" action). Emoji reactions work the same in a channel or a DM:

luge-cli message react <messageId> 👍       # add a reaction (safe to repeat)
luge-cli message unreact <messageId> 👍     # remove yours
luge-cli message reactions <messageId>      # who reacted, with what

Read an attached file — message attachment opens what a message carries, in a channel or a DM alike. Text prints on stdout; anything binary reports its type and size rather than dumping bytes, and -o writes it out (a directory keeps the original filename, an existing file is refused unless --force). The download is streamed, so a meeting recording never lands in memory.

luge-cli message attachment list <messageId>              # number, filename, type, size
luge-cli message attachment get <messageId>               # the first file's text
luge-cli message attachment get <messageId> 2 -o .        # write it here, original name
luge-cli message attachment get <messageId> 2 -o f.pdf --force   # overwrite an existing file

Colleagues — luge-cli colleagues is the platform people directory.

luge-cli colleagues list [--type human]   # the roster, with each human's effective presence
luge-cli colleagues get "Alex"            # a colleague's profile (id, role, skills)

Presence — luge-cli presence reads and sets your own presence: the declared status (active / away / busy) and the "appear offline" flag. set writes both in one call (a status alone would leave a hidden user hidden), hide writes the flag alone, and show prints the declared pair apart from what colleagues actually see — which also depends on a client of yours being connected, so active next to offline is a consistent reading, not a broken command. No set offline: the server ignores a declared offline, hide is the gesture.

luge-cli presence show                    # status, visible, and what colleagues see
luge-cli presence set away                # active | away | busy — and visible again
luge-cli presence hide                    # appear offline to everyone

Artifacts

luge-cli artifact works with conversation artifacts. Five kinds: markdown, html and code carry text; pdf and image carry bytes, encoded for you at create from a local file (reading a binary artifact back shows its size, not its bytes). Every artifact lives in a room — the --room id is a conversation/channel id (shown by conversation show / channel messages).

luge-cli artifact mine                              # your artifacts (summaries)
luge-cli artifact list --room <room-id>             # a room's artifacts (with content)
luge-cli artifact show <id>                          # one artifact + its content
luge-cli artifact show <id> -o report.pdf            # write it to a file (pdf/image decoded)
luge-cli artifact create --room <room-id> "Notes" ./notes.md --type markdown
echo "# Draft" | luge-cli artifact create --room <room-id> "Draft" -    # from stdin
luge-cli artifact delete <id>                        # (asks to confirm; -y to skip)

There is no update-in-place — create always inserts a new artifact.

Projects — the container of a chantier

A project gathers one effort's material — a client, an initiative, a quarter — so the whole thing has a single door. A meeting belongs to a project by hierarchy; everything else (a note, a board, a document, a conversation…) is linked by reference: the object keeps living on its own surface, the project holds a pointer, and unlinking — or deleting the project — never touches the object itself. project link is the one command for both shapes, the type routes the call, and the server owns the closed list of linkable types, so a wrong one is a loud 422 rather than a silent no-op.

luge-cli project list                                # projects you can see
luge-cli project list --archived                     # the ones set aside
luge-cli project create "Site rebuild" -d "The new site effort"
luge-cli project show Rebuild                        # meetings, people, linked objects
luge-cli project update Rebuild --name "Site rebuild v2"
luge-cli project archive Rebuild                     # out of the listing; contents untouched
luge-cli project unarchive Rebuild                   # matched among the archived ones
luge-cli project delete Rebuild                      # links go, the objects stay (-y skips the prompt)

luge-cli project link-options Rebuild                # what it can link, ids included
luge-cli project link-options Rebuild -s spec        # filtered server-side
luge-cli project link Rebuild note <note-id>         # by reference
luge-cli project link Rebuild meeting <meeting-id>   # by hierarchy — same verb
luge-cli project unlink Rebuild note <note-id>

project link-options is where that <type> <id> pair comes from: the picker of the project page, from the command line, instead of hunting an id down with note list, board list, document list and guessing which types are accepted. It shows only what you can already read (a type you lack the capability to list is absent, never a 403) and --limit counts per type — a group sitting at the cap is clipped, not complete, and the output says so. The picker is gated on write access, so an archived project answers 409 rather than a list.

Access never cascades from the project. Sharing one shares the container only: each entry keeps its own audience, and one you cannot read shows in project show as a locked stub — its kind and id, nothing else. That id is exactly what share request <kind> <id> takes, so a locked stub is one ask away from readable, decided by the entry's owner and never by the link.

Who takes part

A project also names its people, which is neither content nor access.

luge-cli project actor list Rebuild                       # each person, their role, their id
luge-cli project actor add Rebuild Alice                  # by name, email or id; participant by default
luge-cli project actor add Rebuild Alice --role responsable
luge-cli project actor add Rebuild --name "Dana Fielding" \
        --email dana@northstudio.test --organization "North Studio" --role client
luge-cli project actor add Rebuild <contact-id> --type contact   # one that already exists
luge-cli project actor role Rebuild Alice responsable     # the role becomes exactly that one
luge-cli project actor remove Rebuild Alice --role client  # one role
luge-cli project actor remove Rebuild Alice                # every role

A role is a business fact and never a grant: naming someone responsable shares nothing with them, and access stays exactly what share says it is. The role is also part of a link's identity, so one person may hold two at once and the listing shows a row per role. --role takes any string — responsable, participant and client are what the web UI offers, not a closed list.

add carries the attach route's two shapes and they exclude each other: someone who already exists, or --name to create an external contact and attach it in the same gesture. The platform has no contacts listing, so re-attaching a contact you already have means passing its id with --type contact — the id project actor list prints beside them — rather than typing the name again, which would create a second one. remove and role name someone among the project's own actors, the only listing that carries the actor_type their route needs in its path, and the only place a contact can be named at all.

There is no update route for a role, so role writes the new link and then drops the others. That order fails safe: a second call that falls leaves the person visibly holding two roles instead of taking them off the project. Someone already holding only that role is left alone, with no call made.

Meetings and captures

meeting reads the meeting layer; notetaker manages the recordings underneath it. A meeting carries the summary, the action items and the notes; each linked capture carries its own content, which is where the transcript lives.

luge-cli meeting list                                # newest first
luge-cli meeting show "Kickoff"                      # summary, action items, notes, captures

luge-cli notetaker list                              # the captures and their state
luge-cli notetaker show <capture>                    # transcript, summary, recording
luge-cli notetaker bot <teams-url>                   # send a recording bot into a meeting
luge-cli notetaker upload ./standup.m4a              # ingest a local recording
luge-cli notetaker resummarize <capture>             # re-summarise the stored transcript
luge-cli notetaker stop <capture>                    # end a running capture
luge-cli notetaker delete <capture>                  # the capture and its recording

A capture's run shows up under activity (kind notetaker) like any other run.

resummarize re-runs the summary against the transcript already stored, without re-transcribing — for when a capture has a usable transcript and no usable summary (the agent was misconfigured, its provider was down, the prompt changed). It works for every source, because the transcript is the only input. It is asynchronous: the capture comes back summarizing and the summary lands later, so read it back with show rather than expecting it in the reply. A capture still recording, or with no transcript at all, refuses with 409.

Bookmarks — reusable web links

A bookmark has a saved HTTP(S) URL, title, description, one optional flat section, and multiple tags. It is private by default and has its own sharing rights. Sections group links (for example Cuisine); tags describe them (poulet, repas). No web page is automatically fetched or indexed.

luge-cli bookmark section create Cuisine
luge-cli bookmark create 'https://recipes.example/chicken?portion=2#steps' --title 'Poulet au citron' --section Cuisine --tag poulet --tag repas
luge-cli bookmark list --section Cuisine --tag poulet --tag repas --search citron
luge-cli bookmark show <bookmark-id>
luge-cli bookmark update <bookmark-id> --section Cuisine --tag repas
luge-cli bookmark update <bookmark-id> --unfiled --clear-tags --clear-description
luge-cli bookmark update <bookmark-id> --add-tag repas --remove-tag matin
luge-cli bookmark bulk move <id-1> <id-2> --section <section-id>
luge-cli bookmark bulk move <id-1> <id-2> --unfiled
luge-cli bookmark bulk tag <id-1> <id-2> --add repas --remove matin
luge-cli bookmark bulk delete <id-1> <id-2> --yes
luge-cli bookmark section list
luge-cli bookmark section rename Cuisine Recettes
luge-cli bookmark tags
luge-cli bookmark list --project <project> --section Cuisine --search citron
luge-cli project link <project> bookmark <bookmark-id>
luge-cli project unlink <project> bookmark <bookmark-id>
luge-cli project link <project> bookmark_section <section-uuid>
luge-cli project unlink <project> bookmark_section <section-uuid>
luge-cli share grant bookmark <bookmark-id> <colleague>
luge-cli share grant bookmark-section Cuisine <colleague> --write
luge-cli bookmark section delete Recettes --yes
luge-cli bookmark delete <bookmark-id> --yes

Bulk commands take 1-100 unique bookmark UUIDs and print a result for each. Refused objects cause a nonzero exit code, while successful changes are retained. Infrastructure failures stop the batch; re-read before retrying uncertain writes. --add-tag and --remove-tag preserve unrelated tags through an atomic update; do not combine them with --tag or --clear-tags.

--tag is repeatable: list filters require all tags; update replaces the whole tag set. Omitted fields stay unchanged; --unfiled, --clear-tags and --clear-description explicitly clear them. Every verb supports --json. list --limit/--offset preserves the server's total even on an empty page.

Sections accept id or name; ambiguous names are refused. Bookmark verbs use UUIDs returned by create/list, including when sharing. <colleague> is a name, email or colleague id from the directory. Deleting a section leaves its links in Unfiled. Project links reference the same bookmark: edits apply everywhere, while unlinking or deleting a project preserves it. Deleting the bookmark destroys it and purges its references. Sharing a project does not grant bookmark access. The API key needs bookmark.read.own to read and bookmark.manage.own to write.

A project can also link a whole section, as a live reference rather than a copy: project link <project> bookmark_section <section-uuid>. bookmark list --project <project> then lists the project's readable bookmarks, the current members of its linked sections included, and combines with --section, --search and --tag. Adding or moving a bookmark changes what the section holds, so the project's list follows without a second command. A link widens no access, and unlinking leaves the section and its links untouched.

A section is shareable in its own right: share grant bookmark-section Cuisine <colleague> --write, with share show for its audience and share revoke to take a grant back. Inheritance is the one note folders use — the owner's bookmarks in the section, present and future, follow the section's audience, while each bookmark's own grants stay independent. A recipient reads the collection; they do not gain control of the section or of its bookmarks' sharing.

Notes — markdown, version-locked

A note is where thinking that outlives a task lives: specs, investigations, design rationale, meeting analyses, documentation. It is versioned, diffable, shareable per note, and reachable months later through semantic search — which is exactly what a card comment or a chat message is not.

luge-cli note list                                   # yours, plus tenant-visible and shared
luge-cli note list --folder Projects/2026/Q1         # a path, a name, or an id
luge-cli note search "retention policy"              # title and content
luge-cli note show "Site spec"                       # the full markdown
luge-cli note create "Site spec" -c "## Context…"    # or -f <path>, -f - for stdin
luge-cli note capture "an idea worth keeping"        # lands in your Inbox, title derived
echo "an idea" | luge-cli note capture               # a pipe is read with no argument

luge-cli note append "Site spec" -c "## Open questions"      # add a NEW section
luge-cli note section "Site spec" -H "Open questions" -c "…" # replace one section's body
luge-cli note edit "Site spec" --title "Site spec v2" -c "…" # retitle and/or replace the body
luge-cli note move "Site spec" Projects/2026/Q1      # file it (-r sends it back to the root)
luge-cli note delete "Site spec"                     # -y skips the prompt

luge-cli note revisions "Site spec"                  # the changelog, last 50 snapshots
luge-cli note revision "Site spec" 7 --diff          # what changed at version 7
luge-cli note restore "Site spec" 7                  # back as a NEW version
luge-cli note history "Site spec"                    # the journal: who, when, through what

Three write shapes, from surgical to total: append adds markdown at the end, section replaces one existing section's body (repeat -H to nest, --occurrence N when a heading repeats), edit replaces the whole thing. Every mutation is version-locked: the CLI reads the note and sends its version, so a note edited elsewhere in between answers 409 with the current version — re-read and retry, nothing was overwritten. restore brings a snapshot back as a new version; history is never rewritten.

A note carries files too, and it carries them the way it carries everything else — by naming them in its own text:

luge-cli note attach "Site spec" ./mockup.png             # upload, and name it in the note
luge-cli note attach "Site spec" --document <id or name>  # cite one already in the corpus
luge-cli note attachments "Site spec"                     # with the number the other verbs take
luge-cli note read "Site spec" 1 -o ./mockup.png          # bytes out (--force to overwrite)
luge-cli note detach "Site spec" 1                        # the text stops naming it

attach appends a @[document:<id>] reference, which is what makes the file part of the note: it shows where the note is read, and it goes when the text stops naming it. So detach edits the note — and like every reference that goes, it never touches its target: the document stays in its owner's library.

The upload goes to the note's own route on purpose: the server (not the caller) marks the document private and out of the RAG corpus, so a file in a note can never surface in an agent's prompt, and whoever may read the note may open it. Bytes the corpus already holds answer that document instead of a duplicate — the same screenshot in two notes is two citations of one file.

--document cites a document from the library instead. That one keeps its own audience: a reader of the note who holds no right on it sees a private reference, not the file.

note folder files notes in a tree, up to five levels deep:

luge-cli note folder list                            # the tree, indented, each row a path
luge-cli note folder create Q1 --parent Projects/2026
luge-cli note folder move Projects/2026/Q1 Archive   # the branch, with everything it holds
luge-cli note folder rename Projects/2026/Q1 Q1-2026 # renames only
luge-cli note folder delete Projects/2026/Q1         # the whole subtree, no trash (-y to skip)

Address a nested folder by its path — Projects/2026/Q1. A name is unique among siblings only, so two projects may each hold a "Q1"; a bare name still works when it matches one folder, and an ambiguous one is refused with the candidate paths rather than guessed. A folder is also a sharing unit: share grant note-folder <ref> --tenant opens everything filed under it, subfolders included, notes and canvases alike. Two consequences of the tree are worth knowing: note list --folder Projects answers with the notes filed anywhere under it, and folder delete destroys the whole subtree, subfolders and canvases included.

Canvases — the visual note

A canvas is a JSON Canvas document (jsoncanvas.org): boxes, arrows, and — the part that matters here — entity boxes standing for platform objects. That is what makes a canvas a container in the graph rather than a drawing: a canvas holding a note shows up in that note's backlinks. Reach for one when the answer is a shape (a mind map, a process diagram, an overview of how things connect) and for a note when the answer is prose.

luge-cli canvas list                                 # --folder filters the page that came back
luge-cli canvas create "Architecture" -f map.canvas  # - reads stdin
luge-cli canvas update "Architecture" -f map.canvas  # replaces the whole document
luge-cli canvas show "Architecture"                  # the shape: node counts, edges, entity boxes
luge-cli canvas show "Architecture" --refs           # entity boxes resolved against YOUR rights
luge-cli canvas show "Architecture" --content        # the document back out, unchanged
luge-cli canvas move "Architecture" Projects/2026    # the same tree as notes (-r for the root)
luge-cli canvas delete "Architecture"                # -y skips the prompt
luge-cli canvas backlinks note <note-id>             # which canvases hold this object

Same ownership, same version lock, and the same folders as notes — one tree for both. The document travels as a file, so an Obsidian .canvas round-trips through the CLI without loss; there is no partial edit of a graph from the command line: read it, change it, send it back. canvas list --folder filters the page that came back rather than the query, unlike note list, so raise --limit when a folder's canvases might sit past the first page.

If a canvas call answers 403, the API key predates canvases — a key's scopes are a snapshot taken at creation. luge-cli auth key update <key> --add canvas.manage.own (or auth key sync <key>) fixes it.

Semantic search

luge-cli search runs the platform's unified semantic (vector) search across notes and documents — it finds meaning, not substrings (note search stays the lexical counterpart). Each hit shows its similarity, its workspace-graph node_id, and a connected: line naming what links to it in the graph (depth-1 neighbourhood).

luge-cli search "how do we handle auth tokens"       # notes + documents, by meaning
luge-cli search "auth" --type note                   # one type only (repeatable)
luge-cli search "auth" --limit 5 --min-similarity 0.3   # cast wider than the 0.5 default
luge-cli search "auth" --json                        # raw server response (diagnostics)

Documents join the results only when your token carries document read access — a restricted API key simply sees fewer types. The search needs a default embeddings agent configured on the platform; without one the server's 400 is reported as-is.

Documents — the RAG corpus

luge-cli document manages the tenant's document corpus: the knowledge layer agents retrieve from. A document is a file (or a pointer to one); rag decides whether it is chunked and embedded for retrieval, and folders are a single flat level it can be filed under.

luge-cli document list --status ready                # the corpus (filter by folder/status)
luge-cli document show "loi25"                       # type, size, RAG, folder — and why it failed
luge-cli document add ./prd.pdf --scope tenant --rag # upload and index it
luge-cli document rag "loi25" --off                  # stop indexing it (the file stays)
luge-cli document move "loi25" Specs                 # file it under a folder
luge-cli document folder create Specs                # folders are one flat level

document reference records a file that lives on Google Drive or SharePoint without copying it — the provider stays the source of truth and the corpus stores a pointer the agent resolves on demand:

luge-cli document reference "Q3 deck" --source microsoft --external-id 01ABC \
    --file-type pptx --drive-id 'b!…' --url https://sharepoint.example/x --rag

Reading what the corpus holds:

luge-cli document chunks "loi25"                     # the pieces it was split into
luge-cli document chunks "loi25" --indexes 0,3       # specific chunk indexes
luge-cli document download "loi25"                   # text prints; binary reports type + size
luge-cli document download "loi25" -o ./out.pdf      # streamed; --force to overwrite

Embedding coherence and re-indexing

Switching the tenant's embeddings agent to a model of a different vector width invalidates every vector already stored, and nothing announces it: the writes keep going, and search silently drops the rows of the old width. document embeddings is how you find out.

luge-cli document embeddings                  # the coherence report + its verdict
luge-cli document reindex                     # the cure — asks first (--yes for scripts)
luge-cli document reprocess "loi25"           # the same, for one document

The report probes the model live — one network round-trip to Ollama/OpenAI, so it is not a free read — and prints the effective vector width against the width pinned on the agent, the stored widths per corpus (document_chunks, note_chunks, memories) with their stale-row counts, any ANN index standing on an embedding column, and whether a re-index is required. An unreachable provider degrades the report instead of failing it: the widths and the indexes that need dropping still print.

document reindex drops any ANN index and re-embeds all three corpora, so it confirms before acting. It queues the work and returns — what it prints is what was accepted, not what has finished; document embeddings reads the outcome back. These four commands need document.manage; a token without it gets a plain refusal.

Sharing — who can access a resource

luge-cli share is one generic surface over every ownable object: a board, a todo, a day plan, a document or its folder, a table, a workflow, a scheduled task, a webhook, a channel, a meeting, a notetaker capture, a note. The reference is the same id-or-name used everywhere else — a plan, which has no name, is referenced by its day (share grant plan today Alice). Managing a resource's shares is owner-only (a channel also lets a channel manager do it).

luge-cli share show board Roadmap                    # visibility + who it is shared with
luge-cli share readers document "PRD loi25"          # concrete readers: members + externals
luge-cli share grant board Roadmap Alice             # read access for a colleague
luge-cli share grant table Budget Alice --write      # write (editor) instead of read
luge-cli share grant workflow Nightly --tenant       # visible to the whole tenant
luge-cli share revoke board Roadmap Alice            # take it back (--team / --tenant too)
luge-cli share request document <id>                 # ask the owner for read access

request is the other direction — the verb for a resource you cannot read. It files an approval task in the owner's inbox; approving shares the resource with you and you hear about it as a notification. Pass the id: a resource you cannot read is in no listing, so no name resolves it. For a document, that id comes from the conflict document add / card attach raises when the corpus already holds those bytes — the one place an unreadable document's id surfaces.

The API answers a request the same way whether one was filed, you could already read the resource, or a request was already pending. The CLI reports that limit instead of claiming a new ask each time.

External collaborators

luge-cli external manages collaborators from outside the tenant. They are not colleagues: no seat, no capability, never in the people directory — they can open exactly the resources shared to them by name, until the collaboration expires or is revoked. Only the kinds the guest surface serves reach them (note, note-folder, canvas, board today — boards read-only); a grant on another kind is accepted by the API and has no effect.

luge-cli external invite jeanne@corp.com --name Jeanne --expires-in-days 30
                                                     # prints the entry link to hand over
luge-cli external list                               # the roster: status, expiration
luge-cli share grant board Roadmap --external jeanne@corp.com   # nominative read grant
luge-cli share show board Roadmap                    # names her: email, right, expiration
luge-cli external grants jeanne@corp.com             # everything she can reach
luge-cli share revoke board Roadmap --external jeanne@corp.com  # one resource back
luge-cli external revoke jeanne@corp.com             # the collaboration; grants are purged

Inviting, listing and revoking need the external_collaborator.invite capability; the grants review needs external_collaborator.manage. The entry link identifies the invitation and never authenticates anyone — entering still costs proving the invited address. Re-inviting an address re-opens the same collaborator, with no grants.

Being the guest

The other half of the same group walks the invitee's side, so a collaboration can be proved end to end without leaving the CLI.

luge-cli external entry "$ENTRY_LINK"       # the entry screen: org, masked address, proofs
luge-cli external code "$ENTRY_LINK"        # send the one-time code to the invited address
luge-cli external verify "$ENTRY_LINK" --code 123456     # exchange it for a session

export LUGE_EXTERNAL_TOKEN=$(luge-cli external verify "$ENTRY_LINK" --code 123456 --json | jq -r .token)
luge-cli external whoami                    # who the carried session belongs to
luge-cli note show "Shared note"            # …and read what was granted, as the guest

entry, code and verify carry no credential at all — not even a profile's API key — because they are reached by someone holding a link and nothing else. They name the deployment with --url, else LUGE_URL, else the stored profile's URL, so an invitee who has never run auth init can use them. Every way an invitation can fail (unknown, revoked, expired, domain-refused) is the same 404 on purpose; being rate-limited is its own 429. All three take the whole entry link or the bare token, query string and all.

LUGE_EXTERNAL_TOKEN replaces the API key rather than joining it: with it set, the CLI sends X-Luge-External-Token and no Authorization at all, so every command answers with exactly what the collaboration was granted. Sending both would be worse than useless — the API reads the external header first and refuses any route not opened to a guest on sight of it. Nothing is written to config.toml: an external session has a TTL and a reverify_after, which is nothing like a profile.

Which is also why it is sticky in a way worth knowing: while that variable is set, a member command like board list speaks as the collaboration too and is refused. unset LUGE_EXTERNAL_TOKEN is how you stop being the guest; the refusals name the external session rather than your API key, so you can tell which credential the door closed on.

The six-digit code is not a CLI surface, by design. It is a proof of the invited address, and the platform sends it to that address and nowhere else — verify takes it as input. Read it in the invited mailbox, or, on a deployment with no mail transport configured, in the API log — the API writes it there when it cannot mail it (a WARNING line, and only when the deployment runs at DEBUG level).

Skills (personal & local)

luge-cli skill manages authored Luge skills — instruction sets (name, description, instructions body, required tools, trigger phrases) that agents can use. By default commands target your personal skills (visible only to you); --scope local targets the tenant's shared local skills. Reading works with any key; create/update/delete need the skill-manage capability (an admin/owner key).

luge-cli skill list                        # your personal skills, with created/updated
luge-cli skill list --scope local          # the tenant's shared local skills
luge-cli skill list --all                  # the whole catalog: built-in, local, marketplace
luge-cli skill show <name>                  # description, instructions, requires, triggers, dates
luge-cli skill create my-skill --description "..." --instructions "..." \
        --require terminal_run --trigger "do the thing"
luge-cli skill create my-skill --instructions-file ./SKILL.md   # body from a file
luge-cli skill update my-skill --description "..." [--instructions … --require … --trigger …]
luge-cli skill update my-skill --instructions "…" --if-match "<updated_at from show>"
luge-cli skill delete my-skill              # (asks to confirm; -y to skip)

--if-match makes the update conditional: pass the updated_at you read (from show or list) and the API answers 412 instead of overwriting a version someone else changed since — re-read and retry. Without it, last write wins.

(Not to be confused with luge-cli claude skill and its siblings — codex, hermes, kimi, openclaw, qwen, cursor, opencode, cline, kilo — which install this CLI's own agent skill — see Agent skill below.)

Installed cookbooks — what a cookbook set up, as one object

A cookbook installs a working system on the tenant: agents, a board, tables, notes, scheduled tasks, a workflow, skills. The platform keeps one record per cookbook a person installed — the installation: its name, its version, what the installer declared, every object it created, and a status read off those objects rather than stored.

luge-cli cookbook list                     # name, version, status, enabled/total triggers, who installed it
luge-cli cookbook show dev-team            # every object with its state, and the device
luge-cli cookbook start dev-team           # the yes after an install: enable its tasks and workflow
luge-cli cookbook pause dev-team           # disable them all; the agents stay reachable in chat
luge-cli cookbook resume dev-team
luge-cli cookbook uninstall dev-team --yes --acp-entry claude-po --agent-home claude

<ref> is the installation's id or a unique substring of the cookbook's name. A Luge older than the registry answers every verb here with one line naming the platform update it needs, rather than an empty list.

status what it means
running every task and workflow enabled
paused they are all disabled
partial some on, some off
drifted a prompt or a note was edited by hand since the install
degraded the machine an agent runs on is not connected
missing one of the objects was deleted

uninstall is two halves. The platform's: tasks and workflow disabled, agents deleted, board archived, tables, notes and channels kept. The machine's: the platform never reaches a computer, so name what to remove — --acp-entry <id> runs luge-edge acp rm for an entry the node still hosts, --agent-home <claude|codex|…> removes the cookbook's skills from that agent's skills directory. Without those options the command says what is left and where.

An installer talks to the same registry through cookbook register and cookbook link; that side is documented in the cookbook authoring guide rather than here.

Self-improvement loop

luge-cli improvement is the platform's self-improvement loop — what the app's self-improvement dashboard renders. The loop observes friction in agent exchanges, writes proposals, auto-applies them into a journaled changelog, and learns from human verdicts. Every command requires tenant admin (tenant.manage); other keys get a clean 403.

luge-cli improvement summary [--days 7]     # metrics, verdicts, pipeline, per-agent
luge-cli improvement proposals              # the proposer's output, newest first
luge-cli improvement proposals --status failed   # only failed applies, with the why
luge-cli improvement proposals --offset 100 # read past the first page
luge-cli improvement changes                # the auto-apply changelog + impact signals
luge-cli improvement synthesize             # queue a proposer pass (reads friction)
luge-cli improvement apply                  # queue an apply pass over pending proposals
luge-cli improvement approve <proposal-id>  # apply a parked fork proposal now
luge-cli improvement reject <proposal-id> --reason "not the right direction"
luge-cli improvement revert <change-id> --reason "regressed the tone"
luge-cli improvement comment <change-id> "good catch, keep this direction"

proposals and changes page at up to 100 rows — --limit/--offset walk the listing and the reply's total says when to stop; a failed row's Error column carries the apply's own words — a semantic-gate refusal reads review refused: …. synthesize and apply queue a worker pass and return immediately (queued): the outcome lands on proposals / changes moments later, never in the trigger's output. revert restores the change's before-snapshot, and a proposal-sourced change's target sits out re-proposal for the dedup window; its --reason (and any comment) feeds back into the proposer's next pass. The changelog's impact columns say what happened since each change — skill activations and friction recurrence — and --json adds the before/after snapshots.

One kind of proposal is never auto-applied: a patch_skill aimed at a synced skill parks as pending — parked in the proposals view — and waits for a human. approve applies it on the spot (a 409 means the row is not parked or already decided; a 422 carries the apply's own failure motive). reject requires a --reason (3-600 chars, checked before the call): the row lands reverted with a rejected by human: … motive and, like a revert, its target sits out re-proposal for the dedup window — the "no" holds for a while, it is not a skip.

Inbound webhooks

luge-cli webhook manages inbound webhook endpoints — receivers Luge hosts so an external system can drive automation. Each endpoint POSTs arriving at its inbound_url are routed to an agent, a workflow, a data table or a channel (or just logged). The CLI creates/inspects endpoints and reads their deliveries; Luge's server receives the webhooks.

luge-cli webhook list
luge-cli webhook create "GitHub CI" --to agent --target "General Agent" --source github
        # prints the inbound_url + signing secret (secret is shown ONCE — save it)
luge-cli webhook create "Stripe" --to workflow --target "veille-techno" --source stripe
luge-cli webhook create "Orders" --to data_table --target "Orders" \
        --map customer=data.customer.name --map amount=data.total   # one row per POST
luge-cli webhook create "Alerts" --to card --target "incidents"   # a card per POST
luge-cli webhook create "Cal.com" --to card --target "incidents" --source generic \
        --signature-header X-Cal-Signature-256 --signature-prefix ""
luge-cli webhook create "Deploys" --to agent --target "General Agent" --source generic \
        --delivery-id-header X-Request-Id      # drop redeliveries carrying the same id
luge-cli webhook create "Bugsink" --to agent --target "General Agent" \
        --prompt-template-file instruction.md --no-secret   # the agent's instruction; open endpoint
luge-cli webhook create "Debug" --to log_only          # just log, triggers nothing
luge-cli webhook show <id|name>
luge-cli webhook deliveries <id|name>                   # what has arrived (the journal)
luge-cli webhook delivery <id|name> <delivery-id>       # one delivery in full
luge-cli webhook update <id|name> --disable | --regenerate-secret
luge-cli webhook update <id|name> --delivery-id-header X-Delivery   # keeps the rest
luge-cli webhook update <id|name> --prompt-template-file instruction.md   # keeps agent_id; "" removes it
luge-cli webhook update <id|name> --remove-secret       # open a plain generic endpoint
luge-cli webhook delete <id|name>

--prompt-template / --prompt-template-file (exclusive, --to agent only) is the text Luge places before every payload it hands the agent. --no-secret on create and --remove-secret on update open the endpoint for a sender that cannot echo a secret; both are refused on a signed endpoint (github, stripe, hubspot, luge, or generic with a signature header), which verifies with its secret.

--to agent|workflow resolves --target (an agent or workflow, by id or name) into the endpoint's destination. --to data_table resolves --target to a table and maps the payload into a row: each --map <column>=<payload.path> fills a column from a dot-path into the delivery body, and every required column must be mapped. --to card resolves --target to a text channel (id or name) that receives each POST as a chat card; it needs write access on that channel, and a voice salon is refused because a card has nowhere to render there. The signing secret is returned only on create and --regenerate-secret — capture it then, it is not shown again.

Three declarations describe a --source generic sender, and apply to that source only (a built-in scheme carries its own): --signature-header names the header holding the body HMAC and --signature-prefix what precedes it (pass "" for a sender that signs with no prefix, like Cal.com; a prefix on its own is refused, it would declare no scheme at all), while --delivery-id-header names the header carrying the sender's delivery id, which must be unique per delivery and lets a redelivery be dropped instead of replayed. update accepts the same three and keeps the ones it is not given; show prints whichever the endpoint declares.

Agent skill

The package bundles an agent skill (luge-platform) for Claude Code, Codex, Hermes, Kimi Code, OpenClaw, Qwen Code, Cursor, opencode, Cline and Kilo Code — every agent that reads a skills/<name>/SKILL.md. It teaches the whole CLI — the card-work protocol (read the thread, announce, deliver, mark done only when asked) plus the schedule / workflow / activity / channel / dm / agent / artifact / webhook / settings surfaces and their non-obvious semantics. make install installs it; to (re)install it on its own:

luge-cli claude skill install --force   # copies it to ~/.claude/skills/luge-platform
luge-cli codex skill install --force    # ${CODEX_HOME:-~/.codex}/skills/luge-platform
luge-cli hermes skill install --force   # ${HERMES_HOME:-~/.hermes}/skills/luge-platform
luge-cli kimi skill install --force     # ${KIMI_CODE_HOME:-~/.kimi-code}/skills/luge-platform
luge-cli openclaw skill install --force # ${OPENCLAW_STATE_DIR:-~/.openclaw}/skills/luge-platform
luge-cli qwen skill install --force     # and likewise: cursor, opencode, cline, kilo

Unlike the editable CLI, the skill is a copy — it does not update on git pull, and skill install without --force refuses to overwrite one. That is what luge-cli upgrade exists for: it rewrites every copy already on the machine (and only those — it never creates a home for an agent that is not installed here), so a release cannot leave an agent reading the previous one's instructions. luge-cli upgrade --check lists each copy and whether it still matches the installed package.

MCP server

For a host that speaks MCP but has no shell, luge-cli mcp serves the whole CLI over stdio:

// Claude Desktop / Claude Code / any MCP client
{
  "mcpServers": {
    "luge": { "command": "luge-cli", "args": ["mcp"] }
  }
}

The tools are generated from the CLI itself at startup — the Click command tree is walked and turned into one tool per command group, with each group's verbs as an enum and every option and argument as a typed field. Nothing is declared by hand, so a command added to the CLI is a tool the server offers on its next start, and two tests (tests/test_mcp.py) fail the build if anything in the tree ever becomes unreachable.

Call a tool by naming the verb in command and its arguments beside it:

{ "name": "luge_card",
  "arguments": { "command": "create", "board": "Roadmap",
                 "column": "To do", "title": "Ship the MCP server" } }

Results are this CLI's --json output verbatim — it is passed automatically, so nothing comes back truncated. profile picks the connection per call, and credentials come from the usual config (luge-cli auth show).

Narrow the surface when a client does not need all of it:

luge-cli mcp --read-only                  # reads only — no create/update/delete/send
luge-cli mcp --group card,note,board      # these groups (repeatable, or comma-separated)
luge-cli mcp --exclude 'auth *' --exclude '* delete'
luge-cli mcp --list-tools                 # print the generated definitions, do not serve

--read-only classifies by verb and treats anything it does not recognise as a write, so a newly added command stays out until someone says otherwise. Four groups are never offered as tools, whatever the flags: mcp and upgrade act on the host, ws would hold the loop open, and api reaches endpoints by free-form path, which would let a host walk around --group/--exclude. A message sent through the server is marked AI-written by default — nothing but a model calls a tool over that transport — unless the host sets LUGE_CLI_AS_AGENT=0.

Watching the realtime stream

luge-cli ws holds this connection's WebSocket open and prints what lands — the probe for "who actually receives this", which no log answers. By default it joins no room (a subscribed connection leaves the unread fan-out); --subscribe <room-id> joins one anyway to see what a browser in that room sees, ephemerals included. --timeout is a wall-clock deadline, for scripts.

luge-cli ws                                     # every frame, one line each
luge-cli ws --type unread_message --json        # JSON Lines, ready for jq
luge-cli ws --subscribe <room-id> --json        # the room's live stream
luge-cli ws --limit 1 --timeout 30              # bounded, for a script

Raw API access

api get|post|put|patch|delete send the current profile's credential at any path the CLI does not wrap yet, so an endpoint stays provable from the terminal:

luge-cli api get settings/integrations/catalog   # any path, relative to the API base
luge-cli api get "automations?days=7"            # query string included
luge-cli api post settings/webhook-endpoints --body '{"name":"x","source_type":"generic","destination_type":"log_only"}'
luge-cli api put settings/webhook-endpoints/<id> --body-file endpoint.json   # body from a file
luge-cli api delete settings/webhook-endpoints/<id>       # asks first (--yes skips, for scripts)

The body prints as answered — pretty-printed when it is JSON, errors included (the status line goes to stderr and the exit code is 0 only for a 2xx, so piping the body into jq keeps working either way). A response carrying no body says so on stderr instead of printing a blank line, which cannot be told from an empty collection: a 204 after a delete succeeds and says so, a 307 exits non-zero naming where the API redirects. The path must be relative: an absolute URL is refused rather than carrying the credential to another host. A write takes its JSON body from --body or --body-file (one or the other); delete confirms before sending unless --yes. It is a diagnostic — a path probed often has earned a real subcommand — and the whole group stays out of the MCP tool surface.

Develop

make sync            # create/refresh the dev environment
make hooks           # install the pre-commit git hooks (once)
make check           # lint (ruff) + type-check (mypy) + test (pytest) — what CI runs
make format          # auto-fix lint issues and format

make test runs the suite (filters, resolution, HTTP client mocked, config, CLI wiring). CI (GitHub Actions) runs lint, type-check, and test on every push and PR.

Release

make bump V=0.48.0   # close CHANGELOG's [Unreleased] under that number + set pyproject
make release         # tag vX.Y.Z + create a GitHub release from CHANGELOG.md
make publish         # upload to PyPI via twine + ~/.pypirc  (REPO=testpypi to test)

Merged work does not touch the version. Add your entry under ## [Unreleased] in CHANGELOG.md and stop there. make bump is what turns that section into a number, and it is run at the moment of releasing — the only moment the number is knowable, since it has to be the one after what PyPI already holds. It refuses a version that is not ahead of the current one and an [Unreleased] section with nothing in it, then reopens an empty one for the next round.

Bumping per merge is how this repo once reached 0.50.0 while PyPI served 0.47.0: three versions announced in a changelog nobody could install.

Project repositories and conversation PRs

GitHub references use the reader's personal GitHub connection. Project access never grants source access. Unavailable references return a content-free stub. Repository roles and subdirectories belong to each project link, and a primary flag selects at most one repository per project. A repeated add is idempotent; use update to change its details. Archived projects are read-only.

luge-cli project repository options <project> --page 1 --json
luge-cli project repository add <project> https://github.com/acme/api --role API --subdirectory src --primary
luge-cli project repository list <project> --json
luge-cli project repository update <project> <ref_id> --subdirectory "" --no-primary
luge-cli project repository prs <project> <ref_id> --state merged --page 2 --json
luge-cli project repository remove <project> <ref_id>
luge-cli conversation pr add <conversation_id> https://github.com/acme/api/pull/7
luge-cli conversation pr list <conversation_id> --json
luge-cli conversation pr remove <conversation_id> <ref_id>

options and prs accept --page and --limit (1–100). Respect has_more, even when a filtered PR page is empty. PR states are open, closed (unmerged), merged, and all. The repository's PR list and a conversation's manually associated PRs are separate: attaching a PR never changes the conversation's project subject. Removing a link never modifies GitHub or any other container.

Discuss a selected GitHub PR

conversation pr discuss <url> --project <id> opens/resumes a private discussion; --conversation <id> resumes a linked one and --preview only inspects it. When several conversations are returned, choose one explicitly. The JSON link opens the same PR details beside the web chat.

conversation pr show <conversation> <ref_id> reads the current details using personal OAuth. conversation pr files <conversation> <ref_id> --head-sha <sha> --base-sha <sha> --page 1 reads bounded patches for that version. Respect has_more, partial, patch_state and version_changed; do not claim a complete verified diff from provider patches. Shared conversations refuse personal context.

agent send <prompt> --conversation <id> --pr <ref_id> explicitly pins that PR's current head/base SHA to this message. It preserves the conversation's project subject. Links alone never select a PR, imply a checkout or choose a working branch.

Release files for luge-cli 0.69.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for luge-cli 0.69.0
File Size Uploaded
luge_cli-0.69.0.tar.gz 765.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for luge-cli 0.69.0
File Interpreter ABI Platform
luge_cli-0.69.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.3 MB

Release files / luge_cli-0.69.0.tar.gz

Download URL luge_cli-0.69.0.tar.gz
Size 765.9 kB
Tags Source
SHA-256 checksum
How to use checksums
4d3a35126dfd8acbc51b34c0bf768eae3eab37edae601a90a51a2ca0894e0ea9
BLAKE2b-256 checksum
How to use checksums
c39f4aacb31bdea531889a197d97eca8d2944e0d4c3f0c85c815a41ffece628a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.3

Release files / luge_cli-0.69.0-py3-none-any.whl

Download URL luge_cli-0.69.0-py3-none-any.whl
Size 516.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6336dd090cf23a9c14cbdaeaed98f63be1a184f9e77d78f88b3d4d05770ac9de
BLAKE2b-256 checksum
How to use checksums
ea07e2c04f85ce3c8abc7ee1e4b633d5d423fc0f13eaa0415f6fe6405182612d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.3

Release history Release notifications | RSS feed

0.73.0

2 release files

0.72.0

2 release files

0.71.0

2 release files

0.70.0

2 release files

0.69.1

2 release files

This release

0.69.0 This release

2 release files

0.68.0

2 release files

0.54.0

2 release files

0.53.0

2 release files

0.52.0

2 release files

0.51.0

2 release files

0.50.0

2 release files

0.49.0

2 release files

0.48.0

2 release files

0.47.0

2 release files

0.46.0

2 release files

0.45.0

2 release files

0.44.0

2 release files

0.43.0

2 release files

0.42.0

2 release files

0.41.0

2 release files

0.40.0

2 release files

0.39.0

2 release files

0.38.0

2 release files

0.37.0

2 release files

0.36.0

2 release files

0.35.0

2 release files

0.34.0

2 release files

0.33.0

2 release files

0.32.1

2 release files

0.32.0

2 release files

0.31.0

2 release files

0.28.0

2 release files

0.26.0

2 release files

0.25.0

2 release files

0.23.0

2 release files

0.20.0

2 release files

0.19.0

2 release files

0.18.0

2 release files

0.17.1

2 release files

0.17.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page