For builders
Pectus architecture
How the pieces fit. Read this if you’re trying to understand how Pectus is wired — whether to extend your own install or to contribute something back.
If you haven’t read concepts, start there. This doc goes deeper into the wiring.
What Pectus is
A container, not a fixed product. The container is uniform: a CMS that runs skills, apps that move data in and out, a core that holds your identity, and connectors to outside services. What the container does depends on the skills and apps you install.
The current download ships skills and apps focused on content publishing (keyword analysis, brand-aware drafts, page builder, GitHub-backed publish). The same machinery hosts skills for customer research, sales enablement, ad performance, internal reporting — anything that fits the SKILL.md + schema contract.
Read the rest of this page as the shape of the container. The bundled examples illustrate one configuration of it.
Layered model
Pectus is split into four layers plus the surfaces:
┌──────────────────────────────────────────┐
│ cms/ CLI │
├──────────────────────────────────────────┤
│ skills/ │
│ apps/ inbound + outbound │
├──────────────────────────────────────────┤
│ brands/<slug>/ projects knowledge │
├──────────────────────────────────────────┤
│ connectors/ supabase, anthropic, ... │
└──────────────────────────────────────────┘
Top-down:
- Surfaces —
cms/and the CLI. What the user touches. - Logic + I/O —
skills/process data into insight;apps/are inbound (fetch) and outbound (publish). - The core —
brands/<slug>/(brand profile + knowledge files on disk) and projects (per-brand markets in the DB). Your identity. Read by every skill and app. - Infrastructure —
connectors/are the wires to Supabase, Anthropic, Google, GitHub.
Each layer depends only on layers below it. The CMS imports from skills, apps, and the core. Skills import from apps. Apps import from connectors. The core depends on nothing inside Pectus.
Path aliases
The CMS resolves connectors and shared lib code via TypeScript path aliases set in cms/tsconfig.json. The full set today:
{
"paths": {
"@pectus/supabase": ["../connectors/supabase/client.ts"],
"@pectus/supabase/*": ["../connectors/supabase/*"],
"@pectus/anthropic": ["../connectors/anthropic/client.ts"],
"@pectus/google/oauth": ["../connectors/google/oauth.ts"],
"@pectus/google/service-account": ["../connectors/google/service-account.ts"],
"@pectus/google/gsc": ["../connectors/google/gsc.ts"],
"@pectus/google/ga4": ["../connectors/google/ga4.ts"],
"@pectus/github": ["../connectors/github/client.ts"],
"@pectus/cli/import-design": ["../cli/src/lib/import-design.ts"],
"@pectus/content-insights/templates": ["../apps/content-insights/templates/index.ts"],
"@pectus/content-insights/blocks": ["../apps/content-insights/src/components/blocks/types.ts"],
"@pectus/skills/*/schema": ["../skills/*/schema.ts"],
"@pectus/apps/*/insights/schema": ["../apps/*/insights/schema.ts"]
}
}
The aliases are the contract between the CMS and the rest of the workspace. Apps and skills import from connectors using the same @pectus/<service> pattern, so renaming a connector folder doesn’t break callers.
Cross-package shared code (the brand importer at cli/src/lib/import-design.ts is the current example) is also aliased here. If you add a new shared lib, alias it in this file and add the package to transpilePackages in cms/next.config.ts so Turbopack will compile the source on the fly.
How a skill runs
The CMS doesn’t know what individual skills do; it knows how to run any of them. The runner reads a skill’s SKILL.md (prompt body and frontmatter), assembles the inputs the skill declares from Supabase, and calls Claude with the prompt as the system message, the inputs as a structured user message, and the skill’s Zod schema for structured output. Cache markers sit on the stable parts of the input — brand, ICP, knowledge — so repeat runs against the same project don’t re-pay for them.
The result is written twice: once as an audit row in skill_runs (input digest, output, status, duration), once into whichever read-path table the skill declares for its surface. The Content Insights’s Insights run writes to data_snapshots, data_interpretations, and idea_generations; the weekly analysis skill writes to weekly_analyses; a customer-insight skill might write to its own interview_themes table that another skill or app reads.
The contract is small enough to be uniform. Every skill exports an inputs declaration, an output schema, and a SKILL.md prompt body. Adding a new skill is a folder under skills/. The runner picks it up by name; nothing else in the CMS changes.
Inbound apps
Inbound apps are wrappers around third-party APIs. They don’t go through the model. Each app exposes a fetch entrypoint that pulls data for one project and writes rows to a table the app declares as its output. The bundled GA4 app writes to analytics_metrics; a CRM-export app would write to its own table; a support-transcripts app to another.
Skills then read from those tables. No skill talks to GA4 directly — it talks to the table that the GA4 app populated. That separation means swapping the source (GA4 → Plausible, HubSpot → Salesforce) is an app change, not a skill change. It also means a single project can have several apps populating different tables, and skills compose them by declaring multiple inputs.
A unified pectus app fetch <name> CLI entrypoint and a scheduled-job runner aren’t shipped yet. Today, fetches and per-app config are triggered from /brands/<slug>/projects/<code>/apps in the CMS — every app has a settings panel there once activated. Inactive apps stay listed but don’t add nav surfaces inside the project.
Outbound apps
Outbound apps publish or send something. They split into two patterns.
Direct publish — the CMS produces an artifact, the app commits or POSTs it as-is. The bundled content-insights works this way: the CMS builds page JSON, a per-locale site plan, and a redirects file, and commits all three to the project’s GitHub repo as one atomic commit. The hub’s static build picks them up on its next deploy. There’s no model rewrite in the path — what the CMS produces is what ships. Future direct-publish apps could ship to Webflow, Storyblok, an internal CRM, or a custom ingest endpoint.
Model-in-the-loop publish — the app composes the five-layer prompt envelope (brand → project → knowledge → skill output → app conventions) documented in apps-spec, asks Claude to rewrite the artifact for the destination, then ships the rewritten version. A WordPress publisher that needs to honor platform tone and structure conventions would work this way. No model-in-the-loop outbound app ships today; the seam is in place for one to land.
Why two-layer brand storage
brands/<slug>/brand.json (file) plus the brands table row (Supabase) sounds redundant. It’s not.
- The file makes brand portable across Supabase resets and lets installed apps (the
content-insightsand any community publisher) read brand at build time without DB access. - The DB row makes runtime queries fast and lets the CMS edit brand from the browser without touching the filesystem.
The CMS keeps them in sync: file is the source of truth, DB is the cache. The brand switcher in the top nav swaps the active brand context; every URL is slug-prefixed so the active brand is always inferable from the path.
Skills are yours
Skills live in skills/ inside your install. Edit them, fork them, write your own. Pectus runs entirely on your own machine, so every install is meant to diverge — that’s the design, not a bug. Two installs running the “same” skill folder will produce different output as soon as their brand, ICP, knowledge, or workspace data differ.
Sharing happens peer-to-peer. Bundle a skill folder, hand it to someone, they drop it into their own skills/. Improve a skill someone gave you and pass it back. The make-it skill scaffolds the right shape for a new skill or app.
Apps work the same way: each is its own folder under apps/, edit them in place, contribute back via PR. A separate community-app install path (clone-from-GitHub-URL) is still on the roadmap.
Updating Pectus pulls new versions of the CMS code, runners, connectors, and schema migrations. Migrations apply from the in-CMS overlay at /settings/updates. The skills and apps you’ve edited are yours; updates land in upstream-managed paths and don’t fight you for them. See upgrading for the details.
Why apps are flat folders, not packaged
A WordPress publisher has nothing in common with a Meta data fetcher. So apps are isolated folders, each with the same shape (APP.md, schema, optional provision, README). Adding a new one is a single-folder PR or a single-folder repo.
The make-it skill produces this shape for both modes (skill or app). The CLI consumes the ScaffoldSpec and writes the files.
The bundled content-insights app
apps/content-insights/ is the outbound app shipped with the initial download — an Astro publisher pre-wired so a fresh install has something visual within minutes. Conceptually it’s no different from a WordPress, Webflow, or Storyblok publisher; it just happens to be the one in the box.
The hub reads its content from a GitHub repo (the repo configured at Project → Content Insights → Site URL). The publish flow commits page JSON, the locale-specific site plan, and content/redirects.json to that repo as one atomic commit. The hub’s build picks them up on its next deploy.
The hub also reads brands/<slug>/brand.json at build time and emits CSS variables on :root from it. When you import a new design, the hub’s accent, fonts, and corner-radius update on the next build automatically — no theme files to swap. Other outbound apps that target the web can follow the same pattern.
Database schema
The download ships with a content-publishing schema, because that’s what the bundled apps need. Migrations live in connectors/supabase/migrations/. v0.4.2 squashed every previous migration (the old 0001–0008) into a single baseline 0001_pectus_v04.sql, and follow-ups stack on top of it. The current set:
0001_pectus_v04.sql— squashed baseline. All tables below ship in this file.0002_content_insights.sql—article_transitions,data_snapshots,data_interpretations,idea_generations,idea_post_dismissals,llm_usage, plus brand example photo categories, image-gen API key slots (google_genai,fal,replicate), and the_pectus_migrationsledger.0003_project_files.sql— theproject-filesstorage bucket.0004_brand_font_sizes.sql—brands.font_sizesjsonb column.
Tables today:
- Users + access:
profiles,review_policy,review_queue_items,review_approvals - Brands:
brands(multi-row, slug-keyed; replaced the old singletonbrand_profile) - Projects:
projects(per-brand),project_data_freshness - Audience:
icp_profiles,answer_public_entries - Content:
articles,article_transitions,keywords,seed_keywords,pages,page_variants(includeslast_published_pathfor redirect detection on slug changes),page_drafts - Sources:
content_sources,content_source_pages - Integrations:
integrations(brand-scoped; holds Google service-account JSON, Anthropic key, image-gen API keys) - Site planning:
topics,site_plan_nodes,redirects - Analysis + Insights:
weekly_analyses,skill_runs,insights,data_snapshots,data_interpretations,idea_generations,idea_post_dismissals,llm_usage - App activation:
activated_appsis keyed on(project_id, app_name);app_config(project_id, app_name, config)stores per-project settings the activation flow collects (Site URL, GitHub repo, mount slug, locales for Content Insights today; per-app config for whatever lands next). - Schema versioning:
_pectus_migrationsledger.
Inbound apps land their fetched data in app-declared tables (e.g. analytics_metrics for GA4). As skills and apps for non-content domains land, the schema grows — a customer-research app would add tables for transcripts and themes, a sales-enablement app for accounts and outreach.