Self-hosted ATS setup, Account API, wire-level discoveries, seed paths, and recruitment configuration
Huly self-hosted runs as a 14-container Docker Compose stack on Ubuntu 24.04. We used the --quick flag for local testing on macOS (OrbStack).
| Service | Image | Port | Purpose |
|---|---|---|---|
| nginx | nginx:1.21.3 | 8087→80 | Reverse proxy — routes /_accounts, /api/v1, /_collaborator |
| front | hardcoreeng/front | 8080 | Web UI (SPA) |
| account | hardcoreeng/account | 3000 | Authentication, workspace management, invite flow |
| transactor | hardcoreeng/transactor | 3333 | Core data engine (WebSocket). All reads/writes go here. |
| workspace | hardcoreeng/workspace | — | Workspace creation, migration, schema upgrades |
| collaborator | hardcoreeng/collaborator | 3078 | Real-time collaborative editing (OT) |
| fulltext | hardcoreeng/fulltext | 4700 | Indexes docs → Elasticsearch for search |
| rekoni | hardcoreeng/rekoni-service | 4004 | AI content intelligence — extracts text from PDFs/DOCX |
| stats | hardcoreeng/stats | 4900 | Telemetry and health metrics |
| kvs | hardcoreeng/hulykvs | 8094 | Key-value store for calendar/integrations |
| cockroach | cockroachdb/cockroach v24.2 | 26257 | Primary database (replaced MongoDB in v0.7) |
| elastic | elasticsearch:7.14.2 | 9200 | Full-text search index |
| minio | minio/minio | 9000 | S3-compatible file storage (avatars, attachments) |
| redpanda | redpanda v24.3.6 | 9092 | Kafka-compatible message broker |
git clone https://github.com/hcengineering/huly-selfhost.git
cd huly-selfhost
./setup.sh --quick
# → Generates config, secrets, starts all 14 containers
# → Huly available at http://localhost:8087 in ~60 seconds
| Resource | Minimum | Recommended |
|---|---|---|
| RAM | 4 GB | 8 GB (Elasticsearch alone reserves 1 GB heap) |
| CPU | 2 vCPU | 4 vCPU |
| Disk | 40 GB SSD | 80 GB NVMe |
Huly's Account service exposes a JSON-RPC over HTTP API at /_accounts. This is not publicly documented. We discovered these methods by inspecting the bundled JavaScript in the account container.
POST http://localhost:8087/_accounts
Content-Type: application/json
Authorization: Bearer {token} (for authenticated methods)
{
"method": "signUp",
"params": {
"email": "admin@agilex.co.za",
"password": "AgileX2024!",
"firstName": "Craig",
"lastName": "Sobey"
}
}
→ { "result": { "account": "uuid", "token": "jwt", "name": "Craig Sobey" } }
{
"method": "createWorkspace",
"params": {
"workspaceName": "AgileX Recruitment",
"workspaceUrl": "agilex-recruitment"
}
}
→ { "result": { "token": "workspace-jwt", "workspace": "uuid", "role": "OWNER" } }
Gotcha: Requires workspaceName AND workspaceUrl as separate fields. Using just name returns BadRequest.
{
"method": "createInviteLink",
"params": {
"email": "thandi@agilex.co.za",
"role": "OWNER", // OWNER | MAINTAINER | USER | GUEST
"expHours": 720
}
}
→ { "result": "http://localhost:8087/login/join?inviteId=123456" }
Gotcha: The autoJoin parameter exists but requires a "schedule" service permission in the token. Use the two-step flow instead (createInviteLink + join).
{
"method": "join",
"params": {
"email": "thandi@agilex.co.za",
"password": "AgileX2024!",
"inviteId": "123456"
}
}
→ { "result": { "token": "workspace-jwt", "workspace": "uuid" } }
{
"method": "getWorkspaceMembers",
"params": {}
}
→ { "result": [{ "person": "uuid", "role": "OWNER" }, ...] }
| Method | Purpose | Verified |
|---|---|---|
login | Authenticate existing account | Yes |
selectWorkspace | Switch workspace context | Yes |
getWorkspaceInfo | Get workspace details | Yes |
assignWorkspace | Assign user to workspace | Forbidden (needs admin) |
deleteAccount | Delete user account | Not tested |
deleteWorkspace | Delete workspace | Not tested |
getWorkspaceRole | Get user's role in workspace | Not tested |
| Role | Value | Permissions |
|---|---|---|
| Owner | OWNER | Full admin — invite, manage, delete |
| Maintainer | MAINTAINER | Manage content, invite users |
| User | USER | Standard access — read/write content |
| Guest | GUEST | Read-only view access |
| Read-only Guest | READONLYGUEST | Strict read-only (no comments) |
We wrote probe scripts (scripts/discover.ts and scripts/probe.ts) that connect to a live Huly workspace via @hcengineering/api-client and query the data model. These findings are not documented anywhere in Huly's public docs.
Answer: space = core:space:Space (global). type = recruit:template:DefaultVacancy. A Vacancy IS a Space (extends task:class:Project).
Answer: Each Vacancy carries a statuses[] array of refs. The 7 default statuses are shared, stable refs across all Vacancies created from the DefaultVacancy template. They are NOT auto-seeded by createDoc — the MCP server must replicate them manually.
Answer: Person has no inline channels field. Each channel (email, phone, LinkedIn) is a separate contact:class:Channel doc added via addCollection on the Person. Drive URLs are stored as Homepage channels — same as a recruiter pasting a link in the UI.
Answer: A Person becomes a Talent by attaching recruit:mixin:Candidate via createMixin(personId, Person, Candidate, {}). Persons can carry multiple mixins (Employee, HR Staff, Candidate simultaneously).
Answer: Huly stores Person.name as "LastName,FirstName" (comma-separated, no space). The UI renders it as "FirstName LastName".
Answer: avatarType and avatarProps are server-defaulted (colour avatar). Not required from the client on createDoc.
Person (contact:class:Person)
name: "LastName,FirstName"
city: string
├── recruit:mixin:Candidate { title?, source? }
├── Channel[] (Email, Phone, LinkedIn, Homepage)
└── Application[] (one per Vacancy applied to)
Vacancy (recruit:class:Vacancy extends task:class:Project)
name: string (job title)
description: string (markdown)
type: recruit:template:DefaultVacancy
statuses: Status[] (7 default pipeline stages)
members: [], owners: [], private: false, archived: false
├── Applicant[] (one per Talent in this pipeline)
Applicant (recruit:class:Applicant)
space: Vacancy (Vacancy IS a Space)
attachedTo: Person (Talent)
collection: 'applications'
status: recruit:taskTypeStatus:* (pipeline stage ref)
assignee?: Employee ref
identifier: string (auto-generated)
| # | Stage | Wire Ref | Category |
|---|---|---|---|
| 1 | Backlog | recruit:taskTypeStatus:Backlog | UnStarted |
| 2 | HR Interview | recruit:taskTypeStatus:HRInterview | Active |
| 3 | Technical Interview | recruit:taskTypeStatus:TechnicalInterview | Active |
| 4 | Test Task | recruit:taskTypeStatus:TestTask | Active |
| 5 | Offer | recruit:taskTypeStatus:Offer | Active |
| 6 | Won | recruit:taskTypeStatus:Won | Won |
| 7 | Lost | recruit:taskTypeStatus:Lost | Lost |
| Provider | Wire Ref | Used For |
|---|---|---|
contact:channelProvider:Email | Candidate email | |
| Phone | contact:channelProvider:Phone | Phone number |
contact:channelProvider:LinkedIn | LinkedIn profile URL | |
| Homepage | contact:channelProvider:Homepage | Google Drive URLs (CV, folder, notes) |
| GitHub | contact:channelProvider:GitHub | GitHub profile |
| Telegram | contact:channelProvider:Telegram | Telegram handle |
contact:channelProvider:Whatsapp | WhatsApp number |
We implemented two different approaches to populating a Huly recruitment workspace:
Insert structured data directly into CockroachDB tables (contact, hr, recruit, space) bypassing the transactor. Fast but fragile.
seed-recruitment.mjs → generates SQL → cockroach sqlUses the official Huly client library over WebSocket. Creates docs through the transactor — triggers all server-side effects (indexing, notifications, counters).
npm run seed:v3-demo in huly-recruit-mcp| Setting | Value |
|---|---|
| Workspace URL | agilexrecruitment |
| Huly version | v0.7.382 |
| Access | http://localhost:8087 |
| Admin | admin@agilex.co.za / AgileX2024! |
| Name | Role | Department | Access |
|---|---|---|---|
| Craig Sobey | Platform Admin | — | OWNER |
| Thandi Mokoena | Managing Director | Leadership | OWNER |
| Pieter van der Merwe | Head of Recruitment | Leadership | MAINTAINER |
| Naledi Khumalo | Senior Recruitment Mgr | Leadership | MAINTAINER |
| Siyanda Dlamini | Banking Specialist | Consultants | USER |
| James Reynolds | Tech & AI Specialist | Consultants | USER |
| Fatima Patel | Graduate Programme Lead | Consultants | USER |
| Bongani Ndlovu | Retail Specialist | Consultants | USER |
| Sarah O'Connor | PM Specialist | Consultants | USER |
| Mpho Sithole | Research & Sourcing | Support | USER |
| Lerato Mahlangu | Admin & Coordination | Support | USER |
All staff emails: firstname.lastname@agilex.co.za / Password: AgileX2024!
| Department | Members |
|---|---|
| Leadership | Thandi, Pieter, Naledi |
| Consultants | Siyanda, James, Fatima, Bongani, Sarah |
| Support | Mpho, Lerato |
| Talent | Title | City | Stage |
|---|---|---|---|
| Kagiso Maile | Senior ML Engineer | Johannesburg | Backlog |
| Chloé Dubois | Senior ML Engineer | Cape Town | HR Interview |
| Rudzani Nemukula | Staff MLOps Engineer | Pretoria | Technical Interview |
| Farhan Ismail | Principal AI Engineer | Cape Town | Test Task |
| Tshepo Mabaso | Senior ML Engineer | Johannesburg | Offer |
Vacancy: V3 · ML Platform Engineer (LLM/MLOps) — R1.4M–R1.8M CTC, Cape Town hybrid.
See the full MCP specification: Huly Recruit MCP Spec
| Tool | Input (required) | Creates |
|---|---|---|
create_talent | first_name, last_name | Person + Candidate mixin + channels |
create_vacancy | name | Vacancy with 7 pipeline stages |
create_application | talent_id, vacancy_id, stage | Applicant linking talent → vacancy at stage |
Huly stores metadata and links only. All durable artifacts live in Google Workspace.
| Huly Stores | Google Workspace Stores | Link Mechanism |
|---|---|---|
| Talent name, title, city | Full CV (PDF) | Homepage channel with Drive URL |
| Channel refs (email, phone) | Candidate folder | Homepage channel with folder URL |
| Vacancy name, description | Full vacancy brief (Doc) | URL appended to description |
| Application stage, assignee | Interview scorecards (Sheet) | — |
| Pipeline status | Offer letters, contracts | — |
AgileX Recruitment/
├── Candidates/
│ ├── Active/
│ │ └── {FirstName LastName}/
│ │ ├── CV.pdf
│ │ ├── References/
│ │ └── Notes.gdoc
│ ├── Placed/
│ └── Archive/
├── Clients/
│ └── {Client Name}/
│ ├── Contract.pdf
│ └── Vacancy_Briefs/
├── Templates/
└── Reports/
The account service's STATS_URL and FRONT_URL default to http://localhost:8087 — unreachable from inside a container. Fix: set STATS_URL=http://stats:4900 in compose.yml.
The published npm package uses esbuild's __reExport pattern. Neither named imports nor namespace imports work through Node's ESM loader. Fix: use createRequire(import.meta.url) to load it.
The Huly UI's "Create Vacancy" flow auto-seeds statuses from the DefaultVacancy template. createDoc via the API does NOT trigger this. You must manually include the 7 default statuses in the statuses[] array.
Because Vacancy extends Project which extends TypedSpace, it needs members: [], owners: [], private: false, archived: false or the server errors with "space.members is not iterable".
Huly passwords containing # characters are silently truncated by some shell env parsers. Use .env.local files with --env-file-if-exists flag instead of inline env vars.
@hcengineering/api-client uses Node's ws library for WebSocket. This doesn't run in Cloudflare Workers even with nodejs_compat. For edge integration, wrap the MCP server in a thin HTTP service that Workers can call via fetch().
The @hcengineering/tags subsystem handles skills/labels. The create_talent tool accepts skills as input but can't attach them yet — the tags subsystem needs a separate investigation.
AgileX Huly Integration Guide · May 2026 · agilex.co.za · 2nth.ai