Skip to main content
nexlem

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:

  1. project.jsonc — project-level defaults (name, language, version)
  2. business.jsonc — brand identity, voice, audience, quality thresholds
  3. main.jsonc (or sites/<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.tsloadConfig() 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:

FieldTypeRequiredDefaultDescription
namestring (min 1)yesInternal project identifier
languagestringno"en-US"Language code for agent prompts and quality gate outputs (e.g. "pt-BR", "en-US")
versionstringno"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:

FieldTypeRequiredDefaultDescription
business_namestring (min 1)yesBrand display name (used in disclaimers and author bios)
business_descriptionstringnoShort description of the business — fed into research and writer agent prompts
business_urlurl or ""noBusiness website URL; empty string is preserved as-is in prompt templates
voicestringnoBrand voice descriptor (e.g. "informative-friendly", "authoritative-formal") — fed into writer + humanizer prompts
missionstringnoMission statement — included in brand context for writer and SEO agents
differentiatorsstring[]no[]Brand differentiators — fed into writer prompts to highlight uniqueness
tone_keywordsstring[]no[]Tone keywords — fed into writer and humanizer prompts
quality_gates_businessobjectnoBusiness-layer quality thresholds. If absent, the executor uses hard-coded defaults.

quality_gates_business sub-fields:

FieldTypeRangeRecommendedDescription
fact_check_minnumber0–10085Minimum score for the fact-check agent verdict to pass
eeat_minnumber0–10080Minimum E-E-A-T score
humanizer_minnumber0–10085Minimum humanizer score
seo_minnumber0–10075Minimum 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

FieldTypeRequiredDefaultDescription
site_urlurlyesFull site URL including protocol
site_namestring (min 1)yesSite display name
site_slugstring (min 1)yesURL-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
languagestringno"en-US"Site-specific language override

Relationship behavior:

ValueVoice style
ownerFirst-person: "we build", "our"
editorialThird-person neutral: "Brand X offers"
affiliateThird-person with UTM-tagged outbound link
noneGeneric third-person, no business linkage

quality_gates Block

Controls article quality gates applied by the quality-gate command.

FieldTypeDefaultDescription
min_wordsnumber1500Minimum word count for the final article
max_wordsnumber5000Maximum word count
min_scorenumber (0–10)7Minimum weighted quality score to pass
required_sectionsstring[][]Section headings that must be present in the article
banned_phrasesstring[][]Phrases that trigger a REPROVADO verdict
scoring_criteriaobject[]5 default criteriaWeighted criteria for the quality gate LLM scoring

Default scoring criteria (v2.0 en-US names):

CriterionWeight
clarity0.25
eeat0.25
originality0.20
relevance0.15
tone_voice0.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 existing sites/<slug>.jsonc files load without error.

Each scoring_criteria entry:

FieldTypeDescription
namestringCriterion identifier (see defaults above)
weightnumber (0–1)Weight in the weighted average — all weights should sum to 1.0

publish Block

Controls blog publishing behavior for this site.

FieldTypeDefaultDescription
branchstring"main"Git branch for blog markdown commits
velocity.targetnumber5Target posts per week at steady state
velocity.ramp_upobject[][]Week-by-week ramp schedule

Each velocity.ramp_up entry:

FieldTypeDescription
weeknumberRamp week number (1-indexed)
max_per_weeknumberMaximum posts allowed in this ramp week

social Block

Controls social media asset generation.

FieldTypeDefaultDescription
enabled_channelsstring[]all fourWhich channels to generate: carousel, caption, reel, youtube_short
fanout_concurrencyinteger (>0)2Concurrent 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_targetsobjectsee belowPer-channel generation targets
brand_complianceobjectsee belowBrand rules applied to all social outputs

atom_targets defaults:

FieldDefault
carousel.min_slides5
carousel.max_slides10
caption.variants3
reel.hooks1
youtube_short.hooks1

brand_compliance fields:

FieldTypeDefaultDescription
voicestring"professional but approachable"Brand voice description
tone_keywordsstring[][]Keywords that define the tone
banned_termsstring[][]Terms to never use in social output
required_elementsstring[][]Elements that must appear in social output
emoji_policy"allowed" | "restricted" | "forbidden""allowed"Emoji usage policy
allowed_emojisstring[][]Allowed emojis (when emoji_policy="restricted")
per_channel_voiceobject{}Per-channel voice overrides (keys: channel names)
cta.carouselobjectCTA rules for carousel
cta.captionobjectCTA rules for captions
cta.reelobjectCTA rules for reels
cta.youtube_shortobjectCTA rules for YouTube Shorts
hashtagsobjectHashtag rules

Each cta.* entry:

FieldTypeDefault
requiredbooleantrue
position"end" | "beginning""end"
templatesstring[][]

hashtags fields:

FieldTypeDefault
alwaysstring[][]
bannedstring[][]
max_per_platform.instagramnumber30
max_per_platform.tiktoknumber5
max_per_platform.youtubenumber15

visual_brand Block

Optional. Controls photography and visual style guidelines fed to the image-planner agent.

FieldTypeDefaultDescription
stylestringOverall visual style description
color_paletteobjectNamed color hex values (e.g. { "primary": "#1A1A2E" })
photographyobjectPhotography style guidelines (see below)
designobjectGeneral design tokens
avoidstring[][]Visual elements to avoid

photography sub-fields:

FieldTypeDefault
anglesstring[][]
lightingstring[][]
lensstring[][]
aperturestring[][]
backgroundsstring[][]
compositionsstring[][]

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 fence

Fields:

FieldTypeRequiredDefaultDescription
versionstringyesSemver MAJOR.MINOR.PATCH (no pre-release / build metadata). Bumped per D-41-03 on any change to this file. See Version Bump Policy below.
namestring (min 1)yesAgent identifier. Must match the filename minus .md and match the registry entry in src/lib/agents/registry.ts.
orderinteger ≥0yesSort 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.
descriptionstringnoHuman-readable summary; surfaced in bun nexlem agent-list --verbose.
acceptsstring[]yesInput file basenames consumed by this agent. Empty array for stage-0 agents (e.g. research).
producesstring[] (length exactly 1)yesOutput file basename. Nexlem enforces exactly one product per agent.
acceptance_criteriaobjectyesPer-agent output contract that the validator enforces. Shape varies by agent.
toolsstring[]yesClaude Code tools the agent may use (e.g. [WebSearch, WebFetch, Write], [Read, Write]).
modelstringnoclaude-sonnet-4-6Claude Code model name for this agent.
max_turnsinteger >0noMaximum agent conversation turns. Omit to use the runner's default.
timeoutinteger >0no300Per-agent timeout in seconds.
retry_policystringnoReserved 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:

  1. 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" }
  2. steps table (DB): the agent_version TEXT column 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 steps table 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/campaigns

Bun 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:

FieldSource layerType
nameprojectstring
languageproject (site overrides)string
versionprojectstring
business_namebusinessstring
business_descriptionbusinessstring?
business_urlbusinessstring?
voicebusinessstring?
missionbusinessstring?
differentiatorsbusinessstring[]
tone_keywordsbusinessstring[]
quality_gates_businessbusinessobject?
site_urlsitestring
site_namesitestring
site_slugsitestring
rolesite"primary" | "satellite"
relationshipsite"owner" | "editorial" | "affiliate" | "none"
cannibalization_policysite"warn" | "block"
quality_gatessiteobject?
visual_brandsiteobject?
publishsiteobject?
socialsiteobject?

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 outputs
  • docs/COMMANDS.md — full 31-command reference
  • docs/AGENTS.md — the 16-agent pipeline surface and frontmatter spec examples

Edit this page on GitHub