Open-source HR information system — people, hiring, leave, attendance, payroll, and an HR assistant that actually knows your policies.
Built for Thai labour practice. Designed to be self-hosted.
English · ภาษาไทย · Overview page
Real console, real second factor, real payroll run — recorded against the
company npm run db:seed builds, by a
script in this repository, on the English interface. Thai is the default and
one switch turns the whole console English (CW-016).
Higher-quality MP4 ·
the Thai take.
Six problems a Thai business pays for every month, and how Cwork answers each one — rarely with a single feature, usually with several working together. The overview page tells the same story with screenshots.
Per-seat pricing means every hire raises the bill, the contract renews every year, and your employee data lives on somebody else's servers.
- No licence fee, no per-seat fee. Apache-2.0: twenty employees or two thousand cost the same — the server.
- One machine. Three Docker commands. PostgreSQL alone does the work of a queue, a lock service and a rate-limit store, so there is no Redis or Kafka to run.
- Your data, in your database. Back it up, move it, or leave; nothing locks you in.
- Room to grow. More API replicas are one setting, not another service.
A spreadsheet one person understands, progressive tax, social security and several overtime rates — one mistake costs money, a correction run and trust.
- Thai law as tested code. Withholding tax, social security, provident fund and Labour Protection Act overtime, as pure functions with worked examples.
- Whoever prepares a run cannot approve it, enforced by the API.
- No export until the figures reconcile. Every run in the period approved, and the payslips adding up to the run totals to the baht (CW-044).
- Approvers see why the total moved. The optional assistant narrates the variance against last period, and a test fails on any figure it invents (CW-040).
- Payslips you can explain a year later, with employer contributions kept apart from deductions.
- Not yet: the PND 1, social-security and bank-transfer files.
- Accounts bound to a device. The first phone binds; a punch from another is flagged, and only HR can move the binding, audited (CW-024).
- Flag for review, never refuse. Punches outside the fence or with a suspicious location are accepted and flagged — whoever turned up can always clock in.
- Offline punches kept safe. An encrypted on-device queue, rooted-device detection, and late deliveries that need confirming before they count (CW-025).
- Rosters that lateness is measured against, assignable a department at a time (CW-010).
- A summary for managers instead of a list of flags: flagged punches grouped by location, so a fence drawn too tight reads differently from fraud (CW-039).
- One approval engine, nine request types — leave, overtime, expenses, attendance corrections, resignations, requisitions, offers, payroll runs and document requests.
- Routes that follow the organisation — line manager, department head, role or named person, with amount thresholds and delegation.
- Approve from a phone. Email and push go out through a transactional outbox that retries, backs off and dead-letters.
- Leave balances that cannot go negative. Pending days are reserved on filing; weekends and public holidays are never counted.
- Self-service certificates, issued as PDFs with a verification code (CW-008).
- National IDs and bank accounts encrypted at rest and masked to the last four digits.
- A password alone is not a session for anyone who can read them or run payroll.
- An audit trail nobody can rewrite, append-only by database trigger.
- Keep only what you need — consent on application, candidates erased after 12 months, leavers redacted once past retention (CW-015).
- Nothing leaks through the logs. No salary, national ID, bank account, token or query string reaches a log line, and an e2e test proves it (CW-050).
- Uploads scanned for malware before they are stored.
- Applicant to employee in one click — requisition, public careers page, auto-graded assessment, scorecards, and an offer that becomes the employee record.
- Fairer reviews — weighted KPIs, check-ins and calibration by HR.
- Benefits that flow into payroll without re-keying (CW-009).
- Offboarding with a clearance checklist and exit interview.
- Thai and English throughout, web and app (CW-016).
Three deployables, one database:
| Admin console | React 19 + Vite. Everything HR, payroll and managers do. |
| Employee app | Flutter. Clock in/out, leave, payslips, approvals — offline-tolerant. |
| API | NestJS modular monolith over PostgreSQL 16. |
flowchart TB
W["Admin console<br/><i>React 19 · Vite</i>"] --> API
M["Employee app<br/><i>Flutter · offline-tolerant</i>"] --> API
C["Public careers page"] --> API
API["<b>NestJS API</b> — modular monolith<br/>JWT → permissions → rate limit<br/>feature modules over pure <i>domain/</i> rules"]
API --> DB[("PostgreSQL 16<br/><i>records · job locks · rate-limit counters · outbox</i>")]
DB --> OUT["Transactional outbox<br/><i>at-least-once, backs off, dead-letters</i>"]
OUT --> MAIL["SMTP"]
OUT --> PUSH["FCM push"]
API -. optional .-> CLAM["clamd<br/><i>scans uploads</i>"]
API -. off by default .-> LLM["Anthropic<br/><i>HR assistant</i>"]
Everything a queue, a lock server and a cache would normally do, PostgreSQL does
here: FOR UPDATE SKIP LOCKED for the outbox, advisory locks so only one replica
runs each scheduled job, and a counter table for shared rate limits. That is the
whole reason a second instance is a setting rather than a project.
No Redis, no message broker, no Kubernetes. Running several API instances needs
one setting (THROTTLE_STORAGE=postgres) rather than another service to operate
— an HRIS that needs a Kafka cluster to send a leave notification is one nobody
can self-host.
One deployment serves one organisation. Every table carries organizationId
and every query filters on it, but that is defence in depth inside a single
install, not a tenant boundary: nothing in the test suite exercises two
organisations sharing a database, so nothing should depend on it.
git clone https://github.com/SuruchBoss/Cwork.git
cd Cwork
cp .env.example .envFill in the secrets .env asks for — compose refuses to start without them:
openssl rand -base64 48 # JWT_ACCESS_SECRET, JWT_REFRESH_SECRET
openssl rand -base64 32 # FIELD_ENCRYPTION_KEY
# plus POSTGRES_PASSWORD, anything you likeThen:
docker compose up -d --build
docker compose run --rm --build migrate # apply migrations
docker compose run --rm migrate npm run db:seed # demo data — evaluation onlyOpen http://localhost:8080.
The seed does not stop at an org chart: it runs last month's payroll through the real calculator, books leave that real approvers approved, and leaves a hiring pipeline mid-flight and two requests waiting in an inbox. Every page has something on it, because a console full of "nothing here yet" tells you nothing about whether the thing works.
| As an employee | dev2@cwork.example — password only |
| As HR | hr.manager@cwork.example — plus a 2FA code |
Every demo account shares one password, which db:seed generates and prints
once, at the end of its output — or choose it yourself with
docker compose run --rm -e SEED_PASSWORD=… migrate npm run db:seed. There is no
default: a password printed here would be the same on every installation that
did not change it.
Admin accounts genuinely require a second factor too, so db:seed also prints a
demo TOTP secret and a QR link. Add it to any authenticator app once and every
admin account works.
Migrations run through the
migrateservice, notexec api. The API image is pruned to production dependencies and ships no Prisma CLI. That service sits behind a compose profile, soupnever starts it.
Running without Docker
# API — needs PostgreSQL 16 with the pgvector extension (the first migration
# creates it; `pgvector/pgvector:pg16` is what CI and compose run)
cd backend
cp .env.example .env # set DATABASE_URL and the secrets
npm install
npx prisma migrate deploy && npm run db:seed
npm run start:dev # → http://localhost:3000
# Console
cd ../web
npm install && npm run dev # → http://localhost:5173, proxies /api
# Mobile
cd ../mobile
flutter pub get
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:3000/api/v1The interactive API docs live at /api/docs whenever NODE_ENV is not
production — which is what compose defaults it to, so set
NODE_ENV=development in .env if you want them.
The five minutes above are demo data: a published password, a published two-factor secret, eight fictional employees. A real install skips the seed entirely.
docker compose up -d --build
docker compose run --rm --build migrate # apply migrations
docker compose run --rm migrate npm run db:init # your organisationdb:init asks for an organisation name, a timezone and the first
administrator's email, and creates exactly that: one organisation, the eight
system roles, one account. Nothing else — no departments, no demo rows, nothing
to clean up afterwards.
If the terminal is not where you want to type all that:
docker compose run --rm migrate npm run db:init -- --webprints a single-use token and you finish at http://localhost:8080/setup. That token is the entire security model. Minting one needs shell access to the server, which is the one thing a stranger who finds a fresh deployment does not have — so the wizard cannot be claimed by whoever reaches it first. It lasts an hour and works once.
Either way the first administrator holds every permission there is, so Cwork makes it enrol a second factor before its first session: have an authenticator app to hand. Neither the CLI nor the wizard ever prints a TOTP secret — you enrol it yourself, once.
Running db:init a second time on a database that already has an organisation
refuses, and so does db:seed: the demo data will not install itself beside a
real company by accident.
The interface is Thai by default and switches to English (CW-016) — in both the console and the employee app, every label, form, status and validation message flips between the two from one switch (a header control on the web, the profile screen on mobile), and Thai stays the default. Every image below is a real screenshot of the seeded demo company on the English interface — the console in a browser, the employee app on a 390×844 phone; the Thai README shows the same screens in Thai. Names, leave types and anything else the company typed in stay Thai, as they would for a Thai company: the interface is translated, its content is not. They are taken by
capture.mjsandcapture-mobile.mjs, so they can be retaken whenever a screen changes.
Employee records with encrypted national IDs and bank accounts, an org chart, employment history, and resignation with a clearance checklist and exit interview.
Thai statutory leave types, seniority-tiered accrual, half and hourly days, and a balance ledger that reserves pending days — so two overlapping requests cannot both fit inside one remaining day.
Friday to Monday costs two days, not four. Weekends and public holidays are excluded, not charged.
Clock in/out with geofencing and anti-fraud flags, shift rosters, corrections, and overtime at Labour Protection Act rates.
A punch outside the fence is flagged, never rejected. An employee must always be able to prove they turned up; HR reviews the flag afterwards.
Effective-dated salary, Thai withholding tax and social security, benefits, expense reimbursement — and payslips you can still explain a year later.
Whoever calculates a run cannot approve it, enforced by the API rather than by policy.
One declarative engine serves leave, overtime, expenses, attendance corrections, resignations, requisitions, offers, payroll runs and document requests. Policies resolve approvers by line manager, department head, role or named user, and carry conditions such as amount thresholds.
Requisitions through a public careers page, PDPA-consented applications, auto-graded assessments, interviews with scorecards, and offers that convert into employee records. Review cycles with weighted KPIs and calibration. Certificates and letters employees can request for themselves.
Answers policy questions from your own documents, checks balances, and files leave and document requests — scoped so it can only ever see the asker's own data.
It is optional and off by default. Policy search falls back to PostgreSQL full-text plus trigram matching, which needs no embeddings and copes with Thai's lack of word boundaries.
Flutter, for everyone who never opens the console. Clock in and out with geofencing, request leave, read a payslip, approve what is waiting, ask the assistant.
Built for a phone that loses signal: punches queue in durable storage and replay on reconnect, carrying a client-generated id so a retry cannot become a second punch.
That payslip is the "explain it a year later" claim in practice: earnings, deductions, and employer contributions kept visibly separate from what came out of the employee's pay.
More screenshots — sign-in and 2FA, expenses, benefits, offboarding, org chart, audit, dark theme, phone width
Sign-in, and the second factor an admin account cannot skip
Expenses, benefits and offboarding
Org chart and the append-only audit trail
More of the employee app — sign-in, payslips, profile, and filing leave
Dark theme, and the console at phone width
Business rules are pure functions. Leave arithmetic, attendance derivation,
Thai tax, KPI scoring and assessment grading live in domain/ directories with
no database, no framework and no I/O. That is why 323 domain tests run in under ten
seconds — and why "why was I charged 2.5 days?" is answered by reading one
function instead of a query plan.
Salary is never prorated by attendance coverage. A monthly-salaried employee is paid the full month minus explicit unpaid leave and absence. Days that simply have not been closed out — future dates, or a clock-in rollout still in progress — must not reduce pay. Getting this wrong silently shorts people, which is the worst class of payroll bug.
A fresh deployment cannot be claimed by whoever finds it first. Between
docker compose up and the moment setup finishes, an install is reachable and
unowned — and a scanner sweeping the port beats the person still reading startup
logs. So there is no "if no organisation exists, let anyone through": the first
administrator is created either by a CLI that needs a shell on the server, or by
a wizard holding a single-use token only that CLI can mint.
A notification is written in the same transaction as the thing it is about.
Approve a leave request and the row, the balance and the notification commit or
roll back together — there is no window where someone is told about a decision
that was rolled back. Delivery is a separate poll over the outbox table with
FOR UPDATE SKIP LOCKED, so a mail server being down cannot fail an approval.
The audit trail is append-only in the database. A trigger raises an exception
on UPDATE or DELETE against audit_logs and attendance_punches. A
compromised application account can add entries but cannot rewrite history.
Privileged accounts cannot sign in with a password alone. Anyone who can read national IDs, run payroll or hand out permissions needs a second factor. TOTP is implemented against RFC 6238's own test vectors rather than pulled in as a dependency, and a code cannot be spent twice even inside its validity window.
Uploads are scanned before they are stored. Résumés arrive from a public careers page — the least trusted input the system takes. The bytes go to clamd first, so malware is never written anywhere for a later change to expose. And a scanner that is not working is never a pass: unreachable, timed out, or a reply that cannot be parsed all leave the file held and undownloadable.
The assistant's tools take no employee id. Every one resolves the subject from the authenticated principal, so there is no parameter a prompt injection could set to read someone else's payslip. The blast radius of a fully compromised model is bounded by what that user could already see. See ADR-0004.
Cwork works on its own and always will. It is also the system of record for people, attendance, leave and payroll in a group of projects by the same maintainer that touch one restaurant chain from different sides:
| PaynEat POS | Point of sale at a branch |
| PaynEat ERP | Supply side: procurement, plant, distribution, stock, cost |
| Cwork | People, attendance, leave, payroll, labour cost per cost centre |
| SherWhyve | Investigates technical incidents across the systems above. Private, no public repository. |
No system reads another's database; they integrate through versioned APIs and events. A site has the same location code in all of them (ADR-0006).
The ERP has a free edition and a paid one; the feature that will consume Cwork's labour aggregates is in the paid one. Cwork is entirely Apache 2.0 and stays that way — that split belongs to the ERP. Worth knowing before you contribute rather than after: Apache 2.0 has always allowed anyone, the maintainer included, to build a commercial product on this, and the labour data contract is a documented public interface any other consumer may implement against.
None of this is a dependency. Installing Cwork on its own gives you the whole product, and every convention adopted for the ecosystem has to earn its place in a standalone install first — the reasoning is in spec.md § Agreed direction.
backend/ NestJS API — modular monolith over PostgreSQL
web/ React 19 + Vite admin console
mobile/ Flutter employee app
docs/ Spec, architecture, security, data model, API, ADRs, backlog
landing/ The overview page, published to GitHub Pages
.github/ CI workflow, issue and pull-request templates
| Specification | What the system does, module by module — the reference for what "correct" means |
| Backlog | Open work, prioritised, with acceptance criteria |
| Architecture | How the pieces fit, and what is deliberately absent |
| Security | Auth, encryption, audit — and an honest list of gaps |
| Personal data | What is held about people, for how long, and how to take it out |
| Privacy notice (ไทย) | A draft notice to hand to employees |
| Data model | Schema patterns and the hand-written SQL |
| API reference | Endpoints, error codes, conventions |
| Payroll: Thai rules | Tax brackets, allowances, OT multipliers |
| The HR assistant | Tools, guardrails, data flow |
| Operations | Deploy, backup, scheduled jobs, the Prisma drift trap |
| ADRs | Decisions that were not obvious |
The UI ships in Thai and English — switched from the header, Thai by default
(CW-016) — and payroll implements Thai rules — PIT withholding, social security,
Labour Protection Act overtime. Nothing in the architecture is Thailand-specific:
the tax rule set is data (THAI_TAX_RULES_2026), leave types are configuration,
and overtime multipliers are per-organisation settings. The English locale added
a dependency-free i18n layer keyed on the English source string, so a third
language is a catalogue rather than a code change; adding another jurisdiction
means a new rule set and that translation pass, not a rewrite.
Working and verified end to end — sign-in through payroll. 373 backend unit tests, 46 web, 35 mobile, plus a 242-check end-to-end suite that drives the real API over HTTP in CI, and the console exercised in a real browser against the live API.
Second-factor authentication, upload scanning, shared rate limiting and email/push delivery are all in place. What is left is tracked in the backlog, P0 first.
Still not production-ready without work. Before running real payroll, read the gaps in docs/security.md. In short: no ภ.ง.ด.1 filing export, issued documents are not rendered as PDFs, the employee app cannot register for push yet, retention is a policy you have to enforce by hand (how), the interface covers only Thai and English, and this code has never had a penetration test.
The Thai payroll and social-security rules have not been reviewed by anyone qualified. They were written from published sources and unit-tested against hand-worked examples, which proves the code computes what its author believed the rules to be — not that the belief is right. Check the figures against your own before a real run. If you have the standing to review them properly, issue #36 is open for it.
Releases are tagged and recorded in CHANGELOG.md. Install a
tag rather than main if you want to be able to say which version you are
running — the tag list is the
authority on what exists:
git clone --branch <tag> https://github.com/SuruchBoss/Cwork.gitThis is 0.x, and breaking changes are allowed. They will be in the changelog with what to do about them, but there is no long-term support branch and no promise of a painless upgrade path until 1.0. Migrations are forward-only; take a database backup before applying any.
1.0 means the schema and the API are stable enough to promise that. It is not close, and pretending otherwise would be the more expensive mistake.
The code in this repository was written by an AI agent, working under
direction, over a small number of days. Several commits add thousands of lines
at once. That is visible in git log to anyone who looks, and it changes what
you should check before trusting any of it — so it is said here rather than left
to be inferred.
What was not generated: the architecture, the security model, the priorities, and the arguments behind them. Those were decided by a person, recorded as they were settled, and are readable in spec.md § Agreed direction and in the ADRs. Where a ticket's own premise turned out to be wrong, the correction is written down in the backlog's Done table next to what was built — that record is the point.
What it implies about review:
- Every suite passes, in CI, on every push — 373 backend unit tests, 46 web, 35 mobile, 242 end-to-end checks against the real API over HTTP.
- The decisions are documented and the reasoning is recoverable.
- No independent human has read every line. Tests passing and a design being defensible are not the same thing as a review, and this has had the first two and not the third.
The history has not been rewritten to look otherwise, and it will not be. Rewriting it would be the actual dishonesty.
Contributions welcome — see CONTRIBUTING.md. The backlog is the list of open work, with the ones marked good first issue called out; they tend to be leave-policy variants, payroll edge cases, or a jurisdiction other than Thailand.
Commits in a pull request from a fork need a sign-off (git commit -s) under the
Developer Certificate of Origin — a statement that you had the right to
submit the change, checked in CI. There is no CLA: a contributor licence
agreement is a barrier in front of a one-line fix, and the DCO answers the
question a CLA is usually reached for. The accepted consequence is that the
licence cannot realistically be changed later, since that would need every
contributor's agreement. Apache-2.0 is the answer, for good.
Apache 2.0. Copyright 2026 Suruch Chakrapeesirisuk; see also NOTICE.
Every source file starts with its copyright line and an SPDX identifier
(SPDX-License-Identifier: Apache-2.0), checked in CI; applied database
migrations are exempt. Outside contributions are signed off under the Developer
Certificate of Origin — see CONTRIBUTING.md.





























