Maple is an OpenTelemetry observability platform: a Foldkit (Vite) dashboard + Effect on the backend, ClickHouse/Tinybird as the warehouse, PlanetScale Postgres for relational state.
apps/*: deployables (web, api, ai, ingest, alerting, cli, landing, sandbox, ...).packages/*: shared code that knows Maple (domain,query-engine,backend,ui,db, ...).viewsis the Foldkit kit, charts and service map thatapps/webandapps/local-uishare.lib/*: zero Maple knowledge, could ship as a standalone OSS library. Alib/package that imports@maple/domainmust move topackages/. New packages default topackages/.
@maple-dev/effect-orm lives in the separate effect-orm repo; change it there.
bun dev # full alchemy dev stack at https://[<worktree>.]<app>.localhost
bun dev api web # subset
bun --filter=@maple/web dev # single app, raw port
bun run test # Vitest via turbo (NOT `bun test`)
bun typecheck
bun run tinybird:manifest # after editing datasources.ts
bun run local-schema:bump <slug> # local chDB schema bump for a datasources.ts change
bun db:up && bun db:migrate:local
bun run --cwd packages/db db:generate # new migration (effect-orm)
bun run dev:signin # one-shot signed-in Clerk link (dev user david+clerk_test@gmail.com)Dev password fallback: Maple-Dev-Kx92qZ!. Toolchain is pinned in mise.toml; keep bun in sync
with packageManager.
apps/airuns the public MCP server and the shared agent runtime (theChatSessionDurable Object) behind investigations and their follow-ups, PR review and the Slack/Discord bot.apps/apiforwards/mcp,/api/chat/*,/internal/chat/*to it over a service binding; OAuth stays inapps/api.- An investigation is one agent turn ending in
submit_diagnosis. Do not split it across planner/lanes/validator. Sub-agents are fine elsewhere (e.g. review fan-out) viaSubagentHost. - LLM code (Effect AI +
@yielded/agent) is only inapps/ai; Maple-specific wiring lives inapps/ai/src/platform/Llm.ts. - Shared services live in
packages/backend, imported via@maple/backend/*subpaths.
- Services acquire their dependencies in the constructor; public methods must not leak implementation services in their Effect requirements. Request-scoped context (transactions, tenant, actor) stays on the invocation, never captured at construction.
- Each service owns
static readonly layer = Layer.effect(this, this.make)with internal deps viaLayer.provide.Env,Database, caches, and bindings come from the app root.
No Tinybird pipes/endpoints. All queries use the ClickHouse DSL in @maple/query-engine and run
through WarehouseQueryService.compiledQuery(). Never fetch() /v0/sql.
Add a query in packages/query-engine/src/ch/queries/*.ts, export from src/ch/index.ts:
const rows = yield* warehouse.compiledQuery(tenant, CH.compile(CH.myQuery({ limit: 50 }), { orgId, startTime, endTime }), {
profile: "list",
context: "myQuery",
})- Every query must filter
OrgId($.OrgId.eq(param.string("orgId"))); enforced at runtime. - Pass the unrun
CH.compileEffect; neverEffect.orDieit at a call site. - Identity UInt64s (hashes/ids) must be
toString()-wrapped in the SELECT.rowSchemas useCH.CHNumber, neverSchema.Number. - Request fields that reach
param.*or column comparisons are validated at the HTTP boundary (TinybirdDateTime,BucketSeconds,WarehouseDateTime) so bad input is a 400, not a 500. - The query-engine root barrel stays driver-free (web/cli import it).
packages/domain/src/tinybird/endpoints.tsis type-only.- Read
docs/warehouse-rollups.mdbefore adding a materialized view. Query perf work usesbun run bench:queries(seedocs/query-benchmarking.md); empty-table timings prove nothing.
Tables in packages/db/src/tables/ (@maple/db/tables), reached via Hyperdrive binding
MAPLE_DB. Queries use @maple-dev/effect-orm: database.execute((db) => db.run(PG.from(T)...)),
db.transaction(effect). Rows are typed by the table: branded ids, literal unions, schema-typed
jsonb, timestamps as epoch ms (PG.timestamptzMillis), so compare them with === null, never by
truthiness.
- No untyped SQL. A query the builder lacks is a
PG.sqltemplate with a typed result. - Readers that must tolerate old jsonb shapes select the column with
PG.undecoded. - Change a table, then
bun run --cwd packages/db db:generate --name <x>: it diffs the tables against the newest snapshot and writespackages/db/drizzle/<ts>_<x>/migration.sql.parity.test.tsfails when the tables and the migrations disagree. - One pool per invocation via
withPgConnectionScope; connections never outlive the invocation. Fork DB work off a request only withforkRequestScoped. - Tests use
createTestDb()(PGlite,packages/backend/src/platform/test-pglite.ts). - Migrations apply in the prd alchemy deploy.
db:migrateis for local databases; never point it at prd. - PR previews (only with the
previewlabel) get their own Neon branch, migrated by the deploy (docs/pr-previews.md).
- Schemas: Effect Schema, not Zod.
Schema.optionalKey()for JSON HTTP/domain schemas,Schema.optional()only whereundefinedis a real value (search params, MCP tool params). - Errors: expected failures use
Schema.TaggedErrorwith a namespaced tag,message, and schema-backed context (Schema.Defect()for unknown causes). Keep them in the typed channel; remap withcatchTag/catchTags. No plainError, throws, orData.TaggedError. Branded IDs in error context; undecodable input goes in araw*string, never cast to a brand. - Types:
Record<string, any>is a lint error (repo is at zero). UseRecord<string, unknown>. - Library source: read
node_modules/effect/src/,node_modules/@effect/*/src/, andnode_modules/alchemy/src/for APIs at the installed version. - Span status codes: Title case:
"Ok","Error","Unset". Only 5xx isErrorfor server spans. Custom attributes usemaple.*. - Self-tracing: the API traces itself through ingest; keep
withTracerDisabledWhen()and avoid spans on hot paths like token validation. - UI: the dashboard (
apps/web) and Maple Local (apps/local-ui) are Foldkit +@foldkit/ui+ Tailwind 4, Nucleo icons. Dashboard pages are built from the kit only; readapps/web/CLAUDE.mdbefore UI work (find the component inpackages/views/src/ui/INDEX.md, build missing ones in the kit). Add icons topackages/views/src/ui/icons.generated.ts(element trees) from the local Nucleo DB:sqlite3 "~/Library/Application Support/Nucleo/icons/data.sqlite3" \ "SELECT id, name, set_id FROM icons WHERE klass='outline' AND grid=24 AND name LIKE '%search%';"
@/resolves to each app's source.- Tests: assert behavior, not source.
test-slop/*rules (scripts/oxlint-plugins/test-slop.mjs) ban constant-vs-literal pins, existence-only/does-not-throw tests, Tailwind class pins and tests that read source files. Import and architecture boundaries areno-restricted-importsoverrides in.oxlintrc.json, not tests.
Every tool is server.define({ parameters, output, hints, handler, render })
(apps/ai/src/mcp/tools/types.ts); the registry owns decode, aliases, errors, output encoding and
rendering. registry.contract.test.ts enforces it across the catalog.
- Parameters come from
apps/ai/src/mcp/lib/params.ts(P.timeWindow,P.service,P.limit,P.oneOf...): one spelling per concept, retired names only asaliases. - Output is an Effect Schema in
packages/domain/src/mcp-outputs/(outputSchema/structuredContent, whichapps/clidecodes). The model readsrender(output), aToolDocwhosenextcalls the registry validates. - Failures are typed (
McpInvalidInputError,McpNotReadyError,McpUnavailableError,McpQueryBudgetError,McpQueryError); noisErrorliterals in tools.
Internal-only agent tools (sandbox_*, apps/ai/src/mcp/tools/sandbox.ts) run commands against a
git clone in Cloudflare's Sandbox container (apps/sandbox, prd only). Invariants:
- Refs are validated (
unsafeRef) and encoded before the GitHub call. - The clone credential never appears in command arguments.
- Commands run unprivileged, read-only, no egress; refuse to run if the network namespace fails.
Test with bun run --cwd apps/sandbox verify:image (needs Docker); it catches what unit tests can't.
A PR labelled preview deploys the full stack to app-pr-<n>.maple.dev / api-pr-<n>.maple.dev
on the Clerk development instance (docs/pr-previews.md). Default to preview:web for dashboard,
API or AI changes: it deploys only the Workers on the same seeded data, faster and without a VPC.
Use preview only to test ingest, Electric live sync, landing, or a connect flow that sends
telemetry. To sign in to either:
infisical run --env dev -- bun run preview:signin <n> # one-shot signed-in link, valid 2 minutesIt signs in as the shared reviewer preview-reviewer+clerk_test@maple.dev in the Preview org,
which the deploy's smoke test seeds with demo telemetry. Humans can instead use the "PR preview"
form on the sign-in page with PREVIEW_SIGNIN_KEY (Infisical dev).
- Never type the key into a form or print it: mint the link with the command above and open it.
{"enabled":false}fromGET /api/auth/preview/sign-inmeans the deploy ran without the key; re-run the preview deploy.- A preview hostname that will not resolve locally right after deploy is a cached NXDOMAIN; it
clears on its own (or use
curl --resolvewithdig @1.1.1.1).
Read the relevant doc before touching an area: error-issue-lifecycle.md (anything in
packages/backend/src/services/errors/), warehouse-rollups.md, service-map-architecture.md,
ingest-wal-durability.md, local-mode.md, infra.md, api-v2.md, otel-spec/,
dashboard-grid.md (the dashboard canvas and lib/grid-engine).
Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the turbo package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run node -p "require.resolve('turbo/package.json')" from a workspace that depends on turbo.
Read docs/README.md inside that installed package first, then read the relevant pages from its docs/ directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access.
This block is written and re-added by turbo before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in crates/turborepo-cli/src/cli/agent_guidance.rs. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set "agentGuidance": false in the root turbo.json or turbo.jsonc to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation.