Skip to content

dwe render ai

Generate hub-level agent documentation for each enabled service from a template pack. The pack declares a manifest.yml listing files to render and symlinks to create inside the service’s hub directory (e.g. services/main/AGENTS.md plus services/main/CLAUDE.md → AGENTS.md).

render ai and render ide share most of the per-service plumbing — selection, template resolution, path-safety guards, manifest schema. They differ in one important place: the collision policy is inverted (shallowest wins, not deepest).

Per-file local overrides via the sibling <pack>.local/ shadow tree apply to AI packs identically to IDE and git — drop a file at workspace/templates/ai/<pack>.local/<rel> to substitute it for the canonical one without touching the tracked pack.

flowchart TD
  CFG["Load merged config"] --> SEL{"Argument given?"}
  SEL -- no --> SP["Select services<br/>policy filter + collision"]
  SEL -- yes --> EXV["Validate the argument"]
  EXV --> RHA["Resolve hub anchor<br/>shallowest wins"]
  SP --> LIST["Sorted service list"]
  RHA --> LIST
  LIST --> EACH{"For each service"}
  EACH --> RP["Resolve template pack"]
  RP --> LM["Load manifest.yml<br/>strict decode"]
  LM --> VM["Validate manifest"]
  VM --> R["Render each manifest.render"]
  R --> SL["Create each manifest.symlinks"]
  SL --> EACH
  EACH -- "end" --> DONE["done"]

A service participates in agent-docs rendering only when both flags are true:

GateSourceDefault
Project-levelservices.<name>.enabled (3-layer merged + required service override)depends on service
Agent docs policyservices.<name>.render.ai.enabledtrue for type: app, false for other types

Agent docs default to enabled only for app services because they are the only service type with a dedicated source directory to host hub-level docs. Tools/infra opt in explicitly with render.ai.enabled: true.

If either gate is false, the service is skipped. Skips fall into two groups:

GroupWhenReported as warning?
Policy skipsThe service is disabled at the project level, or render.ai.enabled is false.no — these are the documented opt-out behavior
Actionable skipsThe service has no hub directory, or another service won a directory collision.yes — these usually indicate a misconfiguration

Same as render ide: services without a hub directory are dropped — either dir is empty or it resolves to the project root. Agent docs need a real hub directory to write to and must not target the project root.

When more than one surviving service points at the same dir, exactly one wins:

  1. Walk each service’s extends chain to compute a depth.
  2. The service with the shallowest chain wins. The chain depth is capped (currently at 32 hops) as a cycle guard.
  3. Ties at the same depth are broken lexicographically by service name, so the result is deterministic.

The losing services emit a warning naming the winner and the contested directory.

flowchart LR
  M["main<br/>extends: nothing<br/>depth 0"] --> D
  MD["main-debug<br/>extends: main<br/>depth 1"] --> D
  D["dir: services/main"] --> W{"shallowest wins"}
  W --> WIN["main renders"]
  W -. "skip warning" .-> MD

Rationale: the agent docs describe the hub’s identity. When a child extends a parent and shares its dir, the parent is the canonical hub owner; the child is a runtime variant of the same workspace, not a separate workspace with its own identity. Picking the parent keeps the AGENTS.md content stable across variant toggles.

This is the opposite of render ide’s deepest-wins policy. The reason is the difference in what is being rendered:

CommandWhat is renderedWhose viewpoint?
render ideper-variant editor configs (debugger, launch profile)the variant being worked on right now
render aihub identity and orientation for an AI agentthe canonical owner of the hub

dwe render ai <name> treats <name> as a hub anchor (same model as render ide).

Validation order (first failure wins):

  1. Service not in config.
  2. Service is disabled at the project level.
  3. Service has no hub directory, or its hub is the project root.
  4. render.ai.enabled is false.

After validation, the same shallowest-wins resolution is applied scoped to siblings sharing the hub. If the winner differs, an info line announces the substitution.

So dwe render ai main-debug still renders the parent main whenever both are enabled — the variant resolves to the canonical hub owner.

For each selected service, the renderer picks one pack directory under workspace/templates/ai/. The resolution chain mirrors IDE pack resolution exactly, only the base directory differs.

flowchart TD
  S{"render.ai.template set?"}
  S -- yes --> EX["workspace/templates/ai/{template}/"]
  EX -- exists --> USE["use this pack"]
  EX -- missing --> ERR["error<br/>explicit is strict"]
  S -- no --> SN["workspace/templates/ai/{service-name}/"]
  SN -- exists --> USE
  SN -- missing --> DEF["workspace/templates/ai/default/"]
  DEF -- exists --> USE
  DEF -- missing --> WARN["warning + skip<br/>implicit not found"]

Rules:

  • Explicit is strict. A set render.ai.template that doesn’t exist is a hard error — no silent fallback.
  • Implicit chain (when render.ai.template is unset): <service-name> → walk the extends: chain ancestor-by-ancestor → default. The first existing pack wins. If the implicit chain exhausts without finding a pack, rendering is skipped with a warning. Invalid pack names in the chain (e.g. a leading dot or path separator) are skipped silently and the walk continues.
  • A pack must be a real directory; symlinks at the pack root or in any parent component are rejected.
  • Template-key validation rejects path separators, leading dots, and ...

Each pack must contain a manifest.yml at its root. The manifest declares what the pack produces; unlike IDE rendering, there is no implicit walk. If a file in the pack is not referenced by render, it is ignored.

render:
- from: AGENTS.md.tmpl
to: AGENTS.md
- from: .claude/CLAUDE.md.tmpl
to: .claude/CLAUDE.md
symlinks:
- link: CLAUDE.md
to: AGENTS.md

The manifest is loaded with strict YAML decode: unknown fields are a hard error so typos like renders: are caught early. An empty file or a manifest with both lists empty is also rejected.

FieldRequiredDescription
fromyesPath to the template file relative to the pack root. Must end in .tmpl and must not be absolute. The file must exist as a regular file (not symlink, not directory).
toyesDestination path relative to the service hub directory. May be nested (e.g. .claude/CLAUDE.md). Must not escape the hub. Empty or .. is rejected.
FieldRequiredDescription
linkyesPath of the symlink to create, relative to the service hub directory. May be nested. Must not escape the hub.
toyesThe symlink target, relative to the service hub directory. Must match the to of one of the render entries — symlinks may only point at files this manifest produces.

Symlinks are written as relative paths computed from the link’s directory to the target’s absolute path inside the hub. They never reach outside the hub.

The manifest is validated before any file is written:

RuleApplies to
At least one render or symlinks entrymanifest
from non-empty, relative, ends in .tmpleach render
from does not escape the pack and contains no symlink in any parenteach render
from exists as a regular file (not a symlink, not a directory)each render
to non-empty, relative, does not resolve to . or ..each render
to unique within the manifesteach render
link non-empty, relative, does not escape the hubeach symlink
link unique among symlinkseach symlink
link does not collide with any render to (one path cannot be both a rendered file and a symlink)each symlink
to matches a known render destinationeach symlink

Path comparisons are performed on cleaned paths, so AGENTS.md and ./AGENTS.md are treated as the same destination.

For each render entry the source is the template file inside the pack and the destination is the entry’s to path joined with the service hub directory. The renderer:

  1. Reads the template file.
  2. Parses it as a Go text template with strict mode enabled — any reference to a missing field aborts rendering instead of writing a <no value> placeholder.
  3. Executes the template against the template variables.
  4. Resolves the destination and runs the path-safety guards.
  5. Creates any missing parent directories.
  6. Refuses to overwrite the destination if it is a pre-existing symlink.
  7. Writes the rendered bytes.

Templates receive the same object shape as IDE templates:

VariableSource
.Projectproject: block from workspace.yml
.Servicecanonical config identity — root of the rendering service’s extends: chain. Use this for raw-config lookups keyed by service name. Equals .Resolved when no extends chain.
.Resolvedrendering identity — service whose hub is actually being rendered (collision-policy winner). For AI the winner is the shallowest extender, so .Resolved typically equals .Service.
.ServiceCfgeffective service config of .Resolved, after extends resolution.
.Runtimemerged runtime block
.Cfgmerged DweConfig (advanced). .Cfg.Raw is the post-merge config map after DWE normalization (services.* injected from per-service service.yml files) — see Templates. Prefer the dedicated fields above for common cases.

Advisory. AI outputs land at <svc.Dir>/<entry.To> — typically tracked project files (AGENTS.md, .claude/CLAUDE.md, …). Avoid consuming developer-local or secret keys via .Cfg.Raw in AI templates: any value layered in from workspace/local.yml will surface in the rendered file and produce per-developer diffs in tracked artefacts. Use .Cfg.Raw for repo-wide conventions only.

Strict-mode rendering means a typo like {{.Servic.Name}} aborts rendering instead of producing <no value>. Use {{if ...}} for fields that may legitimately be empty.

Go’s text/template resolves dot-segments only when each segment matches [A-Za-z_][A-Za-z0-9_]*. Keys with hyphens, dots, leading digits, or any other non-identifier character cannot be reached with dot syntax — use index instead.

{{ .Cfg.Raw.ai.persona }} {{- /* dot — identifier-safe keys */ -}}
{{ index .Cfg.Raw "my-tool" "api-key" }} {{- /* index — hyphenated keys */ -}}

The full set of helper functions available inside *.tmpl files (appURL, sprout registries, text/template built-ins) is documented in Templates.

The renderer applies the same boundary checks as IDE rendering:

  1. Path cleanup. Manifest validation already rejected absolute paths, .. escapes, and . results.
  2. Hub containment. The resolved destination must be inside the absolute hub directory.
  3. No symlinks in the destination path. Existing path components are checked before any directory is created.
  4. Real-path boundaries after creation. After parent directories are created, the destination directory is resolved through any symlinks and must be inside both the real project root and the real hub. This catches a race where a symlink is dropped between checks.
  5. No symlink at the destination file. A pre-existing symlink at the target path is refused (rather than following the link and overwriting whatever it points at).

The pack itself is also guarded: pack-root parent path checks reject any symlinked component, and the manifest validator forbids symlinks in any from path.

For each symlinks entry, the renderer creates a symlink at link pointing to to. Both paths are interpreted as relative to the service hub directory.

Steps:

  1. Validate that both link and to stay inside the hub.
  2. Create the parent directory of the link with the same path-safety guards used for rendered files.
  3. Compute the relative path from the link’s directory to the target inside the hub. The symlink stored on disk is always relative, so the hub stays portable across machines.
  4. Inspect any existing path at the link location:
    • Symlink already pointing at the correct relative target — no-op (idempotent).
    • Symlink pointing somewhere else — remove and recreate.
    • Regular file or directory — refuse with an error suggesting either deleting the file or setting render.ai.enabled: false for the service.
    • Path absent — create the symlink.

The result is content-idempotent: re-running dwe render ai produces a hub directory in the same final state. Note that rendered files are always rewritten on each run (so file modification times advance), but the bytes are determined entirely by the templates and the merged config. Symlinks, by contrast, are only re-created when the existing one is missing or points at the wrong target.

Layout:

workspace/services/
main/
service.yml
main-debug/
service.yml
workspace/templates/ai/
default/
manifest.yml
AGENTS.md.tmpl
.claude/CLAUDE.md.tmpl

Manifest workspace/templates/ai/default/manifest.yml:

render:
- from: AGENTS.md.tmpl
to: AGENTS.md
- from: .claude/CLAUDE.md.tmpl
to: .claude/CLAUDE.md
symlinks:
- link: CLAUDE.md
to: AGENTS.md

Template workspace/templates/ai/default/AGENTS.md.tmpl:

# {{.Service}} Service Hub
This is the {{.Service}} service running inside a DWE-managed hub.
The application source code is at `src/`.
Service container: {{.ServiceCfg.Container}}
Workspace root: {{.ServiceCfg.DirInternal}}

workspace/services/main/service.yml:

type: app
container: app-main
dir: ./services/main

workspace/services/main-debug/service.yml:

type: app
extends: main
container: app-main-debug
dir: ./services/main # same hub as parent — collision

dwe render ai:

  1. Selection: both services pass the activation gate (default render.ai.enabled: true). They share dir: ./services/main. main has the shallower extends chain (depth 0 vs main-debug’s 1), so main wins. main-debug is reported as a collision skip.
  2. Pack resolution for main: render.ai.template is unset; the implicit chain tries workspace/templates/ai/main/ (not found), then workspace/templates/ai/default/ (used).
  3. Manifest is loaded and validated: two render entries, one symlink. The symlink targets AGENTS.md, which is one of the render destinations.
  4. Each render entry is processed: AGENTS.md and .claude/CLAUDE.md are written into services/main/.
  5. The symlink services/main/CLAUDE.md → AGENTS.md is created.

Result:

services/main/
AGENTS.md ← rendered from AGENTS.md.tmpl
CLAUDE.md ← symlink to AGENTS.md
.claude/
CLAUDE.md ← rendered from .claude/CLAUDE.md.tmpl

dwe render ai main-debug produces the same files — the explicit argument is validated, but the hub-anchor resolution picks main (shallowest) and prints ai [main-debug] — resolved to main (hub services/main).

StreamTrigger
infoExplicit argument resolved to a different sibling — names the chosen winner and the shared hub directory.
warningA selected service was skipped because it has no hub directory (or its hub is the project root).
warningA selected service was skipped because another service won the directory collision — the winner is named.
successOne line per rendered file, naming the relative path inside the project.
successOne line per symlink (whether newly created or already correct), showing both the link and its target.
infoNothing was selected after applying policy and collision rules.

Errors are returned as command failures and name the offending service so the source of the problem is clear.

  • Pre-existing non-symlink at a managed symlink path. If CLAUDE.md already exists as a regular file (perhaps from a previous manual edit), render ai refuses to overwrite it. Delete the file or set render.ai.enabled: false for the service.
  • Symlink to must reference a render destination. The manifest validator enforces this; you cannot symlink to an arbitrary file outside the manifest.
  • Manifest typos are hard errors. Strict YAML decode means a misspelled key like renders: or symlink: aborts loading. Fix the spelling.
  • Empty manifest is rejected. A manifest with both render: [] and symlinks: [] is almost always a mistake.
  • Variants share the parent’s identity. This is intentional. If a runtime variant truly needs its own AGENTS.md, give it a different dir.
  • Templates referencing missing fields fail. Strict-mode rendering aborts on any reference to a missing field. Guard optional fields with {{if ...}}.
  • render ai does not walk the pack. Files inside the pack that are not referenced by manifest.yml are silently ignored at render time. Add an entry to render: to include them.
  • services.<name>.render.ai blockenabled, template, inheritance via extends
  • render ide — companion command with the opposite (deepest-wins) collision policy
  • Run dwe render ai --help for the live CLI surface