Skip to content
archmax.ai

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.

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>.

Terminal window
pnpm install
cp .env.example .env # MONGODB_URI and the S3_* settings are required
cp config.yaml.example config.yaml # what this deployment offers; git-ignored

In .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:

Terminal window
docker compose up -d mongo redis minio minio-init
pnpm dev # the frontend, the API and the dispatcher

minio-init creates the bucket named by S3_BUCKET once; the platform does not create it. Then:

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.

Terminal window
pnpm dev # frontend + API + dispatcher, with reload
pnpm build # turbo run build: the SPA and the documentation site
pnpm typecheck # tsc --noEmit in every package
pnpm test # vitest in every package, one package at a time
pnpm check:specs # the spec gate (see the OpenSpec workflow)
pnpm docs:dev # the documentation site with live reload
pnpm docs:build # the documentation site, built to apps/docs/dist
pnpm config:env # config.yaml as the one line of JSON ARCHMAX_CONFIG holds

Turborepo caches typecheck and test. Pass --force (pnpm typecheck --force) before you push, so a cached package cannot hide an edit.

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:

Terminal window
docker compose up -d mongo redis
ARCHMAX_RUN_INTEGRATION_TESTS=1 pnpm test

The 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).

Terminal window
pre-commit install
pre-commit install --hook-type pre-push

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.

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=on starts 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.ts and host-launcher.test.ts start real processes.
  • A worker host. docker compose --profile workers up runs one beside the API and the dispatcher (see docker-compose.yml for 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:

Terminal window
cd packages/core
node --import tsx tools/build-workers.ts # main.js beside main.ts: compiled mode
NODE_ENV=production node --import tsx tools/worker-probe.ts # a turn's and a call's worker report
node --import tsx tools/build-workers.ts --remove # back to the sources

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.id is 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 ARRAY with sub-properties is a list field in the credential form. Its sub-properties are the columns, and a SECRET_TEXT column is masked and sealed. The rows reach piece code as an array of objects. The framework’s CustomAuthProps type leaves ARRAY out though its runtime accepts it, so declare it with a cast, as daytona does.

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.