Development setup
archmax automation is a pnpm + Turborepo monorepo in TypeScript (ESM only). It needs
Node.js 22.19 or later (CI runs Node.js 22), pnpm 10 (the version is pinned in the
root package.json), and Docker for the local MongoDB, Redis and MinIO.
Layout
Section titled “Layout”| Path | What it is |
|---|---|
apps/frontend |
The Vite + React 19 SPA (TanStack Router and Query, Tailwind CSS 4) |
apps/api |
The Hono API, consumed by the SPA through the typed hc<AppType> client |
apps/dispatcher |
The BullMQ consumer of every queue in packages/core/src/queues.ts, which starts a worker for each turn and call |
apps/docs |
This documentation site (Astro Starlight) |
packages/contracts |
@archmax/contracts: schemas and types the browser and the server share |
packages/core |
@archmax/core: models, storage, the harness assembly, queues, services |
packages/ui |
@archmax/ui: the UI kit on Radix primitives and CVA variants |
packages/archmaxpieces |
The Activepieces pieces archmax authors, one @archmaxpieces/piece-<id> package each |
openspec/ |
The specs, the open changes and their archive, and the vocabulary |
tools/ |
The spec gate (check-specs.ts) |
docker/ |
The nginx config and the entrypoint of the image |
The browser never imports @archmax/core: what both sides need lives in
packages/contracts, and server response types reach the SPA through hc<AppType>.
First run
Section titled “First run”pnpm installcp .env.example .env # MONGODB_URI and the S3_* settings are requiredcp config.yaml.example config.yaml # what this deployment offers; git-ignoredIn .env, set MASTER_KEY (openssl rand -base64 32): the database store, the default, seals
every credential with it, so saving a connection fails without it, in development too. Then:
docker compose up -d mongo redis minio minio-initpnpm dev # the frontend, the API and the dispatcherminio-init creates the bucket named by S3_BUCKET once; the platform does not create it.
Then:
- the SPA is at http://localhost:5173, and its dev server proxies
/apito the API; - the API is at http://localhost:3001 (
GET /api/health); - the dispatcher connects to Redis and serves every queue.
With ARCHMAX_SIGN_IN unset, the API resolves a fixed local user and the SPA, which reads its
sign-in settings from the API, runs as the same dev
user. To sign in at an identity provider locally, set ARCHMAX_SIGN_IN=oidc,
ARCHMAX_OIDC_ISSUER, ARCHMAX_OIDC_CLIENT_ID and, for Auth0, ARCHMAX_OIDC_AUDIENCE in
.env, and allow
http://localhost:5173 and http://localhost:5173/silent-callback.html as callback URLs and
http://localhost:5173 as a logout URL in the provider’s SPA application.
The documentation site is not part of pnpm dev; run it on its own with pnpm docs:dev.
Everyday commands
Section titled “Everyday commands”pnpm dev # frontend + API + dispatcher, with reloadpnpm build # turbo run build: the SPA and the documentation sitepnpm typecheck # tsc --noEmit in every packagepnpm test # vitest in every package, one package at a timepnpm check:specs # the spec gate (see the OpenSpec workflow)pnpm docs:dev # the documentation site with live reloadpnpm docs:build # the documentation site, built to apps/docs/distpnpm config:env # config.yaml as the one line of JSON ARCHMAX_CONFIG holdsTurborepo caches typecheck and test. Pass --force (pnpm typecheck --force) before you
push, so a cached package cannot hide an edit.
Integration suites
Section titled “Integration suites”The default pnpm test leaves out the suites that need real services: the git storage
suites, the Activepieces catalog smoke suite, and the MongoDB- and Redis-backed integration
suites. CI runs them all; locally:
docker compose up -d mongo redisARCHMAX_RUN_INTEGRATION_TESTS=1 pnpm testThe repository uses pre-commit: gitleaks scans every commit, and the spec gate runs before every push (it also refuses a push that leaves an archived change untracked).
pre-commit installpre-commit install --hook-type pre-pushWorking on the code
Section titled “Working on the code”AGENTS.md at the repository root holds the rules every change follows: one word per concept,
how branches and pull requests are shared between parallel work, how stored data changes shape,
the schemaVersion plugin every model applies, the SDK pin
(@archmax-ai/harness, the same exact version in packages/contracts, packages/core and
apps/dispatcher), and the boundary between the two zod majors the AG-UI SDK brings in. Changes
themselves go through the OpenSpec workflow.
Running and debugging a worker
Section titled “Running and debugging a worker”The API and the dispatcher run every turn and every call through a connection in a
worker, a process of its own (packages/core/src/workers/).
Locally a worker runs its TypeScript through tsx, without the Node.js permission model, and
its lines appear in the log of the process that started it, prefixed with its kind and id:
[worker turn 3f2a] …. A refusal of its broker is one [worker] refused … line.
- Attaching a debugger.
ARCHMAX_WORKER_INSPECT=onstarts every worker with an inspector on a free port and logs it (Debugger listening on ws://127.0.0.1:…); attach to it as to any Node.js process. Refused in production. - In tests.
useInProcessWorkers()(src/test/workers.ts) runs every worker a suite dispatches in the suite’s own process, through the real dispatch, broker and runners, so a test sees both the dispatching module’s behaviour and what reaches the worker.workers/launchers/local-launcher.test.tsandhost-launcher.test.tsstart real processes. - A worker host.
docker compose --profile workers upruns one beside the API and the dispatcher (seedocker-compose.ymlfor the two variables it needs).
The image compiles the worker’s code to JavaScript beside each source file and runs it
under the permission model. To reproduce that locally, compile, probe with
NODE_ENV=production (outside development the permission model applies), and remove the
output again, which the local launcher would otherwise keep running instead of your sources:
cd packages/corenode --import tsx tools/build-workers.ts # main.js beside main.ts: compiled modeNODE_ENV=production node --import tsx tools/worker-probe.ts # a turn's and a call's worker reportnode --import tsx tools/build-workers.ts --remove # back to the sourcesWriting, forking or narrowing a piece
Section titled “Writing, forking or narrowing a piece”The Activepieces collections a release packages are the @activepieces/piece-<id> and
@archmaxpieces/piece-<id> dependencies of @archmax/core. When upstream’s piece does not do
what the platform needs, archmax writes, forks or narrows one in packages/archmaxpieces/: one
workspace package per collection, laid out like an Activepieces community piece and built
on the exact @activepieces/pieces-framework version core pins. The platform has no
per-collection code, so everything (OAuth2 endpoints, scopes, properties, the credential
check) is declared in the piece.
A fork keeps the upstream collection id and every action and trigger name, so connections,
workflows, remote mounts and the config’s oauthApps keep working; it replaces the upstream
dependency in the same change, keeps the upstream license notice, and its README.md names
the upstream commit and lists every deviation. microsoft-sharepoint is upstream’s SharePoint
piece with app-only access added, and microsoft-dynamics-365-business-central upstream’s
Business Central piece with app-only access and a refresh token for delegated connections.
daytona is archmax’s own, written where Activepieces has no piece: it calls Daytona’s REST
API with the process’s fetch, which the egress guard sees, rather than through a vendor SDK.
A lite collection narrows a piece instead. It serves
part of a packaged @activepieces/piece-<id> under its own id, <id>-lite, beside the full
collection. It copies no code: upstream’s action and trigger objects are picked by name
behind an OAuth2 declaration that asks for a narrower scope. It pins its upstream piece to the
version core pins (manifest.test.ts fails otherwise), so a bump of the full piece bumps it
too. Then re-check every served action’s API methods against the scope.
gmail-lite, google-drive-lite, google-sheets-lite and google-docs-lite are the ones
that ask Google for no restricted scope.
The folder’s README.md has the steps. The image follows the workspace’s dependency graph, so
a piece needs no Dockerfile line.
A piece’s actions, trigger hooks and property resolvers receive their credential as the
pinned framework types context.auth. A secret text is { type, secret_text }, a custom
auth is { type, props }, and an OAuth2 credential keeps its access_token at the top. Its
validate receives the bare value instead: the string, the { username, password } pair, or
the flat props.
Two more things the platform gives piece code:
- The session. An action’s
context.run.idis the session it runs in: a child session’s is its root session’s, and the design agent’s tool call has its thread’s. An action run outside any session gets an id of its own. - List fields. A custom-auth prop of type
ARRAYwith sub-properties is a list field in the credential form. Its sub-properties are the columns, and aSECRET_TEXTcolumn is masked and sealed. The rows reach piece code as an array of objects. The framework’sCustomAuthPropstype leavesARRAYout though its runtime accepts it, so declare it with a cast, asdaytonadoes.
Changing what a collection stores
Section titled “Changing what a collection stores”A MongoDB document carries the schema version of its collection’s shape, schemaVersion. When
a change alters what a collection stores — a field added, renamed, removed or reshaped — raise
the model’s version by one and write the data update that brings stored documents to it:
packages/core/src/data-updates/updates/<collection>.v<n>.ts, appended to DATA_UPDATES in
data-updates/registry.ts, with an integration test beside it. The suite fails for a version
raised without its data update. Every API and dispatcher process applies pending data updates when
it starts, so nothing is left for an operator to run; reading code only ever sees the current
shape. data-updates/updates/README.md has the rules for writing one — above all, write it so
the previous release can still read the result, and say in the pull request whether it can.