Nexlem Configuration Reference
Nexlem reads configuration from three JSONC files arranged in a cascade plus a small set of environment variables. This document is the authoritative schema reference for v2.2 — every field, every default, every constraint.
What Nexlem explicitly does NOT use: Anthropic API keys (text generation is Claude Code, not the API). No image-generation API keys, no social scheduler API keys, no AI gateway URLs. These integrations were removed in v2.0, not deferred — their config fields and environment variables do not exist in this document.
File Layout
<project-root>/
├── config/
│ ├── project.jsonc # Layer 1: project-level defaults (JSONC — supports comments)
│ ├── business.jsonc # Layer 2: brand, voice, audience, quality thresholds
│ ├── main.jsonc # Layer 3: primary site config
│ └── sites/
│ └── <slug>.jsonc # Layer 3 (satellite): one file per additional site
├── .nexlem/
│ └── nexlem.db # SQLite state (campaigns, steps, atom_library, ...)
└── campaigns/
└── <id>/ # Per-campaign output directory
All three layers are required. The config directory is resolved by src/lib/config.ts's loadConfig() function using the NEXLEM_CONFIG_DIR environment variable (see Environment Variables).
Cascade Resolution
Config is resolved in a 3-level cascade — later layers override earlier ones via deep merge:
project.jsonc— project-level defaults (name, language, version)business.jsonc— brand identity, voice, audience, quality thresholdsmain.jsonc(orsites/<slug>.jsonc) — site-specific overrides and social config
Deep merge semantics: nested objects are merged key-by-key. Arrays are replaced wholesale. For example, social.brand_compliance from business.jsonc is preserved in the merged result even when main.jsonc sets social.enabled_channels.
The merged config is validated by MergedConfigSchema (Zod 4). If any required field is missing or invalid, startup throws immediately with a detailed error listing every failing field. Unknown fields at any level cause a fail-loud error — typos are not silently absorbed.
Source of truth: src/lib/config.ts — loadConfig() and MergedConfigSchema.
project.jsonc
Project-level defaults. Created by the project wizard section during /nexlem init.
{
// Internal project identifier — used in logs and nexlem-internal references
"name": "achecar-content-factory",
// Default language for outputs. Affects agent prompt rendering + quality gate verdicts.
"language": "pt-BR",
// Project version (advisory, not enforced downstream)
"version": "2.2.0"
}Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string (min 1) | yes | — | Internal project identifier |
language | string | no | "en-US" | Language code for agent prompts and quality gate outputs (e.g. "pt-BR", "en-US") |
version | string | no | "2.2.0" | Project version (advisory) |
business.jsonc
Brand identity, voice, audience, and business-layer quality thresholds. Created by the business and gates wizard sections during /nexlem init. The quality_gates_business block (produced by the gates section) lives here.
{
"business_name": "Achecar",
"business_description": "Marketplace de carros novos e seminovos",
"business_url": "https://achecar.com.br",
"voice": "informative-friendly",
"mission": "Simplificar a jornada de compra de carros no Brasil",
"differentiators": ["Transparência de preços", "Avaliações independentes"],
"tone_keywords": ["confiável", "acessível", "especialista"],
"quality_gates_business": {
"fact_check_min": 85,
"eeat_min": 80,
"humanizer_min": 85,
"seo_min": 75
}
}Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
business_name | string (min 1) | yes | — | Brand display name (used in disclaimers and author bios) |
business_description | string | no | — | Short description of the business — fed into research and writer agent prompts |
business_url | url or "" | no | — | Business website URL; empty string is preserved as-is in prompt templates |
voice | string | no | — | Brand voice descriptor (e.g. "informative-friendly", "authoritative-formal") — fed into writer + humanizer prompts |
mission | string | no | — | Mission statement — included in brand context for writer and SEO agents |
differentiators | string[] | no | [] | Brand differentiators — fed into writer prompts to highlight uniqueness |
tone_keywords | string[] | no | [] | Tone keywords — fed into writer and humanizer prompts |
quality_gates_business | object | no | — | Business-layer quality thresholds. If absent, the executor uses hard-coded defaults. |
quality_gates_business sub-fields:
| Field | Type | Range | Recommended | Description |
|---|---|---|---|---|
fact_check_min | number | 0–100 | 85 | Minimum score for the fact-check agent verdict to pass |
eeat_min | number | 0–100 | 80 | Minimum E-E-A-T score |
humanizer_min | number | 0–100 | 85 | Minimum humanizer score |
seo_min | number | 0–100 | 75 | Minimum SEO score |
These four thresholds gate LLM-scored agent outputs — they are distinct from the site-layer quality_gates block (which governs word count and structural checks on the final article).
main.jsonc / sites/.jsonc
Site-specific configuration. main.jsonc is the primary site. Additional satellite sites each get a sites/<slug>.jsonc file (created via /nexlem site add). Created by the first-site wizard section during /nexlem init.
{
"site_url": "https://achecar.com.br",
"site_name": "Achecar",
"site_slug": "achecar",
"role": "primary",
"relationship": "owner",
"cannibalization_policy": "warn",
"language": "pt-BR",
"quality_gates": {
"min_words": 1500,
"max_words": 5000,
"min_score": 7,
"required_sections": [],
"banned_phrases": [],
"scoring_criteria": [
{ "name": "clarity", "weight": 0.25 },
{ "name": "eeat", "weight": 0.25 },
{ "name": "originality", "weight": 0.20 },
{ "name": "relevance", "weight": 0.15 },
{ "name": "tone_voice", "weight": 0.15 }
]
},
"publish": {
"branch": "main",
"velocity": {
"target": 5,
"ramp_up": [{ "week": 1, "max_per_week": 1 }]
}
},
"social": {
"enabled_channels": ["carousel", "caption", "reel", "youtube_short"],
"fanout_concurrency": 2,
"fanout_policy": "best_effort",
"brand_compliance": {
"voice": "professional but approachable",
"banned_terms": [],
"hashtags": {
"always": ["#carros"],
"max_per_platform": { "instagram": 30, "tiktok": 5, "youtube": 15 }
}
}
}
}Core Site Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
site_url | url | yes | — | Full site URL including protocol |
site_name | string (min 1) | yes | — | Site display name |
site_slug | string (min 1) | yes | — | URL-safe site identifier (must match filename for sites/<slug>.jsonc) |
role | "primary" | "satellite" | no | "primary" | Site role in the network |
relationship | "owner" | "editorial" | "affiliate" | "none" | no | "owner" | Business relationship — controls first vs. third-person voice in generated content |
cannibalization_policy | "warn" | "block" | no | "warn" | Keyword conflict policy applied by publish-blog |
language | string | no | "en-US" | Site-specific language override |
Relationship behavior:
| Value | Voice style |
|---|---|
owner | First-person: "we build", "our" |
editorial | Third-person neutral: "Brand X offers" |
affiliate | Third-person with UTM-tagged outbound link |
none | Generic third-person, no business linkage |
quality_gates Block
Controls article quality gates applied by the quality-gate command.
| Field | Type | Default | Description |
|---|---|---|---|
min_words | number | 1500 | Minimum word count for the final article |
max_words | number | 5000 | Maximum word count |
min_score | number (0–10) | 7 | Minimum weighted quality score to pass |
required_sections | string[] | [] | Section headings that must be present in the article |
banned_phrases | string[] | [] | Phrases that trigger a REPROVADO verdict |
scoring_criteria | object[] | 5 default criteria | Weighted criteria for the quality gate LLM scoring |
Default scoring criteria (v2.0 en-US names):
| Criterion | Weight |
|---|---|
clarity | 0.25 |
eeat | 0.25 |
originality | 0.20 |
relevance | 0.15 |
tone_voice | 0.15 |
Note: v1.9 used pt-BR criterion names (
clareza,originalidade,relevancia,tom_voz). The config loader (Phase 34 migration) rewrites these to their v2.0 equivalents at parse time, so existingsites/<slug>.jsoncfiles load without error.
Each scoring_criteria entry:
| Field | Type | Description |
|---|---|---|
name | string | Criterion identifier (see defaults above) |
weight | number (0–1) | Weight in the weighted average — all weights should sum to 1.0 |
publish Block
Controls blog publishing behavior for this site.
| Field | Type | Default | Description |
|---|---|---|---|
branch | string | "main" | Git branch for blog markdown commits |
velocity.target | number | 5 | Target posts per week at steady state |
velocity.ramp_up | object[] | [] | Week-by-week ramp schedule |
Each velocity.ramp_up entry:
| Field | Type | Description |
|---|---|---|
week | number | Ramp week number (1-indexed) |
max_per_week | number | Maximum posts allowed in this ramp week |
social Block
Controls social media asset generation.
| Field | Type | Default | Description |
|---|---|---|---|
enabled_channels | string[] | all four | Which channels to generate: carousel, caption, reel, youtube_short |
fanout_concurrency | integer (>0) | 2 | Concurrent social agent branches (Phase 39 D-39-04) |
fanout_policy | "best_effort" | "all_or_nothing" | "best_effort" | Whether partial social failures abort the run |
atom_targets | object | see below | Per-channel generation targets |
brand_compliance | object | see below | Brand rules applied to all social outputs |
atom_targets defaults:
| Field | Default |
|---|---|
carousel.min_slides | 5 |
carousel.max_slides | 10 |
caption.variants | 3 |
reel.hooks | 1 |
youtube_short.hooks | 1 |
brand_compliance fields:
| Field | Type | Default | Description |
|---|---|---|---|
voice | string | "professional but approachable" | Brand voice description |
tone_keywords | string[] | [] | Keywords that define the tone |
banned_terms | string[] | [] | Terms to never use in social output |
required_elements | string[] | [] | Elements that must appear in social output |
emoji_policy | "allowed" | "restricted" | "forbidden" | "allowed" | Emoji usage policy |
allowed_emojis | string[] | [] | Allowed emojis (when emoji_policy="restricted") |
per_channel_voice | object | {} | Per-channel voice overrides (keys: channel names) |
cta.carousel | object | — | CTA rules for carousel |
cta.caption | object | — | CTA rules for captions |
cta.reel | object | — | CTA rules for reels |
cta.youtube_short | object | — | CTA rules for YouTube Shorts |
hashtags | object | — | Hashtag rules |
Each cta.* entry:
| Field | Type | Default |
|---|---|---|
required | boolean | true |
position | "end" | "beginning" | "end" |
templates | string[] | [] |
hashtags fields:
| Field | Type | Default |
|---|---|---|
always | string[] | [] |
banned | string[] | [] |
max_per_platform.instagram | number | 30 |
max_per_platform.tiktok | number | 5 |
max_per_platform.youtube | number | 15 |
visual_brand Block
Optional. Controls photography and visual style guidelines fed to the image-planner agent.
| Field | Type | Default | Description |
|---|---|---|---|
style | string | — | Overall visual style description |
color_palette | object | — | Named color hex values (e.g. { "primary": "#1A1A2E" }) |
photography | object | — | Photography style guidelines (see below) |
design | object | — | General design tokens |
avoid | string[] | [] | Visual elements to avoid |
photography sub-fields:
| Field | Type | Default |
|---|---|---|
angles | string[] | [] |
lighting | string[] | [] |
lens | string[] | [] |
aperture | string[] | [] |
backgrounds | string[] | [] |
compositions | string[] | [] |
Agent Prompt Frontmatter
Every file in agents/*.md opens with a YAML frontmatter block. This block is parsed and validated by src/lib/agent-frontmatter.ts's FrontmatterSchema — a Zod 4 z.strictObject that rejects unknown fields fail-loud at parse time.
---
version: 1.0.0
name: research
order: 1
description: Web research for the article topic
accepts: []
produces:
- 01-research.json
acceptance_criteria:
min_sources: 3
required_fields:
- topic
- sources
- competitor_analysis
- key_facts
- suggested_outline
- keywords
tools: [WebSearch, WebFetch, Write]
model: claude-sonnet-4-6
max_turns: 8
timeout: 600
retry_policy: exponential
---
## Agent body — the prompt itself follows after the closing fenceFields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
version | string | yes | — | Semver MAJOR.MINOR.PATCH (no pre-release / build metadata). Bumped per D-41-03 on any change to this file. See Version Bump Policy below. |
name | string (min 1) | yes | — | Agent identifier. Must match the filename minus .md and match the registry entry in src/lib/agents/registry.ts. |
order | integer ≥0 | yes | — | Sort order within the agent's pipeline channel (blog stages 0–9, social stages 10–13). Duplicate order values within a channel fail loud at registry init. |
description | string | no | — | Human-readable summary; surfaced in bun nexlem agent-list --verbose. |
accepts | string[] | yes | — | Input file basenames consumed by this agent. Empty array for stage-0 agents (e.g. research). |
produces | string[] (length exactly 1) | yes | — | Output file basename. Nexlem enforces exactly one product per agent. |
acceptance_criteria | object | yes | — | Per-agent output contract that the validator enforces. Shape varies by agent. |
tools | string[] | yes | — | Claude Code tools the agent may use (e.g. [WebSearch, WebFetch, Write], [Read, Write]). |
model | string | no | claude-sonnet-4-6 | Claude Code model name for this agent. |
max_turns | integer >0 | no | — | Maximum agent conversation turns. Omit to use the runner's default. |
timeout | integer >0 | no | 300 | Per-agent timeout in seconds. |
retry_policy | string | no | — | Reserved for per-agent retry config. Nexlem uses the runner's default exponential backoff. |
Version Bump Policy (D-41-03)
Any change to an agents/*.md file requires a version bump in the same PR — frontmatter edit, body edit, even a typo fix. No "I'll bump it later" — the bump goes in the same PR as the change.
Reviewer chooses the bump grain per standard semver rules:
- MAJOR (
X+1.0.0) — Breaking output-shape change. Downstream consumers (other agents, hooks, validators) must adapt. - MINOR (
X.Y+1.0) — Added sections, fields, or options without breaking existing consumers. - PATCH (
X.Y.Z+1) — Wording tightening, typo fixes, performance tweaks. The default grain for most changes.
There is no "too small to bump" — silent prompt drift was the original v1.x anti-pattern this discipline exists to prevent.
Agent Version Observability (D-41-04)
The executor logs agent_version at every stage start to two destinations:
-
pino (forensic stream): a structured log line at stage start:
{ "event": "agent_start", "step": 1, "agent": "research", "version": "1.0.0", "campaign_id": "H5xF3kly6_SwIIH7KfRGv" } -
stepstable (DB): theagent_version TEXTcolumn on each step row, populated when the runner inserts or transitions the step to running.
Operators correlate output regressions to a specific prompt revision via either path:
- Live/recent: watch the pino stream (
bun nexlem run ... | pino-pretty) - Historical: query the
stepstable with/nexlem campaign show <id>or direct SQLite
Pre-Phase-41 step rows carry NULL in agent_version — no backfill is performed. Pre-Phase-41 campaigns predate the field semantically.
Environment Variables
# Override the config directory (used by Phase 40.1 UAT for live runs against
# an isolated config set without touching the production .nexlem/)
NEXLEM_CONFIG_DIR=/path/to/alt/config
# Override the SQLite database path (UAT isolation)
NEXLEM_DB=/path/to/alt/nexlem.db
# Override the campaigns base directory (UAT isolation)
NEXLEM_CAMPAIGNS_DIR=/path/to/alt/campaignsBun reads .env natively via Bun.env — no dotenv package. All three variables are validated by src/lib/env.ts at startup.
Nexlem uses zero external service API keys for content generation — all text generation is delegated to Claude Code. There are no API keys for image generation, social scheduling, or AI gateway proxying.
Complete Merged Config Shape
loadConfig() returns a MergedConfig object — the result of deep-merging all three layers and validating against MergedConfigSchema. The top-level fields available to agent prompt templates are:
| Field | Source layer | Type |
|---|---|---|
name | project | string |
language | project (site overrides) | string |
version | project | string |
business_name | business | string |
business_description | business | string? |
business_url | business | string? |
voice | business | string? |
mission | business | string? |
differentiators | business | string[] |
tone_keywords | business | string[] |
quality_gates_business | business | object? |
site_url | site | string |
site_name | site | string |
site_slug | site | string |
role | site | "primary" | "satellite" |
relationship | site | "owner" | "editorial" | "affiliate" | "none" |
cannibalization_policy | site | "warn" | "block" |
quality_gates | site | object? |
visual_brand | site | object? |
publish | site | object? |
social | site | object? |
Unknown top-level keys from any layer are rejected by Zod at validation time — the schema is strict.
See Also
docs/GETTING_STARTED.md— first-campaign walkthrough with real UAT outputsdocs/COMMANDS.md— full 31-command referencedocs/AGENTS.md— the 16-agent pipeline surface and frontmatter spec examples