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:prodtag). DNS (Cloudflare CNAME) points at App Runner. App DB is the private, KMS-encrypted, Multi-AZelas-web-prod-privreached only via theelas-web-prod-proxyRDS Proxy over theelas-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) viaelas-web-staging-proxy, behind Cloudflare Access, sends enabled. Release-gated deploys: every push tomainships to staging only (image promoted to:staging); prod is promoted by merging the release-please PR (retags the tested image:latest→:prod, no rebuild). Seedocs/RELEASES_SOP.md. - QC: https://qc.elasapp.com — AWS App Runner (
elas-web-qc, watches:latest), private RDS (elas-web-qc-priv) viaelas-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 thePreviewlabel to a PR and CI comments the URL. See Preview environments below anddocs/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 oldlogin.elasapp.comauth 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-privilegeelas_approle (the masterelasrole is ops-only). Outbound AWS traffic (S3/Bedrock/Polly) uses VPC endpoints; third-party calls egress via a shared NAT gateway. Seedocs/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 behindqc.elasapp.com, databaseelas-web-qc-priv, login hostlogin-qa.elasapp.com. This page says QC throughout.
Open one
- On a PR: add the
Previewlabel. 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_MSinlibs/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 load | A 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. |
| Login | Your 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. |
| Data | A 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 / Slack | Off. SENDS_ENABLED is deliberately unset, so a throwaway env can never message a real customer. |
| Integrations | Stripe, 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:
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.- No grants on
public— the backstop.search_pathis only a routing hint: anything that sayspublic.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 onpublic— fix the query. Migrations run as this same role, so a migration namingpublic.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 runs | connects as | why separate | |
|---|---|---|---|
| Phase A | elas-preview-provisioner — shared, image pinned to dev | QC master role | It holds the master credential, so it must never run branch code. Creates the schema + role and clones QC's public. |
| Phase B | elas-web-preview-<id>-migrate — a per-PR zip | the preview role | It 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).
| What | Where | Status |
|---|---|---|
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-deploy | IAM (additive; existing policy untouched) | ✅ applied |
PREVIEW_GH_APP_ID + PREVIEW_GH_APP_PRIVATE_KEY | Repo variable + secret (the same GitHub App the Releases screen uses; needs actions: write so the reaper can workflow_dispatch teardown) | ✅ set |
Preview label | Repo 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-previewRDS instance is still provisioned inpreview-bootstrap/main.tfand still billing, unused since previews moved onto the QC database — deleting an RDS instance wants its own decision.scripts/preview/bootstrap-seed.tsand theelas_preview_seedtemplate 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
| Concern | Choice |
|---|---|
| Framework | Next.js 15 (App Router), React 19, TypeScript |
| Hosting | AWS 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. |
| UI | Tailwind 3 + shadcn/ui (app); DaisyUI (marketing only); Recharts (charts), Framer Motion |
| Data fetching | TanStack Query (hooks/use-erp.ts) |
| Database | PostgreSQL (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. |
| Auth | elas-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 reasoning | AWS 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 / OCR | Claude 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 search | Tavily — 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) |
| Voice | AWS 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 / CI | Vitest (*.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 search | Bedrock Titan Text v2 embeddings (1024-d) + pgvector ANN (knowledge table, HNSW cosine; libs/artie/rag.ts); keyword fallback when no embed model |
| Agent protocol | MCP (@modelcontextprotocol/sdk) — see §6 |
| Payments | Stripe + Stripe Connect (per-org payouts) + Stripe Tax |
| File storage | Private AWS S3 (S3_BUCKET) — uploads stored privately, served via presigned-URL redirect (/api/files/[...key], libs/storage/s3.ts) |
| Resend (branded HTML + PDF attachments) | |
| SMS / calls | Whippy (platform-owned, one account, per-tenant channels) |
| Maps / address | Google 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):
| Service | Stack | Purpose |
|---|---|---|
services/stt-relay | Node (WebSocket) | Bridges browser PCM → AWS Transcribe streaming — the default voice path. Falls back to browser STT if absent. |
services/voice-gateway | Node (WebSocket) | Realtime duplex voice (opt-in via localStorage artie:realtime), OpenAI-Realtime-shaped: STT → agent → Polly TTS + barge-in. |
services/doctr-ocr | FastAPI / Python | docTR OCR microservice behind DOCTR_OCR_URL (receipt scanning); the app falls back to the Bedrock vision model when unset. |
services/miso-tts | FastAPI / Python | Self-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 tobasic | pro | enterprise): gates whole modules viaentitled(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 Level roleseatTypeWhat it gets Company Admin ownerpowerFull access within plan plus org administration (billing, users/roles, org settings) Admin adminpowerEvery operational module within plan; not org administration (unless explicitly granted) Manager memberpowerMember defaults + the manager dashboard. Seat is a ceiling, not a granter: POWER_ONLYactions (approve/configure/deploy/manage) still need an explicit grant — the power seat only removes the cap so a granted one takes effectMember memberteamView/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 onrole+seatType+permissions[]. -
Company Admin vs Admin split: org-administration permissions are namespaced under the
orgmodule (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 specificorg.*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 topowerforowner/adminviadefaultSeat(role).models/User.tsand the Drizzle column default both resolve toteam; 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 viaUser.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-keyedADDON_ROLE_SEEDERSregistry. RevOps seeds two personas (Analyst, Manager); Sales Playbook and Bid Finder seed their own. Seedocs/ACCESS.md→ Marketplace addon roles.
can(ctx, perm) resolution order:
- platform-admin → allow
- plan entitlement (perm's module not in plan → deny)
owner(Company Admin) → allow within planadmin→ allow within plan, exceptorg.*(org administration), which requires an explicit grant- explicit grant (
permissions[]) → allow (overrides seat ceiling, not plan) - custom role → strict allow-list (member
*.view/edit/exportdefaults turned off) - seat ceiling → blocks
POWER_ONLYactions forteam - 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 byresourceModule()/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 requiresagent.use. - Dedicated routes: check
can()explicitly. - Org administration (
org.*): held by Company Admin, or by an Admin only where the specificorg.*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, kindbuiltin). 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
| Integration | Type | Status |
|---|---|---|
| Gmail | per-user OAuth (Lead Inbox) + agent recent_emails via workspace MCP | live |
| Outlook / Microsoft 365 | per-user OAuth (mail read/send, calendar) — /api/outlook/* | live (needs MICROSOFT_CLIENT_ID/SECRET) |
| Slack | org OAuth, channel post (agent post_slack) | live |
| Jira | per-org API token; agent create/search issues | live |
| Whippy | platform-managed SMS/calls + agent send_sms | live |
| Stripe / Connect / Tax | deposits + payable invoices + webhook | live |
| QuickBooks Online | OAuth2, two-way sync via quickbooks MCP server | live (needs QBO_*) |
| ADP / Paycor | timesheet CSV export (now) + API push (gated) via payroll MCP server | live |
| Email send | Resend (agent send_email) | live |
| Aircall | per-org API key; call activity → Sales Engine + agent get_calls | live |
| Calendly | per-org token; bookings → Sales Engine + agent get_meetings | live |
| DataFast | per-org API key; web traffic/funnel → Sales Engine + agent get_funnel | live |
| Stripe (revenue) | per-org key; MRR/ARR/churn → Sales Engine + agent get_revenue | live |
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
.envfallbacks — 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). Seedocs/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 byinfra/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, andSENDS_ENABLED/AUTH_MODEare 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.