README

README.md · The big picture + 10-minute quickstart

← Onboarding

ELAS — AI-native ERP

ELAS is an all-in-one, multi-tenant operations platform — CRM/Sales, Operations, Finance, HR/Payroll — with a single MCP-native AI agent (Artie) that can read and act across every module and connected external system, governed by per-user permissions and per-plan licensing.

🚀 Getting started (local dev)

ELAS runs locally in Docker with one command — about 10 minutes to running and your first change.

1. 1. MAKE SURE DOCKER IS RUNNING

Install if you haven't Docker Desktop and make sure it's running.

2. Run it

In Kiro, open Terminal → New Terminal and run:

make run-dev

You'll be prompted to log in to AWS with your aws account, then make run-dev detects it and wires up Bedrock — and authenticates npm to CodeArtifact, which is where @artie/embed-sdk comes from (docs/ARTIE_SDK.md).

Will auto-opens two tabs: the app at localhost:3000 and the onboarding guide at localhost:4000

(continue to localhost:4000 for the rest).

While developing locally, go straight to localhost:3000/dashboard to bypass auth.


ENVIRONMENTS

  • Production: https://elasapp.com — AWS App Runner (elas-web, watches the ECR :prod tag). DNS (Cloudflare CNAME) points at App Runner. App DB is the private, KMS-encrypted, Multi-AZ elas-web-prod-priv reached only via the elas-web-prod-proxy RDS Proxy over the elas-prod-vpc-connector (see the DB-hardening note below).
  • Staging: https://stage.elasapp.com — AWS App Runner (elas-web-staging, watches :staging), separate private, KMS-encrypted RDS (elas-web-staging-priv) via elas-web-staging-proxy, behind Cloudflare Access, sends enabled. Release-gated deploys: every push to main ships to staging only (image promoted to :staging); prod is promoted by merging the release-please PR (retags the tested image :latest→:prod, no rebuild). See docs/RELEASES_SOP.md.
  • QC: https://qc.elasapp.com — AWS App Runner (elas-web-qc, watches :latest), private RDS (elas-web-qc-priv) via elas-web-qc-proxy. QC was the reference implementation for the DB-hardening pattern later applied to Staging and Prod.
  • Preview (per-PR, ephemeral): a throwaway env per pull request on a containerized Lambda + function URL, whose data is its own schema inside the QC database — a point-in-time copy of QC's public, so it holds real QC records. Auto-destroys after 2h idle or when the PR closes. Add the Preview label to a PR and CI comments the URL. See Preview environments below and docs/PREVIEW_ENVS.md.
  • Identity (elas-identity): sign-in is the separate elas-identity service (repo ELASAPP/elas-identity): prod https://identity.elasapp.com, staging https://staging.identity.elasapp.com, QA https://qa.identity.elasapp.com. V3 is an OIDC client of it. The old login.elasapp.com auth service is legacy: it is still running for Artie prod only and is no longer built or deployed from this repo.
  • Repo: ELASAPP/V3
  • Marketing/landing: served at /

App database is hardened (private RDS + RDS Proxy) on all three tiers. Each app DB is a private, KMS-encrypted RDS instance in private subnets, reachable only through an RDS Proxy (TLS verify-full) over an App Runner VPC connector — no public endpoint. Prod is Multi-AZ; QC/Staging are single-AZ. The app authenticates as the least-privilege elas_app role (the master elas role is ops-only). Outbound AWS traffic (S3/Bedrock/Polly) uses VPC endpoints; third-party calls egress via a shared NAT gateway. See docs/BUILDING.md §J and the per-tier runbooks (QC · Staging · Prod). ⚠️ Old public DBs remain live until each tier's Phase-6 soak decommission runs.

Preview environments (per-PR)

A disposable copy of the whole app for a single pull request, on real AWS infra, running against a copy of QC's data. Use it to click through a change before merging, or to hand a reviewer a URL instead of a screenshot. Already set up and live — nothing to configure to use it.

Naming. The preview tooling, its workflows and the PR comment all say "QA"; the tier, its Terraform workspace and its SSM subtree all say qc. They are the same thing — the tier behind qc.elasapp.com, database elas-web-qc-priv, login host login-qa.elasapp.com. This page says QC throughout.

Open one

  • On a PR: add the Preview label. CI builds your branch, creates its schema in the QC database, deploys a Lambda, and comments the URL on the PR. Pushing more commits refreshes the same URL.
  • Without a PR: run Actions → “Preview env — up” and pass a preview_id (lowercase letters, digits and dashes — e.g. demo-search). Handy for a demo branch or a sales walkthrough.

Close one

  • Close or merge the PR — teardown is automatic.
  • Or just walk away: the idle reaper runs every 15 minutes and destroys any preview with no requests for 2 hours (PREVIEW_IDLE_TTL_MS in libs/preview/idle.ts).
  • Manually: Actions → “Preview env — down” with the preview_id (e.g. pr-123). Re-running it is a safe no-op.

What to expect

First create~20 min — it's a full image build — plus a few minutes the first time to clone QC's data into the new schema. Re-pushes reuse cached layers and the existing schema, so only migrations re-run.
First page loadA cold start of a few seconds (Lambda scales to zero, and is VPC-attached so an ENI has to come up). Then it's warm.
LoginYour usual QC account at login-qa.elasapp.com. CI registers a per-preview OIDC client, so you're asked to authorize the app once. Not embedded /api/ba auth.
DataA point-in-time copy of QC's public, taken when the preview was first provisioned, plus your branch's pending drizzle migrations. Real QC records — not empty — and it drifts from QC as QC moves on.
Emails / SMS / SlackOff. SENDS_ENABLED is deliberately unset, so a throwaway env can never message a real customer.
IntegrationsStripe, ADP, Whippy, Resend, Slack, Microsoft, QuickBooks and friends are deliberately absent from preview config. Each self-gates and degrades gracefully — don't test those flows here.

How a preview is kept out of QC's real rows. A preview is a schema inside the QC database, not a separate instance, so isolation is enforced by the database role rather than by the network. Two mechanisms, and both are required:

  1. ALTER ROLE … SET search_path — Postgres applies it at connection setup, so every unqualified query in the app resolves to the preview's schema with no application change.
  2. No grants on public — the backstop. search_path is only a routing hint: anything that says public. explicitly would otherwise reach QC's real rows. With no grants, such a statement fails loudly with a permission error instead. Don't fix that error by granting on public — fix the query. Migrations run as this same role, so a migration naming public. fails on the PR that introduced it.

Staging and prod are separate instances and are unreachable from a preview either way.

⚠️ Preview data is a copy of real QC records, and the function URL is unauthenticated at the edge (NONE). App login gates the content, but treat the URL as shareable-but-guessable and don't hand it to anyone who shouldn't see QC data.

How it's provisioned (two phases, both inside the VPC)

The QC database has no public endpoint, and its proxy accepts traffic only from inside the VPC, so nothing on the GitHub runner can reach it. Provisioning therefore happens in two Lambdas, and the split is a privilege boundary, not just a convenience:

what runsconnects aswhy separate
Phase Aelas-preview-provisioner — shared, image pinned to devQC master roleIt holds the master credential, so it must never run branch code. Creates the schema + role and clones QC's public.
Phase Belas-web-preview-<id>-migrate — a per-PR zipthe preview roleIt is branch code, so it must not sit next to that credential. Applies the branch's drizzle/*.sql.

Phase A only clones on a preview's first provision; a re-push reuses the schema and re-runs Phase B alone. Teardown drops the schema and role through the same Phase A function.

Setup (maintainers — already done, listed so it's reproducible)

Base AWS was applied 2026-09-09 (PR #1222); the in-VPC provisioner and its ECR repo were applied 2026-09-17 (PR #1523).

WhatWhereStatus
elas-preview-provisioner Lambda (VPC-attached, 2 GB / 15 min)AWS (infra/terraform/preview-bootstrap)✅ applied
elas-web-preview ECR repo (7-day expiry)AWS (infra/terraform/foundation.tf) — separate from elas-web so preview images can't evict a tier image prod might need to roll back to✅ applied
elas/web/preview/{db-master,db-app}Secrets Manager✅ applied
Shared runtime config (~32 keys)SSM /elas/web/preview/shared/*✅ applied
elas-gha-preview inline policy on elas-gha-deployIAM (additive; existing policy untouched)✅ applied
PREVIEW_GH_APP_ID + PREVIEW_GH_APP_PRIVATE_KEYRepo variable + secret (the same GitHub App the Releases screen uses; needs actions: write so the reaper can workflow_dispatch teardown)✅ set
Preview labelRepo labels✅ exists

Per-preview DATABASE_URL / BETTER_AUTH_URL are written by Terraform under /elas/web/preview/env/<preview_id>/* — never by hand. DATABASE_URL is composed in Terraform from the QC proxy endpoint it looks up itself, so a URL that works where provisioning ran but not where the app runs is unrepresentable.

Two leftovers, deliberately not cleaned up yet. The old elas-preview RDS instance is still provisioned in preview-bootstrap/main.tf and still billing, unused since previews moved onto the QC database — deleting an RDS instance wants its own decision. scripts/preview/bootstrap-seed.ts and the elas_preview_seed template it builds belong to that same retired design.

Cost & orphans. Idle compute is ~$0 (scale-to-zero); teardown reclaims the preview's schema and role from the QC database. Every resource is tagged preview=<id> + project=v3, so you can group Cost Explorer by the preview tag and sweep orphans — the command is in docs/PREVIEW_ENVS.md.

Full operator guide, architecture and QA plan: docs/PREVIEW_ENVS.md (spec: specs/ephemeral-preview-envs).


1. Tech stack & where things run

ConcernChoice
FrameworkNext.js 15 (App Router), React 19, TypeScript
HostingAWS App Runner (DNS points at App Runner). The Next.js app is containerized (standalone, Dockerfile) and runs on App Runner (elas-web) from an ECR image. It reaches RDS through an RDS Proxy over an App Runner VPC connector (egress = VPC, TLS verify-full) — the private-DB hardening (QC/Staging/Prod all cut over); the old direct-to-RDS default-egress path is retired. Runtime env/secrets in SSM Parameter Store + Secrets Manager, crons on EventBridge, infra in Terraform (infra/terraform/), CI/CD via a GitHub-OIDC role — release-gated: deploy-web.yml (verify → build → ECR → staging) on every push to main; prod is promoted on release-PR merge (release-please.yml retags :prod). NEXT_PUBLIC_* are injected at build time (CI reads them from SSM → Docker --build-args), since they're inlined into the client bundle, not read at runtime. Long-lived WebSocket/OCR services also run on App Runner (services/). Runbooks: docs/AWS_MIGRATION.md; DB hardening QC/Staging/Prod.
UITailwind 3 + shadcn/ui (app); DaisyUI (marketing only); Recharts (charts), Framer Motion
Data fetchingTanStack Query (hooks/use-erp.ts)
DatabasePostgreSQL (AWS RDS). The app connects via DATABASE_URL through an RDS Proxy (TLS verify-full) over an App Runner VPC connector to a private, KMS-encrypted instance — never a public endpoint — authenticating as the least-privilege elas_app role (per-tier hosts: prod elas-web-prod-priv Multi-AZ, staging elas-web-staging-priv, QC elas-web-qc-priv; proxies elas-web-<tier>-proxy). The proxy's TLS cert is a public Amazon root, so libs/db/pg.ts trusts Node's built-in roots (tls.rootCertificates) in addition to the RDS CA bundle. Every ERP resource lives in its own real typed table (libs/db/typed-model.ts — promoted indexed columns + a data jsonb tail); the generic erp_records JSONB store has been dropped (de-JSONB migration, drizzle/0072) and getErpModel() returns a typed model for every resource — it throws for an uncarved one, there is no JSONB fallback. The only JSONB left is mongo_docs, holding a shrinking set of low-volume domain models (Org/User + a few platform tables) through the Mongoose-compatible shim (libs/db/engine.ts); that store is being retired in the final de-JSONB phase. Mongo is gone (no runtime connection). Real pg transactions via libs/db/tx.ts. Migrations: drizzle/*.sql → scripts/pg/migrate.ts. Runbook: docs/POSTGRES_PILOT.md.
Authelas-identity — the separate identity service (ELASAPP/elas-identity; https://identity.elasapp.com, staging https://staging.identity.elasapp.com, QA https://qa.identity.elasapp.com). V3 authenticates as an OIDC client (AUTH_MODE=oidc): authorization-code + PKCE, ID tokens verified via JWKS/RS256, and a local session cookie minted at /api/oidc/callback. The old login.elasapp.com service is legacy (still running for Artie prod, no longer built from this repo); V3 keeps client-side legacy-issuer support (OIDC_LEGACY_ISSUER_URL, var.auth_oidc_tiers, var.elas_legacy_issuer_off_tiers). Sign-up is invite-only (ALLOW_SELF_SIGNUP off by default): unprovisioned emails are rejected at the source, and /signin shows an explicit "no active account" message. The super admin provisions customer owner accounts on /dashboard/customers. Migration notes: docs/AUTH_MIGRATION.md.
AI reasoningAWS Bedrock — Anthropic Claude (Sonnet 4.6 / Haiku 4.5; tiers via BEDROCK_MODEL_ID[_SMALL/_MID/_LARGE]) by default; OpenAI-compatible providers (incl. self-hosted Ollama for edge); provider-swappable in libs/artie/llm.ts
Vision / OCRClaude Sonnet 4.6 (multimodal — the default BEDROCK_VISION_MODEL_ID) for image understanding + OCR; docTR (DOCTR_OCR_URL) when configured; PDF via unpdf, DOCX via mammoth
Web searchTavily — platform-wide web_search MCP tool (news/markets/general web) gated on TAVILY_API_KEY; Artie offers to "search wider" when it can't answer internally (libs/integrations/tavily.ts)
VoiceAWS Transcribe (STT) + Amazon Polly (TTS). Default path is the streaming-STT relay; the realtime duplex gateway is opt-in (localStorage artie:realtime). Services on App Runner (services/voice-gateway, services/stt-relay)
Testing / CIVitest (*.test.ts next to code, npm test) — the CI verify gate (lint + tests) must pass before the image builds/deploys; a red commit can't reach production
RAG embeddings / vector searchBedrock Titan Text v2 embeddings (1024-d) + pgvector ANN (knowledge table, HNSW cosine; libs/artie/rag.ts); keyword fallback when no embed model
Agent protocolMCP (@modelcontextprotocol/sdk) — see §6
PaymentsStripe + Stripe Connect (per-org payouts) + Stripe Tax
File storagePrivate AWS S3 (S3_BUCKET) — uploads stored privately, served via presigned-URL redirect (/api/files/[...key], libs/storage/s3.ts)
EmailResend (branded HTML + PDF attachments)
SMS / callsWhippy (platform-owned, one account, per-tenant channels)
Maps / addressGoogle Maps Places (New)
PDF / archives@react-pdf/renderer (branded proposal/invoice PDFs); fflate (decompress uploaded .zip folders)

Multi-tenancy: every record is scoped to an Org (orgId). getRequestOrg() resolves the caller's org from the Better Auth session (dev bypass → seeded demo org in Postgres). All tenant data lives in Postgres; isolation is defense-in-depth: an org_id column filtered in app code and Postgres Row-Level Security (libs/db/rls.ts sets the app.org_id GUC via SET LOCAL per request, txn-scoped; carved typed tables are FORCE-RLS with a permissive-on-NULL policy so admin/migrations are unaffected — drizzle/0013_rls.sql). RLS is live in prod — see docs/PLATFORM_PLAN.md.


2. Backend services

The Next.js app is the backend — every app/api/* route handler (the governed ERP CRUD at /api/erp/:resource, Better Auth at /api/ba/*, Artie/agent, timesheets, files, etc.) runs in the elas-web App Runner container against RDS Postgres (via the RDS Proxy over the VPC connector — see the DB-hardening note in §Environments). Around it sit a few sidecars, the agent's tool servers, scheduled jobs, and managed dependencies:

Deployable microservices (services/, on App Runner — all OPTIONAL; the app degrades gracefully without each):

ServiceStackPurpose
services/stt-relayNode (WebSocket)Bridges browser PCM → AWS Transcribe streaming — the default voice path. Falls back to browser STT if absent.
services/voice-gatewayNode (WebSocket)Realtime duplex voice (opt-in via localStorage artie:realtime), OpenAI-Realtime-shaped: STT → agent → Polly TTS + barge-in.
services/doctr-ocrFastAPI / PythondocTR OCR microservice behind DOCTR_OCR_URL (receipt scanning); the app falls back to the Bedrock vision model when unset.
services/miso-ttsFastAPI / PythonSelf-hosted MISO TTS — an alternative to Polly in the TTS preference chain.

Central identity (separate elas-identity service, repo ELASAPP/elas-identity — not optional for a tier running AUTH_MODE=oidc): https://identity.elasapp.com (staging https://staging.identity.elasapp.com, QA https://qa.identity.elasapp.com). It is the OIDC provider V3 and other apps delegate to. It is deployed independently of elas-web and is not built from this repo. The old login.elasapp.com Better Auth service is legacy: it still runs for Artie prod and is no longer built here.

MCP servers (the agent's tool backends, mounted in-process by libs/mcp/host.ts — see §6): elas-core (internal business tools), workspace (email/Slack/Jira), and the built-in connectors quickbooks · payroll · toast · square · shopify (+ pos). Tenants can also plug in any remote MCP server from the Integrations screen.

Scheduled jobs (EventBridge → /api/cron/*, fail-closed on CRON_SECRET): sequences, alerts, timesheet-reminders, dataset-refresh, demo-cleanup, pto-accrual, revops-snapshot, graph-build, integration-sync.

Managed / third-party dependencies: RDS (Postgres) · Bedrock (Claude reasoning+vision, Titan embeddings for pgvector RAG) · Transcribe + Polly (voice) · S3 (private files) · Stripe / Stripe Connect (payments) · Resend (email) · Whippy (SMS/calls) · Tavily (web search) · Google Maps · config & secrets in SSM Parameter Store + Secrets Manager. Each integration self-gates on its keys and degrades gracefully when unset (§6).


3. Modules & active features

Sales / CRM — Lead Inbox (Gmail/Outlook/Whippy), AI lead scoring & triage, contacts + accounts (autosuggest linking, editable source/industry catalogs), pipeline/deals, sequences (real enrollment), estimates (3-step wizard: template/AI-upload → details → review; nested sections; billing types + kind; sub-categories; cost codes; section attachments; e-signature; signed → project/work-order automation; Stripe deposit on approval).

Operations — Projects (kanban, persisted), Dispatch board (persisted), Work Orders (→ invoice), Contracts, Purchase Orders & Approvals, Fleet, Equipment, Inventory, Field worker view.

Finance — AR (Stripe payable invoices + pay-link delivery tracking + webhook → Paid), AP/Bills, Chart of Accounts, Journal Entries, Vendors, finance dashboard, cost-code catalog (CSI MasterFormat seed).

HR / Team — Employees, Departments, Org chart, Hiring & Onboarding, Performance reviews, Timesheets (work-order + free cost codes, employee entry vs. manager approval).

Platform admin (ElasAdmin) — Customers (tenants), per-company billing/licensing, per-company platform fee %, Whippy number assignment, feature flags.

RevOps (Sales Engine) — /dashboard/revops: a Sales Engine Score (gauge + pillars + live metric tiles) with a Today / 7 / 30 / 90-day time window that re-windows the demand tiles; plus a signals feed, acquisition funnel, revenue snapshot, and a Calendly bookings card (next-7-days, meetings-by-person, a 30-day calendar). Powered by the RevOps connectors (§7 — Stripe revenue, Aircall calls, Calendly meetings, DataFast traffic), which feed both the score and Artie (get_revenue / get_calls / get_meetings / get_funnel).

Artie (AI agent) — chat + voice, tool-calling over MCP, audited & undoable actions, a live "what Artie is doing" step preview (per-tool SSE events), real memory (remember), RAG, global search. Default dashboard is role-driven and permission-scoped.

Knowledge Base — the unified /dashboard/knowledgebase screen with scoped lanes (Mine / Department / Org / Global & Model) over a single visibility ladder (user→department→org→global). Datasets, chats saved as knowledge, and skills all live here. Datasets (file/zip/feed-backed tables) get custom dashboards and a Postgres SQL join engine (dataset↔dataset star-schema joins). The standalone "My Data"/Datasets screen is folded in (the old /dashboard/datasets redirects here); Bulk Import lives inside it ("Import data"). Only the platform super admin can add global/parametric model-training data. Design: docs/KNOWLEDGE_MODEL.md.


4. Licensing (plans) & Access Levels

Defined in libs/access.ts (labels in libs/access/labels.ts); canonical playbook in docs/ACCESS.md.

  • Plan (Org.license → normalized to basic | pro | enterprise): gates whole modules via entitled(plan, module).

  • Access Level — one ordered per-user axis, highest → lowest: Company Admin / Admin / Manager / Member. It's a lens over the two stored fields (role + seatType) — no new column. accessLevelFor(role, seat) maps them:

    Access LevelroleseatTypeWhat it gets
    Company AdminownerpowerFull access within plan plus org administration (billing, users/roles, org settings)
    AdminadminpowerEvery operational module within plan; not org administration (unless explicitly granted)
    ManagermemberpowerMember defaults + the manager dashboard. Seat is a ceiling, not a granter: POWER_ONLY actions (approve/configure/deploy/manage) still need an explicit grant — the power seat only removes the cap so a granted one takes effect
    MembermemberteamView/edit/export within plan, capped below POWER_ONLY

    "Super Admin" is not an Access Level — that term is reserved for the platform operator (libs/admin.ts).

  • Underlying storage is unchanged: role (owner | admin | member) + seatType (team "User" | power "Power User"). Access Level is derived from them for display and assignment; can() still enforces on role + seatType + permissions[].

  • Company Admin vs Admin split: org-administration permissions are namespaced under the org module (org.manage, org.team.invite, org.team.assign_role, org.billing, org.data.export, org.data.import). Company Admin holds them; a plain Admin does not, unless a specific org.* perm is granted explicitly (existing admins are grandfathered so no one loses access).

  • Seat default is reconciled to one canonical rule: base default team, elevated to power for owner/admin via defaultSeat(role). models/User.ts and the Drizzle column default both resolve to team; role-based elevation happens in code on every creation path.

  • Permissions: module.action (e.g. sales.edit, finance.view, operations.edit, org.manage, agent.use).

  • Role templates (roleDefinitions, assigned via User.customRole): nine seeded FOSA-aligned system templates (CompanyAdmin, Sales/Finance/Payroll/HR/Department/Office-Location Managers, Team Lead, Employee) plus admin-authored custom roles. A custom role is a strict allow-list (turns off the member view/edit/export defaults).

  • Marketplace addon roles: an installed addon can seed default role templates into roleDefinitions (seed-if-absent, idempotent, never auto-assigned), via the slug-keyed ADDON_ROLE_SEEDERS registry. RevOps seeds two personas (Analyst, Manager); Sales Playbook and Bid Finder seed their own. See docs/ACCESS.md → Marketplace addon roles.

can(ctx, perm) resolution order:

  1. platform-admin → allow
  2. plan entitlement (perm's module not in plan → deny)
  3. owner (Company Admin) → allow within plan
  4. admin → allow within plan, except org.* (org administration), which requires an explicit grant
  5. explicit grant (permissions[]) → allow (overrides seat ceiling, not plan)
  6. custom role → strict allow-list (member *.view/edit/export defaults turned off)
  7. seat ceiling → blocks POWER_ONLY actions for team
  8. otherwise the member role default decides

Server is always the source of truth; useAccess() mirrors it client-side for hiding/disabling UI.


5. Permissions enforcement

  • Generic ERP routes (/api/erp/:resource): GET→.view, POST/PATCH/bulk→.edit, DELETE→.delete, mapped by resourceModule() / permFor().
  • Agent (Artie) ⊆ user: every write tool declares a required permission; the MCP host checks can(access, perm) before executing, plus write-disable and a bulk-confirm threshold. Chat requires agent.use.
  • Dedicated routes: check can() explicitly.
  • Org administration (org.*): held by Company Admin, or by an Admin only where the specific org.* perm is an explicit grant — so consolidation never widens what a plain Admin can do.

6. Agentic AI architecture (MCP-native)

One host, a few domain servers. Artie is a single MCP host/client (libs/mcp/host.ts); tools are discovered (listTools) and executed (callTool) over the protocol — they are not hardcoded into the agent.

Artie (MCP host)  ──in-memory──▶  elas-core   (internal ERP: read/write/navigate + knowledge resource)   [always on]
                  ──in-memory──▶  workspace   (email · Slack · Jira)                                        [always on]
                  ──in-memory──▶  quickbooks  (customers/vendors/AR/AP/cost-codes/timesheets, 2-way)        [when connected]
                  ──in-memory──▶  payroll     (ADP/Paycor timesheet export + run payroll)                   [when connected]
                  ──HTTP──────▶  <any remote MCP server you connect in Integrations>                        [per-tenant]
  • You do NOT need one MCP server per integration. Group by domain/auth-lifecycle. Lightweight tools live in a shared server (workspace); heavier connectors with their own OAuth/sync get their own (quickbooks, payroll).
  • Built-in connectors mount in-process via the registry (McpConnection, kind builtin). Remote MCP servers connect over streamable-HTTP — paste a URL in Integrations → AI tool servers (MCP) and the tools appear to Artie with no code change ("no integration projects").
  • Governance travels with each tool: internal tools use a static map (libs/mcp/governance.ts); remote/built-in tools derive governance from their MCP annotations (readOnlyHint → no write-gating; otherwise external + the connection's permission). All actions audited (ArtieAction).
  • Eval harness: /api/artie/eval (platform-admin) runs golden prompts → expected tool to catch tool-calling regressions.
  • Toggle off with ARTIE_MCP=false (falls back to the legacy in-process registry).

7. Integrations

IntegrationTypeStatus
Gmailper-user OAuth (Lead Inbox) + agent recent_emails via workspace MCPlive
Outlook / Microsoft 365per-user OAuth (mail read/send, calendar) — /api/outlook/*live (needs MICROSOFT_CLIENT_ID/SECRET)
Slackorg OAuth, channel post (agent post_slack)live
Jiraper-org API token; agent create/search issueslive
Whippyplatform-managed SMS/calls + agent send_smslive
Stripe / Connect / Taxdeposits + payable invoices + webhooklive
QuickBooks OnlineOAuth2, two-way sync via quickbooks MCP serverlive (needs QBO_*)
ADP / Paycortimesheet CSV export (now) + API push (gated) via payroll MCP serverlive
Email sendResend (agent send_email)live
Aircallper-org API key; call activity → Sales Engine + agent get_callslive
Calendlyper-org token; bookings → Sales Engine + agent get_meetingslive
DataFastper-org API key; web traffic/funnel → Sales Engine + agent get_funnellive
Stripe (revenue)per-org key; MRR/ARR/churn → Sales Engine + agent get_revenuelive

The RevOps data sources (Stripe revenue, Aircall, Calendly, DataFast) live in the same Integrations catalog (a "RevOps" category — no separate section), connected via /api/revops/connectors. They feed the Sales Engine Score (§3) and are exposed to Artie over MCP.


8. Environment variables

Core: DATABASE_URL (Postgres/RDS), PG_DUALWRITE / PG_READ (legacy strangler-migration flags — effectively no-ops now that Mongo is gone and every ERP resource is a typed table), BETTER_AUTH_SECRET, BETTER_AUTH_URL, GOOGLE_ID, GOOGLE_SECRET, RESEND_API_KEY (magic-link delivery), ALLOW_SELF_SIGNUP (off by default → invite-only; true re-opens public sign-ups), ALLOW_DEV_NO_AUTH (local dev bypass). Google OAuth redirect URI for Better Auth: <BETTER_AUTH_URL>/api/ba/callback/google. (MONGODB_URI is no longer used at runtime — kept only for the one-off scripts/pg/backfill-* data-migration scripts.) AI (Bedrock, default): AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, BEDROCK_MODEL_ID (Claude, e.g. us.anthropic.claude-sonnet-4-6; + optional _SMALL/_MID/_LARGE tiers — small ≈ Haiku 4.5), BEDROCK_EMBED_MODEL_ID (amazon.titan-embed-text-v2:0), BEDROCK_VISION_MODEL_ID (defaults to the Claude Sonnet text model — multimodal), BEDROCK_MAX_TOKENS, BEDROCK_TEMPERATURE. OpenAI-compatible alternative: LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_VISION_MODEL. Agent knobs: ARTIE_ALLOW_SEND, ARTIE_BULK_THRESHOLD, ARTIE_MAX_STEPS, ARTIE_MCP, ARTIE_LLM_PROVIDER. Web search (platform-wide): TAVILY_API_KEY — backs Artie's web_search tool (news/markets/general web). Unset → web search degrades off. Observability (OpenTelemetry, optional): OTEL_EXPORTER_OTLP_ENDPOINT is the master switch — set it to an OTLP HTTP endpoint (a collector, or a SaaS like Grafana/Honeycomb/Datadog/CloudWatch-via-ADOT) to turn on vendor-neutral traces + logs + metrics; unset → the whole subsystem is a no-op (no SDK init, no export). Optional knobs: OTEL_EXPORTER_OTLP_HEADERS (auth/tenant headers for the endpoint, standard OTLP form), OTEL_SERVICE_NAME (default elas-web), OTEL_ENVIRONMENT (default NODE_ENV), OTEL_METRIC_EXPORT_INTERVAL_MS (default 60000). The SDK initializes in instrumentation.ts: the full Node SDK on the nodejs runtime (auto-instruments HTTP/fetch/pg, routes libs/log.ts through the OTel Logs API for trace-correlated logs, records full error stack traces + org.id on the active span in withRoute(), and exports metrics via a MeterProvider — app code records custom metrics through libs/observability/metrics.ts), plus a minimal edge-runtime tracer (libs/observability/otel.edge.ts, fetch-based OTLP — the edge runtime can't load the Node SDK). Browser RUM is opt-in via NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT (build-time inlined): when set, libs/observability/web.ts traces document-load + fetch from the browser to the collector (NEXT_PUBLIC_OTEL_SERVICE_NAME optional). The OTLP collector itself is deployed by infra/terraform/otel_collector.tf (ADOT on ECS Fargate + ALB — App Runner can't run a sidecar); point OTEL_EXPORTER_OTLP_ENDPOINT at it per tier. Spans carry only org.id/user.id — never PII or secrets. See .kiro/specs/otel-observability/. Storage / OCR: S3_BUCKET (+ AWS_* above) for private uploads; DOCTR_OCR_URL/DOCTR_API_KEY (optional — vision model is the OCR fallback). Voice (optional): NEXT_PUBLIC_STT_WS_URL (streaming STT relay on ECS Fargate + ALB — App Runner cannot host WebSockets), NEXT_PUBLIC_REALTIME_VOICE_URL (duplex gateway — same WS limitation on App Runner), VOICE_GATEWAY_SECRET, POLLY_VOICE. Payments/email/comms/maps: STRIPE_PLATFORM_SECRET_KEY (or STRIPE_SECRET_KEY), STRIPE_WEBHOOK_SECRET/STRIPE_CONNECT_WEBHOOK_SECRET, PLATFORM_APP_FEE_PERCENT, RESEND_API_KEY, WHIPPY_API_KEY, NEXT_PUBLIC_GOOGLE_MAPS_API_KEY. Connectors: MICROSOFT_CLIENT_ID/MICROSOFT_CLIENT_SECRET (Outlook), QBO_CLIENT_ID/QBO_CLIENT_SECRET/QBO_SANDBOX, ADP_CLIENT_ID/ADP_CLIENT_SECRET, PAYCOR_CLIENT_ID/PAYCOR_CLIENT_SECRET/PAYCOR_SUBSCRIPTION_KEY. (Jira is per-org, set in the UI.) Platform: PLATFORM_ADMIN_EMAILS (the platform-admin allowlist). Two-tier admin (optional): PLATFORM_ADMIN_RESTRICTED_EMAILS — a subset of platform admins who are restricted: still full platform admins for customer tenants, but denied platform-admin AND company-admin access to the protected org(s) in PLATFORM_ADMIN_PROTECTED_ORG_IDS (defaults to the elas company org; setting the var REPLACES that default). A platform admin not listed as restricted is "god mode" (unrestricted). Keep PLATFORM_ADMIN_EMAILS in sync with the identity service copy (elas-identity).

Integrations have no .env fallbacks — they're disabled and degrade gracefully until configured.

On AWS these same vars are not in a .env — they live in SSM Parameter Store (/elas/web/*) + Secrets Manager (RDS Proxy creds), injected into App Runner at runtime (never baked into the image); the scheduled /api/cron/* jobs run from EventBridge (infra/terraform/scheduler.tf). See docs/AWS_MIGRATION.md.

Preview envs read a different subtree. Per-PR previews load config from /elas/web/preview/shared/* (32 keys) plus per-env overrides at /elas/web/preview/env/<preview_id>/*, fetched at Lambda boot by infra/preview/bootstrap/load-ssm.mjs — Lambda's 4 KB env limit can't hold them. Adding a new required var means seeding it there too, or previews break while App Runner keeps working. External-side-effect creds (Stripe, ADP, Whippy, Resend, Slack, Microsoft, QuickBooks, OIDC/provisioning tokens) are deliberately excluded, and SENDS_ENABLED / AUTH_MODE are left unset.


9. Building / got a question? — start here

Not sure if a change is backend or frontend, what the workflow is, or which files to touch? Ask the triage tool — type the issue (or paste a customer/Slack message) and it tells you backend / frontend / both, whether a DB migration is likely, the spec-driven steps, which IDE/tool, and the files to touch:

npx tsx scripts/triage.ts "the status dropdown should also show on mobile"
npx tsx scripts/triage.ts "give customers a switch to turn Artie off org-wide"   # → BOTH
npx tsx scripts/triage.ts --no-ai "tighten the card padding"                     # offline (rules only)

It's AI-backed when a model is configured (BEDROCK_MODEL_ID or LLM_*), and falls back to a deterministic keyword classifier otherwise.

Then build it the right way:

  • docs/BUILDING.md — the one-stop guide for adding a feature, ERP resource, agent tool, or MCP connector (agent + permission + licensing structure).
  • docs/ACCESS.md — permissions/plans/seats playbook.
  • docs/SESSION_SUMMARY.md — how the Postgres data layer + auth actually work under the hood.
  • docs/PLATFORM_PLAN.md — the evolution roadmap + live status checklist.
  • docs/PREVIEW_ENVS.md — per-PR preview environments: how to open/close one, the architecture, the one-time setup, and the manual QA plan.