AI-agent-first CLI for self-hosted Huly.
huly is a unified command-line interface for the Huly platform. It wraps
the Huly SDK into scriptable commands so you can automate workspace tasks,
integrate Huly into CI/CD pipelines, or operate Huly from agents without
a browser.
This README is the canonical reference. It is intentionally long so you can
find what you need without leaving the docs. The CLI's own --help output
is concise by design; this document expands it with examples, caveats, and
the rationale behind design decisions.
- Why huly-cli
- Installation
- Agent Skill (LLM agents / OpenClaw)
- Configuration
- Authentication
- Global flags
- Output modes
- Ref resolution
- Command reference
- login / signup / whoami
- workspace
- user
- project
- issue
- component
- milestone
- issue-template
- comment
- channel
- dm
- thread
- card
- card-space
- master-tag
- action
- document
- teamspace
- calendar
- schedule
- time
- space / space-type
- association / relation
- project-type / task-type / issue-status
- activity
- notification
- approval
- Common workflows
- Platform behaviors & best practices
- CLI behaviors and smart defaults
- Operational tips
- Output mode reference
- Class ID reference
- Plugin / model surface map
- Escape hatches
- Internal architecture
- Environment variables reference
- Security model
- Node compatibility
- Development
Huly's web UI is great for interactive use. The SDK is great for programmatic
use. huly-cli bridges them for shell-and-script use cases:
- Shell pipelines: pipe
huly issue list --jsontojq,xargs, etc. - CI/CD: log issues from CI failures, link commits, close on merge
- Agents: any LLM can drive the CLI; no browser, no Playwright
- Cron/automation: daily backups of comments, scheduled cleanup, audits
- Cross-workspace ops: bulk-move issues between workspaces
- Offline scripting: write ops as bash scripts, version them in git
The CLI mirrors the platform's domain model — projects, issues, channels,
calendar events — so there's a 1:1 mapping between huly <surface> <verb>
and the underlying SDK calls.
npm i -g @iamcoder18/huly-cli
huly --version# pnpm
pnpm add -g @iamcoder18/huly-cli
# yarn (classic)
yarn global add @iamcoder18/huly-cli
# yarn (berry / modern)
yarn global add @iamcoder18/huly-cli
# bun
bun add -g @iamcoder18/huly-cligit clone https://github.com/IamCoder18/huly-cli.git
cd huly-cli
pnpm install
pnpm --filter @iamcoder18/huly-cli build
pnpm --filter @iamcoder18/huly-cli start -- --versionThe repo's bin/huly script wraps node dist/index.js "$@", so it's
also fine to add bin/ to PATH directly.
Tested on Node 22.11 and Node 24. Node 20 lacks some crypto features
the SDK uses. Node 26 fails the rush build check (this repo's build chain
version-checks Node major).
If you must run Node 26, set:
export RUSH_ALLOW_UNSUPPORTED_NODEJS=1node>= 22.11npm>= 9- A Huly server reachable from where you run the CLI
- Credentials for at least one Huly account
In addition to being a CLI, huly-cli ships a drop-in Agent Skill — a curated SKILL.md plus a references/ bundle that teaches an LLM coding agent (or OpenClaw) how to drive your Huly workspace end-to-end without a browser. The skill encodes the surface map, the cascade side effects (Issue ↔ Action state machine, WorkSlot mirrors, parent-chain reportedTime recompute), the ref-resolution order, and the right command for each user intent — so the agent doesn't have to rediscover them.
For AI coding agents (Kilo Code, Cursor, Claude Code, etc. — anything that consumes the open skills package format):
npx skills add IamCoder18/huly-cliFor OpenClaw:
openclaw skills install @iamcoder18/hulyThe install gives the agent the skill's SKILL.md and references/*.md so it can pick the correct surface on the first try. The skill assumes the huly CLI itself is already installed and authenticated — see Installation above and Configuration / Authentication below.
No proactive check is needed — the skill instructs the agent to proceed with your request normally and only run setup if a huly command fails. If the CLI is missing or credentials are invalid, the agent will install the CLI and prompt you to configure credentials — see Configuration and Authentication.
The canonical source for the skill lives in this repo at packages/huly-skill/SKILL.md, with per-surface deep dives under packages/huly-skill/references/. It is published in lockstep with the CLI.
Configuration comes from (in precedence order):
- CLI flags (highest)
- Shell environment variables
~/.config/huly/.env(loaded automatically if present)
| Path | Purpose |
|---|---|
~/.config/huly/.env |
Login + URL config (mode 0600 recommended) |
~/.config/huly/credentials.json |
Cached JWT tokens (mode 0600) |
~/.config/huly/active-workspace |
Last-used workspace name |
The CLI creates these on first run. Deleting credentials.json forces
re-login on the next invocation.
export HULY_URL=https://huly.example.com
export HULY_EMAIL=you@example.com
export HULY_PASSWORD=your-passwordexport HULY_URL=https://huly.example.com
export HULY_TOKEN=eyJ0eXAiOiJKV1Q... # pre-issued account JWT, skip login
export HULY_WORKSPACE=production
export HULY_PROJECT=BACKEND # for bare-number issue refs
export HULY_NONINTERACTIVE=1 # disable all promptsThe CLI supports three auth modes:
huly login
# prompts for password if HULY_PASSWORD is unsethuly login --headless
# reads HULY_EMAIL + HULY_PASSWORD from env only
# never promptsexport HULY_TOKEN=eyJ0...
huly whoamiUseful for service accounts and CI where you don't want a stored password.
After login, the CLI stores the account token and workspace tokens
in ~/.config/huly/credentials.json. Each subsequent invocation reuses
the cache until tokens expire.
# clear cache
rm ~/.config/huly/credentials.json
# verify cache contents
cat ~/.config/huly/credentials.json | jq .There's no huly logout command. Either:
rm ~/.config/huly/credentials.jsonOr unset the tokens in the file. Logout is intentionally manual so you don't accidentally drop credentials during a long automation run.
Create a new account directly:
huly signup --email you@example.com --password '***' --first You --last Name
huly signup --headless # uses HULY_* env vars, no prompts
huly signup --email ... --password ... --create-workspace my-ws # signup + workspaceOn selfhost the signup endpoint is open. On hosted/invite-only deployments
the account server may reject uninvited signups — in that case use an
invite link (huly workspace access-link --role GUEST).
These flags work on every command. They may be placed before or after the subcommand:
huly --workspace prod issue list
huly issue list --workspace prod # equivalent| Flag | Description |
|---|---|
--url <url> |
Server URL (overrides HULY_URL) |
--workspace <name> |
Active workspace (overrides HULY_WORKSPACE). Name or UUID. |
--json |
Output machine-readable JSON |
--ci |
Alias for --json. Same effect; signals non-interactive intent. |
--markdown |
Output body content as raw markdown (skips markup resolution) |
--dry-run |
Print the tx that would be applied, do not apply |
--minimal |
Skip smart defaults (no auto-Teamspace, no auto-IssueStatus) |
-y, --yes |
Skip confirmation prompts (required for destructive ops) |
--non-interactive |
Same as --yes + disable any interactive prompts |
- A flag on the subcommand overrides the flag on the parent
- A flag after the subcommand overrides the flag before
--workspace prod issue list≡issue list --workspace prodhuly login --workspace prodis a no-op — login is workspace-independent
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic error (uncaught exception, network failure, etc.) |
| 2 | Validation error (missing required arg, invalid ref, etc.) |
| 3 | Not found (ref doesn't exist) |
| 4 | Forbidden (insufficient permissions) |
| 64 | Usage error (no command given, unknown subcommand) |
All errors are exit-coded; pipe-friendly. set -e works as expected.
Designed for humans. Auto-sizes columns, truncates long fields, hides uninteresting ones:
ID NAME DESCRIPTION _ID
──── ───────── ─────────────────────── ────────────
TSK Default Default project faultProject
DEMO Demo Demo project emoProject
Full objects, arrays for lists. Designed for jq / xargs:
[
{
"_id": "tracker:project:DefaultProject",
"_class": "tracker:class:Project",
"name": "Default",
"identifier": "TSK",
"description": "Default project",
"private": false,
"archived": false,
"members": [],
"modifiedBy": "core:account:System",
"modifiedOn": 1782697470759
}
]Identical to --json. Use --ci in shell scripts to signal "I expect
machine-readable output, do not prompt for input" — helps future maintainers
understand intent. (Currently no behavioral difference; reserved for future
strict-mode behavior.)
For resources that have body content (documents, comments, channel messages,
issue descriptions), --markdown returns the raw markdown text instead
of a table:
huly document get <ref> --markdown
# prints: # Hello\nThis is the document body in markdown.The CLI's read path catches markup resolution failures and falls back to
returning the raw body string. If a doc was created with the CLI (which
stores bodies as raw strings), --markdown returns that string verbatim.
References to documents can be specified in several ways. The CLI tries each in order:
The full class-prefixed ID. Always works, slowest:
huly issue get tracker:issue:6a41527f12a078ec98cf64d5For issues: <PROJECT_IDENTIFIER>-<NUMBER>. Resolved via the local index
of issues:
huly issue get TSK-1If HULY_PROJECT is set, bare numbers resolve against that project's
issues:
export HULY_PROJECT=TSK
huly issue get 1 # equivalent to TSK-1Case-insensitive match on the document's title. Used for documents, teamspaces, projects, etc. (not issues):
huly document get "My design doc"- Check if it matches
_idregex (<prefix>:<prefix>:<id>) - Check if it matches prefixed issue form (
[A-Z]+-\d+) - Check if it's a bare number with
HULY_PROJECTset - Look up in the local class index (built from prior
findAll) - Try
findOneby name/title - Throw
NotFoundwith candidate suggestions
The local index is invalidated automatically after writes to the same class. Cross-class writes (e.g. updating an issue doesn't invalidate the project index) require a fresh process.
This section documents every command in detail. Commands are grouped by top-level resource.
huly login # interactive
huly login --headless # env-only
huly signup --email ... --password ... --first ... --last ...
huly signup --headless # uses HULY_* env vars, no prompts
huly signup --create-workspace my-ws # signup + first workspace
huly whoami # show current account + workspace
huly whoami --json # machine-readablewhoami output:
URL: https://huly.example.com
Account: you@example.com
Workspace: production (uuid=..., mode=active)
Workspace-level operations.
huly workspace list # list all accessible workspaces
huly workspace current # show current workspace
huly workspace use <name> # set active workspace
huly workspace create --name X # create (requires --yes)
huly workspace delete --yes # delete current (requires --yes)
huly workspace delete --yes --force # delete active workspace
huly workspace info # show uuid, region, mode
huly workspace members # list members (OWNER role required)
huly workspace member add <account> --role MAINTAINER # add / change role (requires OWNER)
huly workspace rename <new-name> # rename current
huly workspace guests --read-only true # toggle guest read-only
huly workspace guests --sign-up true # toggle guest sign-up
huly workspace access-link --role GUEST # create invite link
huly workspace regions # list available regionsThe pair sides (workspace member remove, workspace member list) are
intentionally not exposed as subcommands. List via workspace members
(filter with --role Owner / --role Guest); remove via the account-server
UI or the accountClient SDK call directly.
Destructive: delete requires --yes. Deleting the active workspace
additionally requires --force.
Permissions: delete, member, rename, guests, access-link
require OWNER role. info, members, list, use, current, regions
require membership.
Account-level identity operations.
huly user get # current user profile
huly user get --ref <uuid> # by account uuid
huly user update --city "Berlin" # update profile fields
huly user find <email> # look up by email (returns personUuid)user find resolution order:
- Try
accountClient.findPersonBySocialKey(account-level) - Fall back to workspace-local
Personscan (name match)
Both paths may fail if the user is not in your workspace.
Tracker project operations.
huly project list [--limit N] [--offset N]
huly project get <ref> # by identifier, name, or _id
huly project create --name X --identifier BACKEND [--description] [--private]
huly project update <ref> --set description="..."
huly project update <ref> --set description=null # clear field
huly project update <ref> --set private=true
huly project delete <ref...> [--yes]
huly project statuses --project TSK
huly project target-preferences --project TSK
huly project target-preference upsert --project TSK --key ... --value ...Identifier rules:
- Must be uppercase letters and digits only
- 1-5 characters typical
- Unique per workspace (CLI pre-checks for duplicates; this selfhost's server does not enforce uniqueness server-side)
--set semantics: Pass key=value to set, key=null to clear.
Anything else is left unchanged.
Best practices & side effects:
deleteis destructive: cascade-deletes allIssue,Component,Milestone, andIssueTemplatein the project (OnProjectRemove). Usehuly project get <ref> --jsonfirst to inspect the project.- The CLI does not expose project/status/task-type creation. Custom space types and custom task types can only be applied to new projects — you cannot migrate an existing project to a different type.
- New projects are created with
ProjectType.classic = true(Tracker default); Recruit/Lead space types setclassic: false, which disables the issue/todo cascade automation. See Platform behaviors & best practices.
Tracker issue operations — the most-used surface.
huly issue list [--project TSK] [--status <name>] [--status-category Active]
[--assignee <email>] [--label bug] [--parent <ref>|null]
[--description-search <q>] [--limit N] [--offset N]
huly issue get <ref> [--markdown] # by prefixed (TSK-1), bare (1), or _id
huly issue create --project TSK --title "..." [--description] [--body <md>]
[--body-file <path>] [--status <name>] [--priority <p>]
[--assignee <email>] [--label bug --label auth]
[--due 2026-07-01T14:00:00Z] [--parent <ref>]
[--task-type <name>]
huly issue update <ref> --title "..." # any combination of updatable fields
huly issue delete <ref...> [--yes]
huly issue preview-delete <ref...> # show what delete would affect
huly issue label <ref> add <name>
huly issue label <ref> remove <name>
huly issue relation <ref> add <type> <targetRef> # type: blocks|isBlockedBy|relatesTo
huly issue relation <ref> remove <type> <targetRef>
huly issue relation <ref> list
huly issue link-document <ref> <docRef>
huly issue unlink-document <ref> <docRef>
huly issue move <ref> --parent <parentRef> # set parent
huly issue move <ref> --parent null # clear parent
huly issue related-targets --project TSK
huly issue related-target set --project TSK --source <ref> --target <ref>Status categories: UnStarted | ToDo | Active | Won | Lost. Used by
the kanban-style status filters.
Priorities: Urgent | High | Normal | Low | None. Server-side enum;
the CLI auto-matches case-insensitively.
Body format: Markdown. Stored as raw string.
--markdown on get: returns raw body string. For CLI-created
documents (which store raw strings), this works correctly. For web-UI-created
documents with markup refs to y-docs, the ref string is returned instead.
Best practices & side effects: assigning an issue or changing its status
may auto-create, auto-close, or auto-rollback attached ProjectToDos and
auto-advance the issue's status when the first WorkSlot starts (classic
projects only). See Platform behaviors & best practices
for the full cascade table. To inspect side effects after a mutation, use
huly action list --issue <ref> and huly issue get <ref> --json.
huly component list --project TSK
huly component get <ref>
huly component create --project TSK --label "Backend"
huly component update <ref> --label "New Name"
huly component delete <ref...> [--yes]Best practices & side effects: delete cascades — every issue that has
this component gets component: null set automatically (orphans are detached,
not deleted). See
Platform behaviors & best practices.
huly milestone list --project TSK
huly milestone get <ref>
huly milestone create --project TSK --label "v1.0" [--target-date 2026-08-01] [--description <text>]
huly milestone update <ref> [--label <name>] [--description <text>] [--target-date 2026-08-15] [--status <s>]
huly milestone delete <ref...> [--yes]Best practices & side effects: milestones are project-locked; you cannot
transfer a milestone to another project after creation. delete cascades —
all issues referencing the milestone get milestone: null.
huly issue-template list --project TSK
huly issue-template get <ref>
huly issue-template create --project TSK --title "Bug template"
huly issue-template update <ref> --title "..."
huly issue-template delete <ref...> [--yes]
huly issue-template add-child <templateRef> <childRef> # template refs can include other templates
huly issue-template remove-child <templateRef> <childRef>huly comment list --issue <ref> # issue can be TSK-1 or full _id
huly comment add --issue TSK-1 --body "Looking into this"
huly comment add --issue TSK-1 --body-file ./comment.md
huly comment update <commentRef> --body "Updated text"
huly comment delete <ref...> [--yes]Best practices & side effects: issue comments are stored as
ChatMessages in the issue's comments collection. Sending a comment auto-adds
the author (and any @mentioned users) as collaborators on the issue, and
emits an inbox notification per collaborator. Delete cascades — any
InboxNotification attached to the deleted comment is removed.
huly channel list [--archived]
huly channel get <ref>
huly channel create --name "engineering" [--description] [--topic "..."] [--private] [--auto-join] [--members <email...>]
huly channel update <ref> [--name] [--topic "..."] [--description] [--private true|false] [--auto-join true|false]
huly channel delete <ref...> [--yes]
huly channel archive <ref> [--value false] # value=false to unarchive
huly channel unarchive <ref>
huly channel members <ref>
huly channel join <ref> # join self
huly channel join <ref> --member alice@... # join specific user
huly channel leave <ref>
huly channel add-member <ref> alice@... # one or more members
huly channel remove-member <ref> alice@...
huly channel message list <channelRef>
huly channel message send <channelRef> --body "hello" [--body-file <path>]
huly channel message update <channelRef> <messageRef> --body "edited" [--body-file <path>]
huly channel message delete <channelRef> <messageRef...> [--yes]Note: unlike huly dm, channel commands don't expose flat-form aliases
(huly channel message create and huly channel message get are intentionally
not provided). Use huly channel message send / huly channel message list
respectively. To fetch a specific message by _id:
huly channel message list engineering --json \
| jq '.[] | select(._id == "chunter:class:ChatMessage:<id>")'Best practices & side effects:
- Sending a message auto-adds the sender as a channel member (
$push: members). - The sender and every
@-mentioned person in the message body are auto-added asCollaborators on the channel, and each gets an inbox notification (subject to their notification provider settings). #generaland#randomare auto-created when a workspace is created.archiveon these requiresSpaces AdminorWorkspace Owner.--private truekeeps the channel listed in the sidebar; users must request access. Use a DM (not a channel) for hidden conversations.- Channel
auto-joinonly affects future workspace members, never retroactively adds existing ones. See Platform behaviors & best practices.
Direct messages.
huly dm list # list DM spaces
huly dm create --person alice@example.com # create 1:1 DM
huly dm create --members a@... --members b@... # group DM
huly dm message list <dmRef>
huly dm message send <dmRef> --body "hi"
huly dm message send <dmRef> --person alice@... --body "hi" # auto-creates DM
# aliases:
huly dm messages <dmRef>
huly dm send <dmRef> --body "hi"Best practices & side effects: sending a DM message parses @mentions
from the markup and creates per-recipient inbox notifications; the mentioned
person is auto-added as a Collaborator on the underlying DM space. Use a DM
(or group DM) rather than a private channel if you want a conversation that
isn't listed in the channel sidebar. "Close conversation" hides from the
sidebar but preserves message history.
Replies to chat messages (channel messages or DM messages).
huly thread list <targetRef> # target = channel + message _id, or just message _id
huly thread add <targetRef> --body "reply" [--body-file <path>]
huly thread update <replyRef> --body "edited" [--body-file <path>]
huly thread delete <replyRef...> [--yes]Best practices & side effects: thread replies attach to the parent
ActivityMessage and auto-push the author into repliedPersons[] (unless
already present); the parent message's lastReply is updated to the reply's
modifiedOn. The author and @-mentioned persons in the reply body receive
inbox notifications. Replying to a Telegram notification appears here as a
thread reply.
Card module (separate from tracker issues).
huly card list
huly card get <ref> [--markdown]
huly card create --master-tag <name|ref> --title "..." \\
[--card-space <ref>] [--parent <ref>] \\
[--description <text>] [--body <md>] [--body-file <path>]
huly card update <ref> [--title] [--description] [--body] [--body-file] [--replace-content]
huly card delete <ref...> [--yes]Master-tag: cards MUST have a master-tag. The CLI resolves name or
ID. Use huly master-tag list to see available tags. First-card setup
typically requires using the web UI once to create a master-tag, since
the CLI doesn't expose master-tag creation.
huly card-space list
huly card-space get <ref>
huly card-space create --name "Engineering" [--description] [--private]
huly card-space delete <ref...> [--yes]huly master-tag list # read-only on CLIhuly action list [--owner email@...] [--issue <ref>] [--title <q>]
[--priority High|Medium|Low|NoPriority|Urgent]
[--visibility public|busy|private]
[--due-from <iso>] [--due-to <iso>]
[--completed true|false|all] [--limit N] [--offset N]
huly action get <ref>
huly action create --title "..." [--description] [--body] [--body-file] \\
[--due <iso>] [--priority <p>] \\
[--owner email@...] [--attached-to <ref>] [--attached-to-class <class>]
huly action update <ref> [--title] [--description] [--body] [--body-file]
huly action complete <ref> # sets doneOn=now
huly action reopen <ref> # clears doneOn
huly action schedule <ref> # creates a WorkSlot for the task
huly action unschedule <ref> # removes WorkSlots for the task
huly action delete <ref...> [--yes]--completed filter: true|false|all (default all). true / false
match the value of doneOn; all returns both.
Priority: accepts any of Urgent | High | Medium | Low | NoPriority.
Match is case-insensitive. Unknown priorities throw NotFound.
Best practices & side effects:
--attached-to <ref>+--attached-to-class tracker:class:Issueattaches the todo to one parent only. Unlike server-auto-created todos (which usecreateTxCollectionCUDand live under both the issue andtime.space.ToDos), a CLI-created todo appears under the issue but not in the assignee's personal todo list. Use--owner <email>to additionally pointuserat a person, or omit--attached-toentirely to attach the todo to aPerson.complete/deletemay trigger issue status rollback or advance (when the todo is attached to an issue).scheduleon aBacklog/Todoissue-attached todo can auto-advance the issue's status to the nextActivestate. See Platform behaviors & best practices.
huly document list
huly document create --title "..." [--body <md>] [--body-file <path>] \\
[--teamspace <name|id>] [--parent <ref|title>]
huly document update <ref> [--title] [--body] [--body-file]
[--old-text] [--new-text] [--replace-all] [--archived]
huly document delete <ref...> [--yes]
huly document snapshots <ref> # list version snapshots
huly document snapshot <ref> # get a specific snapshot (by --snapshot-id)
huly document inline-comments <ref>--body vs --old-text/--new-text: These are mutually exclusive.
Full body replace with --body; targeted substitution with --old-text
--new-text. The substitution throws if--old-textappears 0 times (unless--replace-all).
Auto-teamspace: On first document create in a workspace with no
teamspaces, the CLI auto-creates a default General teamspace.
Best practices & side effects:
- Body is stored as raw Markdown.
- Any
@mentionin the body creates a backlink and an inbox notification for the mentioned user (subject to their notification prefs). - Documents created from
huly document createare nested under a teamspace; if you want flat-by-Type organization, use cards instead (seecards/docs). - For controlled documents,
--statetransitions are gated by an approval workflow: Author → Reviewer → Approver e-signatures are enforced in that order, and inline comments must be resolved before approval.
Document teamspaces.
huly teamspace list
huly teamspace get <ref>
huly teamspace create --name "Engineering" [--description] [--type public|private] [--private]
huly teamspace update <ref> [--name] [--description]
huly teamspace delete <ref...> [--yes]Calendar events, recurring events, and calendars.
huly calendar calendars # list calendars (NOT events)
huly calendar create-calendar --name "Work" [--description] [--private] [--access owner|team|public]
huly calendar delete-calendar <ref>
huly calendar list # list events
huly calendar get <eventRef> [--markdown] # events have --markdown body
huly calendar create --title "..." [--start ISO] [--end ISO] [--attendee email@...]
[--location] [--all-day] [--description] [--body <md>]
[--calendar-id <ref>] [--rrule "FREQ=DAILY;COUNT=3"]
huly calendar update <eventRef> [--title] [--start] [--end] [--attendee]
huly calendar delete <eventRef...> [--yes]
huly calendar recurring # list recurring event definitions
huly calendar recurring-instances <recRef> # list materialized instancesDate format: ISO 8601 with timezone, e.g. 2026-07-01T14:00:00Z.
The CLI does not parse natural-language dates — use date -u -d "..."
or similar to generate.
RRULE format: iCalendar RFC 5545, e.g. FREQ=DAILY;COUNT=3,
FREQ=WEEKLY;BYDAY=MO,WE,FR. Use recurring-instances to see what got
materialized.
calendars vs get: confusingly, calendar get <ref> returns
EVENTS (not calendars). To fetch a calendar's metadata, use
calendar calendars --json and grep for _id.
Calendar schedules (owner availability).
huly schedule list
huly schedule create --title <t> --owner <userUuid> --time-zone <tz> \\
[--description <text>] [--duration 30] [--interval 15]
huly schedule update <ref> [--title] [--description] [--time-zone] [--duration] [--interval]
huly schedule delete <ref...> [--yes]--owner: UUID of the account that owns the schedule (typically the
current user). Resolve via huly user get --json | jq -r '._id'.
Time tracking on issues.
huly time list [--issue <ref>] [--start <iso>] [--end <iso>] [--limit N] [--offset N]
huly time log --issue TSK-1 --minutes 30 --description "did thing"
huly time log --issue TSK-1 --hours 2 --description "pair programming"
huly time report <issueRef> # per-issue summary
huly time delete <entryRef...> [--yes]Note:
time reporttakes a single positional issue ref. Earlier revisions of this README mistakenly documented--from/--to/--user/--projectflags here, but the CLI never accepted them — the underlying SDK method is single-issue only. For workspace-wide or date-range aggregations, usehuly time list --jsonand filter client-side, e.g.:huly time list --json | jq '[.[] | select(.date >= "2026-06-01")]'
Best practices & side effects: logging time on an issue updates that
issue's reportedTime and recomputes remainingTime. If the issue has a
parent, the change walks up the parent chain automatically (OnIssueUpdate).
There is no opt-out — script accordingly.
Core Space containers (typed buckets that hold issues, channels, projects,
etc.). Most workspaces expose this via tracker projects, calendar
calendars, and chunter channels — these commands target the raw space
documents.
huly space list [--type <id>] [--archived <bool>] [--private <bool>]
huly space get <ref>
huly space update <ref> [--name <n>] [--description <text>] [--private <bool>] [--archived <bool>]
huly space permissions <ref>
# `space members` is the management surface — the trio below take the
# required `--members <email...>` option:
huly space add-member <ref> --members <email...>
huly space remove-member <ref> --members <email...>
huly space set-owners <ref> --members <email...>huly space-type list
huly space-type get <ref>Bi-directional associations between any two docs (A↔B). Underlying
primitive for relations of kind N:N.
huly association list [--a <ref>] [--b <ref>] [--a-class <id>] [--b-class <id>]
huly association create --a <ref> --b <ref> [--a-class <id>] [--b-class <id>]
huly association delete <ref...> [--yes]Asymmetric relations (A→B with a parent side) — the underlying primitive
for tracker:class:IssueRelation. Prefer huly issue relation for the
high-level ergonomic interface.
huly relation list [--source <ref>] [--source-class <id>] [--target <ref>]
huly relation create --source <ref> --target <ref> \
[--source-class <id>] [--target-class <id>] [--name <n>]
huly relation delete <ref...> [--yes]Tracker project types (Classic, Recruit, Lead, …).
huly project-type list
huly project-type get <ref>Task types used by projects (e.g. Issue, Pull request).
huly task-type list [--project-type <ref>]
huly task-type create --project-type <ref> --label <name> [--description <text>]--project-type and --label are required.
Tracker issue statuses (the names appear in huly issue status filters
and huly project statuses).
huly issue-status create \
--project-type <ref> \
--name "Blocked" \
--category Active \
[--task-type <ref>] \
[--description <text>] \
[--rank <r>]
# alias: huly issue-statuses create ...--project-type, --name, and --category are required.
--category accepts: UnStarted | ToDo | Active | Won | Lost.
--task-type defaults to the project type's default task type when omitted.
Activity messages (ActivityMessage), reactions, replies, saved messages,
and @mention lookups.
huly activity list [--target <ref>] [--target-class <id>] [--pinned] [--limit N]
huly activity get <ref>
huly activity pin <ref> [--unpin]
huly activity react --target <ref> --emoji 👍 [--add|--remove|--list]
huly activity reply list <targetRef>
huly activity reply add <targetRef> --body "..."
huly activity reply update <replyRef> --body "..."
huly activity reply delete <replyRef...> [--yes]
huly activity saved list
huly activity saved save --target <ref>
huly activity saved unsave --target <ref>
huly activity mentionsInbox notifications, contexts, providers, types, and per-target subscribe state.
huly notification list [--read|--unread] [--archived <bool>] [--limit N]
huly notification get <ref>
huly notification mark-read <ref...>
huly notification mark-unread <ref...>
huly notification mark-all-read
huly notification archive <ref...>
huly notification unarchive <ref...>
huly notification archive-all
huly notification delete <ref...> [--yes]
huly notification unread-count
huly notification providers
huly notification types
huly notification contexts list
huly notification contexts get <ref>
huly notification contexts pin <ref> [--unpin]
huly notification contexts hide <ref> [--unhide]
huly notification subscribe --target <ref> [--target-class <id>]
huly notification unsubscribe --target <ref> [--target-class <id>]
huly notification settings list [--provider <ref>]
huly notification settings update --provider <ref> --type <ref> --enabled true|falseApproval requests attached to any target doc.
huly approval list [--status Active|Completed|Rejected|Cancelled] [--attached-to <ref>]
huly approval get <ref>
# Create an approval request (one or more approvers)
huly approval request \
--attached-to <ref> \
--requested <emails...> \
[--attached-to-class <id>] [--required-count <n>] [--tx <json>]
huly approval comment <ref> --body "..." [--decision approve|reject|comment]
# `--decision` is OPTIONAL. When omitted, the comment is a plain comment
# with no vote (same effect as `--decision comment`). The CLI accepts
# `--decision comment` to mirror the upstream enum verbatim, but the
# preferred form is to omit the flag entirely.
huly approval approve <ref> [--comment "..."]
huly approval reject <ref> --comment "..." [--rejected-tx <json>]
huly approval cancel <ref> # requester only
huly approval delete <ref...> [--yes]# Create the project
huly project create --name "Q3 Initiative" --identifier Q3I --description "Q3 goals"
# Add statuses (web UI recommended — CLI doesn't expose status creation)
# Add components
huly component create --project Q3I --label "API"
huly component create --project Q3I --label "Web"
# Add milestones
huly milestone create --project Q3I --label "v1.0" --target-date 2026-09-30
# Create the first issue
huly issue create --project Q3I --title "Set up CI pipeline" --priority High \
--assignee alice@example.com --label backendhuly issue list --status-category Won --limit 1000 --json \
| jq -r '.[]._id' \
| xargs -I{} huly issue move {} --parent null --yes# Issues created today (issue list has no --since filter; use jq)
TODAY_MS=$(date -u -d 'today 00:00:00' +%s)000
huly issue list --limit 1000 --json | \
jq -r --argjson t "$TODAY_MS" \
'.[] | select(.createdOn >= $t) | "\(.identifier): \(.title)"'
# Time logged today (time list has --start/--end date filters)
huly time list --start "$(date -u +%Y-%m-%dT00:00:00Z)" --json --limit 1000Tip:
huly time report <issueRef>is per-issue only — see the time section for the rationale.
The Huly platform does not let you change an issue's space (project) after
creation — the SDK has no method for it. The CLI exposes
huly issue move <ref> --parent <ref|null> for re-parenting inside the same
project only. To "move" issues between projects, copy them and delete the
originals:
set -e
SOURCE=OLD
DEST=NEW
IDS=$(huly issue list --project "$SOURCE" --json | jq -r '.[]._id')
for id in $IDS; do
issue=$(huly issue get "$id" --json)
title=$(echo "$issue" | jq -r .title)
desc=$(echo "$issue" | jq -r .description)
prio=$(echo "$issue" | jq -r .priority)
asg=$(echo "$issue" | jq -r '.assignee // empty')
huly issue create --project "$DEST" --title "$title" \
--description "$desc" \
--priority "$prio" \
${asg:+--assignee "$asg"} \
--yes
echo "copied: $id"
done
# Then delete the originals in a second pass (after you verify the copies):
# for id in $IDS; do huly issue delete "$id" --yes; done# Documents whose teamspace was deleted
huly document list --json | \
jq -r '.[] | select(.space == null) | ._id' \
| xargs -I{} huly document delete {} --yesThe Huly server runs server-side triggers on most transactions. This means a
single CLI command can cascade into side effects the user did not explicitly
request — auto-created ProjectToDos, inbox notifications, parent-estimate
recomputations, cascade-deletes, and more. The CLI is intentionally a thin
wrapper over the SDK, so these behaviors apply equally whether the action
came from the CLI, the web UI, or an integration.
This section catalogs the behaviors a CLI user is most likely to encounter, grouped by surface. Use it as a reference when a command produces an unexpected result.
Gating: many tracker/todo behaviors below only fire for projects whose
ProjectType.classicistrue. Default Tracker projects are classic; Recruit and Lead projects are not. There is no per-workspace toggle.
| User action (CLI) | Server-side side effect |
|---|---|
huly issue create --assignee <email> (in a classic project, status Todo/In Progress) |
Auto-creates a ProjectToDo for the assignee; sends inbox notification. |
huly issue create --assignee <email> (status Backlog/Done/Canceled) |
No auto-todo. Status must be Todo or In Progress (status category, not name). |
huly issue update <ref> --assignee <email> (assignee changes) |
Closes all open ProjectToDos on the issue (doneOn = now), then creates a new ProjectToDo for the new assignee. |
huly issue update <ref> --status <name> moving to Done/Canceled |
All open ProjectToDos on the issue are marked done. |
huly issue update <ref> --status <name> moving to Todo/In Progress + assignee set + no todos exist |
Creates the first ProjectToDo. |
huly action delete <ref> (when this is the last open todo on its issue) |
Issue status auto-rolls back to the previous un-started status in the workflow. |
huly action schedule <ref> (first WorkSlot on a todo whose issue is Backlog/Todo) |
Issue status auto-advances to the next Active status. |
huly action complete <ref> (completing the last open todo) |
May auto-advance the issue status past the last Active state (driven by IssueToDoDone mixin on classic projects). |
huly time report ... (logging time on an issue with a parent) |
Updates reportedTime / remainingTime on the issue and walks up to parents automatically (OnIssueUpdate recomputes the chain). |
huly issue update <ref> --title ... |
Propagates the new title into parentTitle on every sub-issue's parents[]. |
huly issue move <ref> --parent ... |
Rewrites the issue's parents[] chain; recomputes parent childInfo. |
huly issue create --parent <ref> |
The new issue inherits the parent's space and appears under it. |
| User action | Side effect |
|---|---|
huly action create --attached-to <issueRef> --attached-to-class tracker:class:Issue |
Attaches to one parent only. Unlike server-auto-created todos (which use createTxCollectionCUD and live in both the issue's todos collection and time.space.ToDos), a CLI-created todo is a single-parent doc — it appears under the issue but not in the assignee's personal todo list unless you also --owner <email> and omit --attached-to. There is currently no CLI flag to mimic the dual-parent shape. |
huly action update <ref> --title/--description/--visibility |
Mirrors the change to all WorkSlots of that todo (OnToDoUpdate). |
huly action complete <ref> |
Removes/crops future WorkSlots; on an attached issue, may auto-advance status (OnToDoUpdate → IssueToDoDone). |
huly action delete <ref> |
Triggers OnToDoRemove. If it was the last open todo on an issue, the issue's status rolls back. |
huly action schedule <ref> --start ... --duration ... |
Creates a WorkSlot (OnWorkSlotCreate). The first WorkSlot on an issue-attached todo can auto-advance the issue's status. The todo's visibility change mirrors to the WorkSlot (OnWorkSlotUpdate). |
huly action unschedule <ref> |
Removes WorkSlots; if the todo had a status that was auto-advanced by OnWorkSlotCreate, the rollback only happens via OnToDoRemove. |
| User action | Side effect |
|---|---|
huly project delete <ref> |
Cascade-deletes all Issue, Component, Milestone, IssueTemplate in the project; sets broadcast target filter to drop notifications (OnProjectRemove). |
huly component delete <ref> |
Every issue with that component gets component: null (orphans are detached, not deleted) (OnComponentRemove). |
huly project create --name X |
Identifier is auto-generated from the title and can be edited. Default space type is Classic project; default task type is Issue with states Backlog/Todo/In Progress/Done/Canceled. |
--auto-join on a project / channel |
Only future workspace members are added. Existing members are not retroactively added. |
huly issue-template create/delete |
Templates are project-scoped — usable only on issues in the project they were created in. |
| User action | Side effect |
|---|---|
huly channel message send / huly dm send (alias for dm message send) / huly thread add |
Auto-creates ChatMessage; sender + every @-mentioned person (parsed from the markup via extractReferences) are auto-added as core.class.Collaborator on the attached doc. On channel sends, the sender is auto-joined to the channel. Each collaborator and mention gets an inbox notification. |
huly comment add <issueRef> ... |
Issue comments are ChatMessages stored in the issue's comments collection; same auto-collaborator + auto-notification rules apply. |
huly dm send --body "@alice ..." |
@mention resolves from workspace members by display name and creates a backlink; the recipient gets an inbox notification (subject to their notification prefs). |
| New workspace | #general and #random channels are auto-created; archiving them requires Spaces Admin. |
huly channel archive |
Allowed only for the owner/creator of the channel; for the auto-created system channels (#general/#random), Spaces Admin or Workspace Owner is required. |
huly channel update --private true |
Private channels still appear in the sidebar — users must request access. Use a group DM (not a channel) for hidden conversations. |
huly dm ... "close conversation" |
Hides from sidebar; message history is preserved. |
| Inline comments on issues / docs | Not linked to inbox notifications or chat; resolving an inline comment thread deletes all comments in it (cannot be undone). |
| User action | Side effect |
|---|---|
huly document create |
Body is stored as raw Markdown string. |
huly document update --state effective (ControlledDocument → Effective) |
All older Effective versions of the same template are auto-archived; DocumentMeta.title is rewritten to "<code> <title>"; if the document has documents.mixin.DocumentTraining enabled, training.class.TrainingRequest is auto-created per trainee. |
| Edit a ControlledDocument after review | The document must be re-reviewed before it can be approved (OnDocTitleChanged/OnDocHasBecomeEffective). Inline comments must be resolved before approval. |
| Author / Reviewer / Approver e-signatures | Order is enforced: Author must sign before Reviewer/Approver can sign. |
huly document create (first in a workspace) |
A "Records" Drive is pre-created; metadata (code/prefix/category) is editable only during the initial draft phase. |
huly document update --transfer |
Requires archive rights on source + create rights on destination; doc must be in current product version. |
| Training assigned before being released | Blocked — must Release the training first. |
| Trainee exhausts max attempts | No auto-retry; a new TrainingRequest must be issued. |
| User action | Side effect |
|---|---|
| Add an attribute to one card of a Type/Tag | The field is added to all existing cards of that Type or Tag (OnCardTag mixin). |
| Define a Relation between Types A ↔ B | Bi-directional: shows up on both A and B cards automatically. |
| Define a Reference (not Relation) | One-directional on A; usable as sort/filter criterion; cannot be made back-link later. |
| Delete a Card Type | Cascade-deletes all cards of that Type — cannot be undone. |
Delete the File Type |
Refused (system type). |
| Upload a file to a File Card | The file is permanently attached — no delete. |
| Reparent a Card | Increments new parent's children, builds parentInfo[], and rolls back on cycle detection. |
| Derive a Type from another | Sub-types auto-inherit all parent properties. |
| Save a filtered Card view | Can be Public (workspace-wide) or Private (only you). |
| User action | Side effect |
|---|---|
| Invite via invite link | New joiner is automatically added as Employee (OnPersonCreate → OnEmployeeCreate). |
huly user add <email> (Employee creation) |
Sends an invite email — user can only sign up with that email. |
| Deactivate / kick an Employee | Marks inactive, retains the contact for object integrity. Re-invite via "Resend Invite" rather than re-create. |
Activate an Employee (OnEmployeeCreate) |
Creates the user's private PersonSpace, auto-joins all core.class.Space with autoJoin: true, and (for Owner role) auto-assigns ownership of any TypedSpace/CardSpace with empty owners. Also auto-creates a default Calendar ("HULY"). |
| Activate an Employee (in HR-enabled workspaces) | Auto-adds hr.mixin.Staff with department: Head; walks the department hierarchy on Staff.department change. |
| GitHub integration collaborator | Auto-created as Person contact (no workspace access unless invited separately). |
| Merge Person + Employee | Combines into one record (use when same person joins from two paths). |
| Custom contact fields | Only Contact and Task classes are customizable. Supported types: URL, String, Boolean, Number, Date, Enum. Ref and Array are not yet implemented. |
| Hide vs Remove property | Hide keeps data; Remove deletes property and data. |
| User action | Side effect |
|---|---|
Any create/update/delete via the CLI |
Emits an ActivityMessage in the doc's docUpdateMessages and a collaborator inbox notification (ActivityMessagesHandler), unless the class has the IgnoreActivity mixin or is a Card with serverCard.metadata.CommunicationEnabled. |
@mention someone in chat or a doc |
Auto-resolves to a Person ref, creates a backlink in the recipient's notifications, and the recipient gets an inbox notification subject to their per-provider prefs. |
| Telegram reply to a notification | Appears in Huly as a thread reply in the originating message. |
| Per-thread unsubscribe (three-dot → unsubscribe) | Only available in the web UI; not currently exposed via the CLI. |
| Hover-peek in inbox | Lets you preview without marking the message Read. |
Delete a ChatMessage |
Removes all InboxNotification rows whose attachedTo points at it (no dangling notifications). |
| Web-push | Sends to recipient's PushSubscriptions only when serverNotification.metadata.WebPushUrl is configured server-side. |
| Integration | Behavior |
|---|---|
| GitHub linked repo | Issues/comments/PRs sync bidirectionally; "Create issue without GitHub" override creates a Huly-only issue. |
| Gmail connected | Past emails with each contact back-fill onto the contact page on first connect. |
| Telegram | Multi-workspace requires /sync_all_channels in the bot menu; replies to notifications become thread replies in Huly. |
| Google Calendar sync | Pre-sync Huly events don't retroactively push to Google; visibility maps (Public ↔ Visible to everyone, Private ↔ Only visible to you). Disconnecting Google does not delete already-synced events. |
| Recording in a meeting | Auto-saves to Drive, visible to anyone with Drive access. |
| Live transcription (Hulia) | Currently workspace-wide visibility; privacy hardening planned. |
PublicLink create with empty url |
Server auto-fills url with a signed JWT — no CLI action needed. |
| Role | CLI-relevant limits |
|---|---|
OWNER |
Required for workspace delete, member, rename, guests, access-link. Only OWNER/Maintainer can create spaces, projects, or manage task types. |
MAINTAINER |
Cannot delete the workspace, remove owners, or change their own role. |
GUEST |
Limited to spaces explicitly flagged as Guest-accessible; can only create/update/delete in those. |
READONLY |
All write attempts rejected. |
Spaces Admin |
Can archive system channels (#general/#random). |
| TraceX roles | Qualified User, Manager, QARA for controlled-document workflows. |
Private space + autoJoin |
New workspace members auto-added regardless of explicit member list. |
| Read-only guest | A magic UUID (83bbed9a-0867-4851-be32-31d49d1d42ce) represents the global read-only guest. When workspace guests --read-only true is set, all workspace members get re-granted to this identity; their notifications are force-read; sessions get data.connection.readOnly = true. |
Per-space Permission records |
Every TxCUD is checked against the space type's Permission matrix. There is no global role override; granting rights is per-space. |
| Disabling RBAC | Settings → General → disable RBAC for the whole workspace. Useful for scripting tests; do not leave enabled in production. |
| Behavior | Notes |
|---|---|
--rrule accepted on huly calendar create |
iCalendar RRULE string (e.g. FREQ=DAILY;COUNT=3). Server coerces BYDAY/BYMONTH/BYMONTHDAY/BYSETPOS to numeric arrays. |
| Recurring exceptions (EXDATE) | Not implemented. The ReccuringEvent model has no exdates field; exception dates are silently ignored. There is no UI to skip a single occurrence. |
| Recurring instance model | Each instance is a ReccuringInstance carrying recurringEventId + originalStartTime. To list instances, query by recurringEventId. |
blockTime defaults to false |
Events don't block the user's calendar by default. Pass --block-time to set. |
visibility mapping for Google sync |
Google transparency:transparent ↔ Huly visibility:freeBusy; Huly private ↔ Google private. |
| Visibility levels | public (everyone sees title+time), freeBusy (only "Busy"), private (only you). Title is always shown to those with view rights. |
--time-zone defaults to UTC |
For recurring events, the RRULE is evaluated in the given TZ. Pass --time-zone America/New_York etc. |
WorkSlot visibility mirrors to event |
Changing WorkSlot.visibility mirrors back to the parent ToDo and derived calendar events. |
| Behavior | Notes |
|---|---|
| Full-text backend | Elasticsearch. fulltextSummary field is concatenated from markup text + all isFullTextAttribute fields + link preview metadata. |
| Searchable fields | Determined by FullTextSearchContext per class; default includes title, description, body. Custom attribute opt-in via isFullTextAttribute: true. |
| Operators | No Huly-specific DSL. ES query_string passes through: AND, OR, NOT, +, -, "…", *, ~, field:value. The CLI does not wrap queries. |
| Index cap | fulltextSummary capped at textLimit (~1 MB); huge bodies are truncated server-side. |
| Reindex | fullReindex workspace event triggers a clean rebuild (the CLI has no direct hook — you can call huly ws with {"method":"triggerReindex","params":[...]} if needed). |
domain: fulltext-blob is excluded from backups |
Transient; not restorable. |
| Indexing is per-workspace | Each workspace gets its own pipeline; queries are workspace-scoped. |
| Behavior | Notes |
|---|---|
| Concurrency model | Optimistic locking via modifiedOn / modifiedBy. No version counter. Last write wins. |
| Y-doc collaborative fields | Concurrent edits to rich text merge via Y.js CRDT (per-character). |
| Audit trail | The tx domain IS the audit log. Every TxCreateDoc/TxUpdateDoc/TxRemoveDoc/TxMixin is persisted with modifiedBy/modifiedOn/objectId. Query it: huly ws findAll core.class.Tx --json | jq '.[] | select(.objectId=="…")'. |
| Activity feed | User-visible summaries built from the tx stream by ActivityMessagesHandler. Excludes ActivityMessage/InboxNotification/DocNotifyContext. |
| Soft delete | Card.removed:boolean, Project.archived, Vacancy.archived, Document.state ∈ {Deleted, Obsolete, Archived}. Other entities are hard-deleted (TxRemoveDoc). |
| Workspace states | pending-creation → creating → active; pending-upgrade → upgrading → active; pending-deletion → deleting; archiving-* chain; migration-* chain; pending-restore → restoring → active. |
WS_OPERATION env var (server-side) |
all (default) covers pending-creation + pending-upgrade; all+backup adds pending-deletion, archiving, migration, restoring. For selfhost single-pod, set all+backup on the workspace pod. |
| Read-only guest data | The CLI's resolver cache is client-scoped via a WeakMap<PlatformClient, …>, so each connected workspace gets its own cache automatically and entries die with the connection. No cross-workspace data leakage. |
| Behavior | Notes |
|---|---|
| Adding attribute to one Card | Adds to every Card of that Type or Tag (OnCardTag). Cannot be scoped. |
| Derived Type inheritance | Sub-types auto-inherit all parent properties; intermediate mixins apply automatically. |
| Tag application | Tag properties only appear after the Tag is applied; removing the Tag drops values. |
| Relation kinds | 1:1 / 1:N / N:N. N:N is symmetric; 1:N has owner/child sides. Relations are bi-directional, References are not. |
| Reference vs Relation | Reference is a one-directional attribute, filterable and sortable. Relation is a separate Relation doc with cardinality rules. |
| Reproving Cards | Card Type can be re-assigned post-creation (re-organization without data loss). |
| Card hierarchy cycle detection | Reparenting a Card walks up the parent chain; cycles are detected and the tx is rolled back. |
| File Type undeletable | The default File MasterTag cannot be deleted; uploaded files on File-Cards are permanent. |
| Drive versioning | Re-uploading onto an existing file creates a new version automatically. All versions listed under the original. |
| Default Drive | Every new workspace ships with one Drive named Records. |
| Mermaid | Slash command → Diagram; valid MermaidJS auto-renders below editor. Press Delete to remove. |
| Drawing board | Slash command → Drawing board; multi-user real-time. Clear is irreversible. Scribble history tracks who drew what. |
| Backlinks | Paper-clip icon (top-right of doc) opens panel of every @mention pointing to the doc. |
| Notes on highlights | Highlight text → Note icon → color; persists as inline note. Re-highlight to edit. |
| Inline comments vs Activity comments | Inline comments are isolated to the doc/issue and DON'T notify. Resolving a thread deletes all replies. |
| Saved messages in chat | Bookmark any message → appears in Saved tab in Chat sidebar. |
[] action items in docs |
Typing [] at line start inserts a checkbox; assigning it creates a Planner todo + sends notification. |
The CLI silently applies several defaults and auto-creations to keep common flows one-liners. This section catalogs them all so you know what the CLI will do when you don't.
| Command | What gets auto-created | When |
|---|---|---|
huly document create |
A General teamspace (type space-type:default, members [], description "Default teamspace (auto-created)") |
Workspace has zero teamspaces. |
huly issue create |
5 default IssueStatus records (Backlog, To do, In progress, Done, Canceled) in core:space:Model |
Workspace has zero IssueStatus. |
huly issue create |
First ProjectToDo (classic projects only) |
--assignee set, status Todo/Active. See cascade table. |
huly dm create --person <email> / huly dm send --person <email> |
A DM with that person (resolves via resolvePersonId) |
No existing DM with that person. |
huly issue label add <ref> --label <name> |
A TagElement in tags:space:Tag (first TagCategory) |
Label doesn't exist yet. |
huly project create |
The current user is added as a members: [<uuid>] |
Always, unless --minimal. |
huly calendar create |
A new Calendar doc |
Always; --type public|private defaults to public. |
huly action create |
If --attached-to omitted, the task is attached to the owner's Person (or current user) |
Default. |
Note:
huly issue createre-tries the auto-seed on the second call if the first failed silently (model-load race). If the issue create keeps failing on a fresh workspace, run any other issue-list command first to nudge the model.
| Command | Flag | Default |
|---|---|---|
huly project create |
--sequence |
0 |
huly project create |
--members |
[<current-user-uuid>] |
huly project create |
--description |
'' (omitted with --minimal) |
huly issue create |
--status |
Lowest-rank IssueStatus (usually Backlog) |
huly issue create |
--priority |
Normal if it exists in the workspace; else first priority; else omitted |
huly issue create |
--task-type |
tracker:issue:default |
huly issue create |
parent |
null (top-level), unless --minimal |
huly issue create |
space |
project._id (unless --minimal) |
huly calendar create-calendar |
--access |
public (one of owner / team / public) |
huly calendar create-calendar |
--private |
false |
huly schedule create |
--duration |
30 (minutes) |
huly schedule create |
--interval |
15 (minutes) |
huly action create |
--priority |
NoPriority |
huly action create |
--visibility |
public |
huly action create |
--owner |
Current user |
huly action create |
--attached-to-class |
contact:class:Person |
huly action create |
--due |
none (dueDate: null) |
huly action create |
doneOn |
null |
huly action create |
rank |
0|aaaaa: |
huly time log |
--date |
Date.now() |
huly time log |
value conversion | minutes → man-hours (value = minutes/60); rounds to nearest 15 min |
huly card create |
--card-space |
card:space:Default (may not exist; create one first) |
huly teamspace create |
--type |
public |
huly card-space create |
--private |
false |
When you pass a value to a flag like --assignee, --project, --owner,
--person, --calendar, etc., the CLI tries in this order:
me/""(empty string) — resolves to current user.- Raw
_id(matches^[a-z-]+:[a-z-]+:[0-9a-f-]{36}$) — used as-is. - Prefixed form (
PREFIX-123, e.g.TSK-1,USR-42) — looked up via the index. - Bare number (
42) — uses$HULY_PROJECTenv var for project context. identifier | name | label | title(lowercased) — exact match against the index.- Substring fallback (loose
includes()match) for--assigneeonly. NOT applied to--owner— see step 6b. May produce false positives; pass an exact email/name to avoid. 6b.--owneris exact-match only —resolveEmployeeIddoes a strict===comparison againstPerson.nameandPerson.email(if the field is populated). There is no fuzzy fallback. Pass the full name or email. - Account lookup —
accountClient.findPersonBySocialKeyfor--person; falls back to workspace-localPersonscan. - Single-other-member heuristic —
resolvePersonIdin DM/Channel code picks the only other workspace member if exactly one exists. Documented for awareness; avoid relying on it.
Heads up: the substring fallback in step 6 is silently enabled for
--assigneeonly. If you pass--assignee boband there's aBob Andersonand aBob Bishop, the first alphabetical match wins. Use exact email to disambiguate.--ownerdoes NOT have this fallback — it requires an exact name or email match.
huly project update --set key=value (and huly issue update --set) coerce
values automatically:
key=null→ clears the field (sendsTxUpdateDocwithoperations[key]: null)key=true/key=false→ booleankey=<numeric string>→Numberkey=<anything else>→ string
Reserved keys (silently stripped): set, unset, json, ci, markdown, dryRun, minimal, yes, workspace, url, defaultProjectIdentifier.
| Cache | Lifetime | Invalidation |
|---|---|---|
Resolver index (PlatformClient → Map<classId, Map<key, _id>>, backed by a WeakMap) |
In-memory, no TTL; dies with the PlatformClient |
Explicit invalidateIndex(client, classId) after every write. |
Account _accounts URL cache |
In-memory, per-host | Never invalidated; restart the CLI process to refresh. |
~/.config/huly/credentials.json (account + workspace tokens) |
On disk, mode 0600, no expiry | Refreshed on re-login. Delete the file to reset. |
~/.config/huly/active-workspace |
On disk, mode 0606 | Updated on huly workspace use <name> or --workspace. |
~/.config/huly/active-account |
On disk, mode 0606 | One line per host, updated on login. |
Stale-cache gotcha: the resolver index never expires. If someone deletes or renames a project between two CLI commands in the same shell, the second command may still see the old name. Restart the CLI process (or run any write against the changed resource) to force a refresh.
Cross-workspace safety: because the cache is keyed on the
PlatformClientinstance (WeakMap), switching workspaces — even within the same process — gives you a fresh cache automatically. No risk of stale entries bleeding across workspaces.
| Path | Timeout | Fallback |
|---|---|---|
client.fetchMarkup (all --markdown reads) |
5 seconds | '(body fetch timed out)' |
ws raw command |
60 seconds | Promise rejects |
ws raw command ping |
5 seconds (interval) | --no-ping disables |
retry() helper (defined, unused) |
429 only |
500 * attempt² ms backoff, max 3 attempts |
There is no WebSocket auto-reconnect in the CLI. Each command opens a
fresh WS, runs, and closes in finally. If the connection drops mid-call,
the error bubbles up.
| Flag | Match type | Case-sensitive? |
|---|---|---|
--status (issue) |
Exact label/name | No |
--status-category |
Strips task:statusCategory: prefix, exact |
No |
--priority (issue) |
Exact label/name | No |
--task-type (issue) |
Exact label/name OR raw _id |
No |
--role (member) |
Aliases (owner|admin|guest|docguest|readonlyguest|maintainer) |
No |
--priority (todo) |
Strict enum (High|Medium|Low|NoPriority|Urgent), throws on invalid |
No |
--visibility (todo) |
Strict enum (public|busy|private) |
No |
--archived (channel/document) |
Anything that isn't 'false' or '0' → true |
n/a |
--completed (todo) |
true|false|all |
n/a |
--description-search / --content-search / --title (todo) |
MongoDB-style regex with $options: 'i' |
No |
--private (channel) |
Non-strict coercion (anything not 'false'/'0' → true) |
n/a |
| Command | Behavior |
|---|---|
huly issue create |
If the create returns duplicate/exists/already, the CLI re-runs the lookup and returns the existing issue's _id (idempotent). |
huly project create |
Pre-flight findAll({identifier}); on `already exists |
| Error | Hint |
|---|---|
PLATFORM_NOT_FOUND / not found |
"check the ref or run huly <resource> list" |
PLATFORM_UNAUTHORIZED / 401 |
"run huly login or set HULY_TOKEN" |
PLATFORM_FORBIDDEN / 403 |
"insufficient permissions" |
PLATFORM_ALREADY_EXISTS |
mapped to ExitCode.Conflict(6) |
PLATFORM_RATE_LIMITED / 429 |
mapped to ExitCode.RateLimited(5) |
PLATFORM_VALIDATION / 400 |
mapped to ExitCode.Validation(4) |
>=500 |
mapped to ExitCode.Server(7) |
Bad --status |
lists all available statuses |
Bad --priority |
lists available priorities |
ref-resolver NotFound |
shows first 10 candidates |
The CLI loads the full result set in one findAll call, then slices
in-memory with --limit/--offset. There is no server-side pagination.
For very large workspaces (>10k docs of a type), prefer filtering by
project/space/date to bound the result set before piping to jq.
Required for:
workspace createworkspace delete(active workspace also needs--force)- Any delete of ≥2 refs (
issue delete,project delete,channel delete,document delete,teamspace delete,action delete,comment delete,time delete,calendar delete,card delete,card-space delete,thread delete,channel message delete,action unscheduleof multiple slots)
NOT required for:
dm create --person(auto-creates a DM silently)dm send --person(auto-creates a DM silently)action unscheduleof a single slot- All single-ref deletes
None. Every CLI invocation opens a fresh PlatformClient /
AccountClient and closes it in finally. The SDK keeps a single WS open
for the duration of the client. This is fast (sub-second per command) but
means you cannot pipeline multiple mutations over one WS.
Lessons learned from running the CLI against a self-hosted Huly instance. These are CLI-user-facing, not server-admin-facing.
| Var | Default | Purpose |
|---|---|---|
HULY_URL |
— | Server URL (required — CLI exits with HULY_URL is required if unset) |
HULY_EMAIL |
— | Login email (used by --headless login) |
HULY_PASSWORD |
— | Login password (used by --headless login) |
HULY_TOKEN |
— | Pre-issued account JWT (bypasses login + caching) |
HULY_WORKSPACE |
— | Default workspace (URL or UUID) |
HULY_PROJECT |
— | Default project for --project and bare-number issue refs |
HULY_TEAMSPACE |
— | Default teamspace for --teamspace |
HULY_ENV_FILE |
~/.config/huly/.env |
Path to the dotenv file |
HULY_NONINTERACTIVE |
— | 1 disables all prompts |
HULY_INSECURE_TLS |
— | 1 disables TLS verification globally (sets NODE_TLS_REJECT_UNAUTHORIZED=0 + https.globalAgent.options.rejectUnauthorized = false) |
NO_COLOR |
— | Disables chalk colors |
XDG_CONFIG_HOME |
~/.config |
Base for credential/config files |
CI |
— | Triggers JSON output and disables spinner |
Precedence for global flags: flag > env > cached file. The cached
~/.config/huly/active-workspace is the lowest-priority default.
- Tokens are persisted at
~/.config/huly/credentials.json(mode 0600). - Account token + per-workspace tokens are stored separately.
- Re-login preserves existing workspace tokens when the account token is refreshed.
HULY_TOKENbypasses all caching (account-level pre-issued JWT).- The CLI will NOT re-login if a cached account token exists for the given email — this avoids clobbering workspace tokens.
- Workspace-scoped tokens are re-fetched via
selectWorkspaceon everyconnectPlatformcall.
rm -f ~/.config/huly/credentials.json \
~/.config/huly/active-account \
~/.config/huly/active-workspace
huly login --headlessWhen a workspace is being upgraded, the server allows the previous session
to multiplex for up to 30 seconds (sessionManager.reconnectTimeout).
After that window, the client is force-disconnected. The CLI doesn't
auto-reconnect — restart the command. If you see Model version mismatch,
the workspace was upgraded under you; refresh and retry.
- All
Docupdates use optimistic locking viamodifiedOn/modifiedBy. - Last write wins. There is no version counter.
- Rich-text fields (in y-docs) merge via Y.js CRDT (per-character).
- No pessimistic locks anywhere.
For workspaces with >10k issues, prefer server-side filtering by project
or status before piping to jq. The CLI does not paginate server-side;
each list command fetches the full result set then slices in-memory.
For fulltext search, use huly ws findAll with a FullTextSearchContext
query — the CLI does not wrap search syntax, so ES query string operators
(AND, OR, NOT, +, -, "…", *, ~, field:value) pass
through.
The tx domain is the audit log. To see who changed what:
huly ws findAll core.class.Tx '{"objectId":"<doc-id>","modifiedOn":{"$gte":<start-ms>,"$lte":<end-ms>}}' --jsonEach tx carries modifiedBy, modifiedOn, space, objectId, and the
full operations payload.
WORKSPACE_LIMIT_PER_USER defaults to 10 on the account pod. If you
hit it, you get WorkspaceLimitReached. Either increase the env var on
the account pod or delete some workspaces (use WS_OPERATION=all+backup
to make the worker actually clean up pending-deletion workspaces).
New plugin versions ship new model-upgrade txs. The workspace pod
applies them automatically when WS_OPERATION=all and the workspace's
version_major/minor/patch is below the current. On a fresh workspace,
this takes ~30 seconds. If findAll returns 0 for classes that should
have data, the model may not have applied yet — wait or restart the
workspace pod with WS_OPERATION=upgrade to force a re-apply.
| Command category | Default | --json |
--markdown |
|---|---|---|---|
list |
Table | Array of full objects | N/A |
get <ref> |
Table (key fields) | Full object | Body as markdown text |
create / update / delete |
One-line confirmation | { _id, created: bool, ... } |
N/A |
whoami |
Multi-line | Object | N/A |
login |
One-line | One-line | N/A |
Use --json whenever:
- You're piping to
jq,xargs, or another tool - You're writing a script that needs the
_idfield - You want to assert specific fields in CI
- You want full objects instead of truncated table rows
Avoid --json when:
- You're interactively exploring (tables are more readable)
- You want body content (use
--markdowninstead)
The platform's class hierarchy. Used as _class in JSON, as class IDs in
escape-hatch calls, and as class filters in queries.
| Plugin | Class ID pattern | Examples |
|---|---|---|
core |
core:class:* |
Account, Space, Type, Doc, Obj |
contact |
contact:class:* |
Person |
tracker |
tracker:class:* |
Project, Issue, IssueStatus, Component, Milestone, IssueTemplate, TimeSpendReport, TypeIssuePriority |
task |
task:class:* |
Task |
board |
board:class:* |
Card |
card |
card:class:* |
CardSpace, MasterTag |
calendar |
calendar:class:* |
Event, ReccuringEvent, ReccuringInstance, Calendar, Schedule |
document |
document:class:* |
Document, DocumentSnapshot, DocumentEmbedding, Teamspace |
chunter |
chunter:class:* |
Channel, ChatMessage, DirectMessage, Message, ThreadMessage |
time |
time:class:* |
ToDo, WorkSlot |
notification |
notification:class:* |
Notification, NotificationContext, InboxNotification (Phase 15 — not yet in CLI) |
activity |
activity:class:* |
ActivityMessage, Reaction, SavedMessage (Phase 14 — not yet in CLI) |
approval |
approval:class:* |
ApprovalRequest, Approval (Phase 16 — not yet in CLI) |
The CLI's class IDs are in src/transport/identifiers.ts. They're the
canonical reference for escape-hatch use.
For each plugin, what classes the CLI exposes and which are read-only:
| Plugin | CLI surface | Read | Write |
|---|---|---|---|
| core | (used internally) | — | — |
| contact | user |
get, find |
— |
| tracker | project, issue, component, milestone, issue-template, time |
All | All |
| task | action (alias for todo) |
All | All |
| board | card |
All | All |
| card | card-space, master-tag |
All | card-space only (master-tags are read-only) |
| calendar | calendar, schedule |
All | All |
| document | document, teamspace |
All | All |
| chunter | channel, dm, thread |
All | All |
| time | (used by time commands) |
— | — |
| notification | (Phase 15 — not implemented) | — | — |
| activity | (Phase 14 — not implemented) | — | — |
| approval | (Phase 16 — not implemented) | — | — |
When a CLI command doesn't exist for what you need, use the raw RPC escape hatches. These pass through directly to the server.
huly api GET /api/v1/version
huly api GET /config.json
huly api POST /api/v1/something --body '{"key":"value"}'
huly api GET /api/v1/things --query foo=bar --query baz=qux
huly api GET /api/v1/things --header "Authorization: Bearer ..."Available methods: GET | POST | PUT | PATCH | DELETE. The path is
appended to the workspace's API URL.
The Huly RPC protocol uses WebSocket. Use the ws command for direct
method calls:
# findAll
huly ws findAll '{"_class":"tracker:class:Project"}' '{}'
# findOne
huly ws findOne '{"_class":"tracker:class:Project"}' '{"identifier":"TSK"}'
# createDoc
huly ws createDoc 'tracker:class:Project' 'core:space:Space' \
'{"identifier":"NEW","name":"New project"}'
# tx (raw transaction)
haly ws tx '{"_class":"core:class:TxCreateDoc",...}'Method names mirror the SDK's PlatformClient interface. See
node_modules/@hcengineering/api-client/lib/client.js for the full list.
- A command exists but doesn't expose the flag you need (rare)
- A command exists but operates on a wrong sub-resource
- You're doing batch operations and need to skip validation
- You're debugging and need to see the raw server response
- The CLI doesn't support the surface you need (use the SDK instead)
src/
cli.ts # top-level command registration (1000+ LOC)
index.ts # entry point + Node shims (window, localStorage)
auth/
client.ts # login, accountClient, connectPlatform
cache.ts # token cache (credentials.json)
env.ts # env var loading
resources/
_helpers.ts # shared command helpers
_project-resolve.ts
project.ts # project CRUD
issue.ts # issue CRUD + relations + labels + moves
component.ts # component CRUD
milestone.ts # milestone CRUD
issue-template.ts
comment.ts
channel.ts # channel CRUD + members + messages
dm.ts # (in channel.ts)
thread.ts # (in channel.ts)
card.ts # card module
card-space.ts # (in card.ts)
master-tag.ts # (in card.ts)
action.ts # planner tasks
document.ts # documents + teamspaces + snapshots
teamspace.ts # (in document.ts)
calendar.ts # events + recurring + calendars + schedules
schedule.ts # (in calendar.ts)
time.ts # time tracking
user.ts # profile + person lookup
workspace.ts # workspace ops
todo.ts # (legacy todo; replaced by action)
project.parse.ts # project parsing helpers
misc.ts # misc utilities
transport/
sdk.ts # connectCli, connectAccountCli, resolveWorkspace
identifiers.ts # CLASS, CLASS_ICON, ref helpers
ref-resolver.ts # ref → Ref<Doc> resolution
output/
format.ts # table, json, kv, withTimeout
progress.ts # withSpinner
errors.ts # CliError, ExitCode
commands/
dry-run.ts # dry-run helpers
scripts/
smoke.sh # phase-based smoke test (13 phases)
docs/
HANDOVER.md # session handover
issues.md # historical issue inventory
learnings.md # detailed learnings
open-issues.md # currently-open issues (excluding verified fixes)
huly --workspace prod issue listglobalsFrom(cmd)extracts--workspace prodfrom the parsed CommandconnectCli({ workspace: 'prod' })resolves workspace name → URL/UUIDconnectPlatform(...)reads token from cache, falls back to env login- SDK opens WebSocket to transactor, loads model
client.findAll(CLASS.Issue, { ... })issues server-side query- CLI formats result as table/JSON
The SDK's processMarkup calls the collaborator's uploadMarkup RPC
on every MarkupContent instance. The CLI passes body content as raw
strings instead of new MarkupContent(body, 'markdown'); the SDK's else
branch forwards strings through unchanged. The read path
(get --markdown) wraps client.fetchMarkup in a 5-second timeout and
falls back to returning the raw body string when markup resolution fails.
This means get --markdown returns the raw body for CLI-created docs
(always correct) and the ref string for web-UI-created docs. If you need
rich-text round-trip features, use the raw escape hatch:
huly ws tx '{"method":"createDoc", ...}'.
- Loads credentials from env or
~/.config/huly/.env(mode 0600) - Caches JWTs to
~/.config/huly/credentials.json(mode 0600) - Connects over TLS to the server (no plaintext HTTP)
- Never logs tokens (not even at debug level)
- Validates server certs (no self-signed bypass)
- Does NOT handle password rotation (CLI just reads
HULY_PASSWORD) - Does NOT enforce workspace-level RBAC (the server does)
- Does NOT store secrets in source control (use
.envoutside git) - Does NOT support OAuth or SSO (password login only)
- Does NOT support TOTP / 2FA login (server-side only)
For personal use: the defaults (mode 0600) are fine.
For shared CI runners: use HULY_TOKEN with a service-account JWT, never
embed passwords. Set short TTLs on the token.
For production automation: consider a secrets manager (Vault, AWS Secrets Manager, etc.) that injects env vars at runtime.
The CLI assumes:
- The server is trusted (run it on your own infrastructure)
- The local filesystem is trusted (no other users can read ~/.config/huly/)
- The shell environment is trusted (env vars may be logged by parent processes)
If any of these don't hold, the CLI's threat model is violated.
| Node | Status |
|---|---|
| 22.11 | ✅ Recommended |
| 24.x | ✅ Works |
| 26.x | RUSH_ALLOW_UNSUPPORTED_NODEJS=1 for rush-based builds downstream |
| 20.x | ❌ Missing crypto features used by SDK |
The CLI is a TypeScript source project — it requires Node 22.11+ to run
the dev tooling. The bundled dist/index.js runs on any Node ≥ 22.11.
- TypeScript strict mode (no
anyexcept at API boundaries) - camelCase functions, PascalCase classes, SCREAMING_SNAKE constants
- One resource per file in
src/resources/ - New class IDs go in
src/transport/identifiers.ts - Help text MUST describe each flag, even if obvious
- Errors throw
CliError(ExitCode.X, msg, hint?)— never rawError
- Add the resource function in
src/resources/<surface>.ts - Add the class ID to
src/transport/identifiers.ts - Wire the command in
src/cli.ts(find the relevantprogram.command(...)) - Update
README.mdwith the new command - Run
pnpm --filter @iamcoder18/huly-cli build
This section explains how the CLI interacts with the Huly server. Useful for debugging, performance tuning, and writing automation.
The selfhost has ~16 services. The CLI talks to four of them:
| Service | What the CLI does with it |
|---|---|
account (port 3000) |
Login, workspace ops, account token management |
transactor (port 3333) |
WebSocket RPC: findAll, findOne, createDoc, updateDoc, tx, loadModel |
collaborator (port 3078) |
Read path only: fetchMarkup, getContent. The CLI's read timeout (5s) covers this. |
The CLI never talks to workspace, kvs, minio, redpanda, elastic,
cockroach, or front directly. Those are server-internal.
CockroachDB holds everything. Two schemas per workspace:
defaultdb (the account DB) — global across the cluster:
global_account.workspace— uuid, name, dataId (the workspace's DB name)global_account.workspace_status— mode, is_disabled, processing_attempts, version_*global_account.workspace_members— (account_uuid, workspace_uuid, role)global_account.account,global_account.person,global_account.social_idglobal_account.region,global_account.invite, etc.
Per-workspace DB (named after workspace.dataId):
public.tx— the transaction log (every CUD as TxCreateDoc/Update/Remove)public.tracker— Project, Issue, Component, Milestone, IssueStatus, etc.public.document— Document, DocumentSnapshotpublic.calendar— Calendar, Event, Schedulepublic.chunter— Channel, ChatMessagepublic.time— ToDo, WorkSlotpublic.card— Card, CardSpace, MasterTagpublic.contact— Personpublic.config— workspace config
To inspect a workspace's data directly:
docker exec -e PGPASSWORD=$CR_USER_PASSWORD huly_v7-cockroach-1 \
/cockroach/cockroach sql --insecure -d defaultdb -u selfhost \
-e "SELECT * FROM global_account.workspace_members LIMIT 5"Use cockroach root (cert-based) for full access:
docker exec -u root huly_v7-cockroach-1 /cockroach/cockroach sql \
--url 'postgresql://root@127.0.0.1:26257/defaultdb?sslcert=certs/client.root.crt&sslkey=certs/client.root.key&sslmode=verify-full&sslrootcert=certs/ca.crt'The Huly "model" is the sum of all classes registered in the workspace. Classes are organized into plugins (tracker, calendar, chunter, ...). Each class has a domain (storage bucket):
tracker(DOMAIN_TRACKER): Project, Issue, Component, Milestone, IssueStatus, IssueTemplate, TypeIssuePriority, TimeSpendReport, RelatedIssueTargetcalendar(DOMAIN_CALENDAR): Calendar, Event, ReccuringEvent, ReccuringInstance, Scheduledocument(DOMAIN_DOCUMENT): Document, DocumentSnapshot, DocumentEmbedding, Teamspacechunter(DOMAIN_CHUNTER): Channel, ChatMessage, DirectMessage, Message, ThreadMessagetime(DOMAIN_TIME): ToDo, WorkSlotcard(DOMAIN_CARD): Card, CardSpace, MasterTagcore(DOMAIN_MODEL): Type, Status, ArrOf, EmbValue, and all base classescontact(DOMAIN_CONTACT): Person
The model's findAll behavior depends on the class's domain:
- DOMAIN_MODEL classes: query the local
ModelDb(in-memory index) - All other domains: query the server (via WebSocket)
A workspace goes through these states (mode column):
[created] → pending-creation → creating → active
[upgraded] → pending-upgrade → upgrading → active
[deleted by owner] → pending-deletion → deleting → [gone]
[archived] → archiving-pending-backup → archiving-backup → archiving-pending-clean
→ archiving-clean → archived
[migrated] → migration-pending-backup → migration-backup → migration-pending-cleanup → [deleted]
[restored] → pending-restore → restoring → active
The workspace pod polls for pending workspaces and processes them.
WS_OPERATION env var controls which states the pod handles:
| WS_OPERATION value | Processes |
|---|---|
upgrade (default) |
only pending-upgrade |
all (after Fix #5) |
pending-creation + pending-upgrade + pending-deletion |
all+backup |
all of all + migration-pending-* + archiving-pending-* + pending-restore |
For self-hosted single-pod deployments, use WS_OPERATION=all+backup.
The CLI speaks Huly's binary RPC protocol over WebSocket. Key methods:
| Method | Direction | Purpose |
|---|---|---|
hello |
client → server | First message; identifies client (binary mode, compression) |
findAll |
client → server | Query; server returns array + total |
findOne |
client → server | Single-doc query |
loadModel |
client → server | Initial model load (returns txs since last hash) |
loadChunk |
client → server | Lazy-load a domain's documents |
tx |
client → server | Apply a transaction |
updateFromRemote |
server → client | Push a tx (server-initiated) |
ping / pong |
both | Keepalive |
Chunks are how the server streams large query results. The default chunk size is whatever fits in a WebSocket frame (~64KB compressed).
Every write in Huly is a transaction (tx). A tx is one of:
TxCreateDoc— new documentTxUpdateDoc— update document fieldsTxRemoveDoc— delete documentTxMixin— attach/update a mixinTxApplyIf— atomic tx group (commit-on-condition)
The CLI generates these via the SDK's client.createDoc, client.updateDoc,
etc. Each tx has:
_id— tx UUID (generated client-side)_class— tx type classspace— where the tx lives (core:space:Tx)objectId— the document being created/updatedobjectClass— the doc's classobjectSpace— the doc's spacemodifiedBy,modifiedOn— actor + timestampattributes— the create/update payload
The server applies txs in order, checking model consistency. A tx can be rejected if:
- The
objectClassdoesn't exist in the model - A referenced object doesn't exist
- The user lacks permission
- The doc was deleted concurrently
Rejected txs surface as PlatformError. The CLI surfaces these as CliError.
For content-bearing fields (description, body, content), the platform uses
a markup reference indirection: the SDK's processMarkup uploads the
body to the collaborator, which produces a y-doc, and the doc field
stores a MarkupRef pointing to it instead of the inline text. On
read, client.fetchMarkup(...) retrieves and renders the y-doc.
The CLI passes body content as raw strings instead of
new MarkupContent(...); the SDK's else-branch forwards strings
through unchanged, and the read path falls back to the raw body string
when markup resolution fails or times out. See
Markup handling for the rationale.
The account server gates every method by token type:
| Token type | extra.service |
Granted methods |
|---|---|---|
| Login token (password / OAuth) | undefined | User-level methods only: login, selectWorkspace, listWorkspaces, findPersonBySocialKey (after Fix #1), getWorkspaceInfo, getSocialIds, etc. |
| Service token | 'tool' | 'workspace' | 'aibot' | 'backup' | 'payment' | ... |
Service-level methods: getPendingWorkspace, updateWorkspaceInfo, etc. |
| Admin token | admin === 'true' |
All methods |
The CLI uses login tokens. Service-to-service calls (e.g. the worker
calling getPendingWorkspace) use service tokens.
Common pitfall: calling a service-only method with a login token returns Forbidden. Always use the right token type.
When a workspace's version_major/minor/patch is less than the server's
current version, the workspace pod applies model-upgrade txs:
- Pod calls
getPendingWorkspace(this.region, this.version, 'upgrade') - Account server returns workspaces where
version_* < current - Pod loads the model-upgrade txs from the platform's source tree
- Pod applies them in order
- Pod calls
updateWorkspaceInfo(workspace, 'upgrade-done', version) - Workspace's
version_*is bumped, status becomesactive
The model-upgrade txs are auto-generated from the platform's @Model(...)
decorators in ~/platform/models/<m>/src/. Each plugin contributes a
batch of class-creation txs.
When you createWorkspace, the server assigns a dataId (a cockroach
DB name). All subsequent docs for this workspace go into that DB.
Bug: if kafka replays a workspace-deleted event for a workspace
that was already hard-deleted (e.g. via direct SQL), the worker re-creates
the workspace row without a dataId. Subsequent operations on this
workspace fail because there's no DB to write to.
Workaround: if you hard-delete via SQL, also delete the kafka
events for that workspace. Or just leave the workspace in
pending-deletion mode and let the worker process it eventually.
Backups are stored in MinIO bucket huly-backups. The CLI/server doesn't
configure MinIO lifecycle, so backups accumulate forever unless you set
up ILM externally:
docker exec huly_v7-minio-1 mc alias set local http://localhost:9000 minioadmin minioadmin
docker exec huly_v7-minio-1 mc mb --ignore-existing local/huly-backups
docker exec huly_v7-minio-1 mc ilm add local/huly-backups --expiry-days 14This sets 14-day expiry on all backups. Adjust as needed for compliance.
The kafka broker (Redpanda) requires SASL auth. During initial bootstrap,
rpk cluster info -X user=admin -X pass=... returns ILLEGAL_SASL_STATE
because SASL isn't ready yet.
Fix: use an unauthenticated metadata probe:
healthcheck:
test: ['CMD-SHELL', 'rpk cluster info --brokers=localhost:9092 || exit 1']
interval: 10s
timeout: 5s
retries: 20
start_period: 30sThen set depends_on: { redpanda: { condition: service_healthy } } on
every kafka-dependent service.
The transactor and workspace pod must be at the same MODEL_VERSION
(derived from ~/platform/common/scripts/version.txt). If they drift,
the transactor's sessionManager rejects WebSocket connections:
version mismatch: transactor 0.7.422 != workspace 0.7.423
Fix: keep ~/platform/common/scripts/version.txt in sync across
builds. After bumping, rebuild all pods that consume the version.
The CLI reads the version from bundle.js (the SDK). The server's
hello response includes serverVersion. The CLI logs Connected to server: <version> on connect.
If you're using the MCP server (huly-mcp) and want to switch to huly-cli:
Same operations, different invocation:
# MCP: list_issues
# CLI:
huly issue list --json
# MCP: create_issue
# CLI:
huly issue create --project TSK --title "..." --json
# MCP: get_issue
# CLI:
huly issue get TSK-1 --jsonOutput format: both produce JSON arrays. The MCP server wraps
responses in { result: [...] }; the CLI returns raw [...]. Strip the
wrapper if you're reusing MCP client code.
Auth: both use the same account-token JWT. You can reuse the
MCP server's credentials cache by symlinking it:
ln -s ~/.config/huly-mcp/credentials.json ~/.config/huly/credentials.jsonTool naming: MCP uses snake_case (e.g. list_issues); CLI uses
kebab-case (e.g. issue list). The MCP names map to CLI as:
list_<resources>→<resource> listget_<resource>→<resource> getcreate_<resource>→<resource> createupdate_<resource>→<resource> updatedelete_<resource>→<resource> delete<verb>_<resource>(e.g.add_comment) →<resource> <verb>
If you're used to clicking around in the web UI:
| Web UI action | CLI command |
|---|---|
| Click project in sidebar | huly workspace use <name> then huly project list |
| Open issue TSK-1 | huly issue get TSK-1 --markdown |
| Create new issue | huly issue create --project TSK --title "..." |
| Move issue to "Done" | huly issue update TSK-1 --status Done |
| Add label "bug" | huly issue label TSK-1 add bug |
| Comment on issue | huly comment add --issue TSK-1 --body "..." |
| Send DM | huly dm send --person alice@... --body "..." |
| Create channel | huly channel create --name engineering |
| Create calendar event | huly calendar create --title "Standup" --start ... --end ... |
| Log time | huly time log --issue TSK-1 --minutes 30 |
| Switch workspace | huly workspace use <name> |
If you have scripts using the SDK directly:
// SDK
import { connect } from '@hcengineering/api-client'
const client = await connect(url, { workspace, token })
const issues = await client.findAll('tracker:class:Issue', { space: project._id })# CLI equivalent (in shell)
huly --workspace $WORKSPACE issue list --project $PROJECT --jsonThe CLI wraps the SDK and handles auth, caching, model loading, and error formatting. Prefer the CLI for one-off scripts; prefer the SDK for long-running services.
If you're using curl against the Huly REST API:
# REST (raw)
curl -X GET "$HULY_URL/api/v1/version"
# CLI
huly api GET /api/v1/versionThe CLI's api command passes through to the REST API but handles auth
headers automatically. Use it for ad-hoc endpoints the CLI doesn't cover.
Huly doesn't ship a GraphQL API. The CLI is the closest equivalent — it wraps the platform's RPCs into REST-like commands. If you need GraphQL, you're out of luck.
# .github/workflows/huly-sync.yml
name: Sync CI status to Huly
on: [push]
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install -g @iamcoder18/huly-cli
- name: Sync status to Huly
env:
HULY_URL: ${{ secrets.HULY_URL }}
HULY_TOKEN: ${{ secrets.HULY_TOKEN }}
HULY_WORKSPACE: ${{ vars.HULY_WORKSPACE }}
run: |
COMMIT_MSG=$(git log -1 --pretty=%B)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
huly issue create --project CI --title "$BRANCH: $COMMIT_MSG" \
--label auto --label ci --yes#!/bin/bash
# standup.sh — runs daily, posts to #standup channel
set -e
# Get yesterday's issues you closed
CLOSED=$(huly issue list --json | \
jq -r --arg date "$(date -u -d 'yesterday' +%Y-%m-%d)" \
'.[] | select(.modifiedOn > ($date | strptime("%Y-%m-%d") | mktime * 1000)) | select(.status == "Done") | "- #\(.identifier) \(.title)"')
# Post to channel
huly channel message send "standup" --body "Yesterday I closed:
$CLOSED
"#!/bin/bash
# migrate-issues.sh — copy issues from one project to another
set -e
SOURCE=$1
DEST=$2
STATUS="open"
IDS=$(huly issue list --project "$SOURCE" --status-category "$STATUS" --json | jq -r '.[]._id')
for id in $IDS; do
# Get full issue
issue=$(huly issue get "$id" --json)
title=$(echo "$issue" | jq -r .title)
desc=$(echo "$issue" | jq -r .description)
# Create in dest
huly issue create --project "$DEST" --title "$title" --description "$desc" --yes
echo "migrated: $id ($title)"
done#!/bin/bash
# weekly-digest.sh
set -e
WEEK_AGO_MS=$(date -u -d '7 days ago' +%s)000
# Issues created this week (issue list has no --since; jq filters on createdOn)
NEW=$(huly issue list --limit 1000 --json | \
jq -r --argjson t "$WEEK_AGO_MS" \
'.[] | select(.createdOn >= $t) | "- #\(.identifier) \(.title) (\(.assignee // "unassigned"))"')
# Issues closed this week (status filter + jq on modifiedOn)
CLOSED=$(huly issue list --status Done --limit 1000 --json | \
jq -r --argjson t "$WEEK_AGO_MS" \
'.[] | select(.modifiedOn >= $t) | "- #\(.identifier) \(.title)"')
# Send via your mailer (here we use sendmail)
{
echo "Subject: Huly Weekly Digest"
echo
echo "This week:"
echo "$NEW"
echo
echo "Closed:"
echo "$CLOSED"
} | sendmail -t# Find documents with no teamspace
huly document list --json | jq -r '.[] | select(.space == null) | ._id' | \
xargs -I{} echo "orphan doc: {}"
# Find documents with no author
huly document list --json | jq -r '.[] | select(.createdBy == null) | ._id' | \
xargs -I{} echo "no-author doc: {}"# /etc/cron.d/huly-backup
0 2 * * * huly user get > /dev/null && echo "workspace OK at $(date)" >> /var/log/huly-health.logOr use the Huly server's own backup mechanism (see "Backup strategy" above).
#!/bin/bash
# management-report.sh
set -e
cat <<EOF
Weekly Status Report — $(date +%Y-%m-%d)
Open issues: $(huly issue list --status-category Active --json | jq length)
Top contributors (issues created this week):
$(huly issue list --limit 1000 --json | \
jq -r --argjson t "$(date -u -d '7 days ago' +%s)000" \
'[.[] | select(.createdOn >= $t) | .assignee // "(unassigned)"] | group_by(.) | map({k:.[0], n:length}) | sort_by(-.n) | .[0:5] | .[] | " \(.n)\t\(.k)")')
EOF