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.
Contents
Section titled “Contents”- Pipeline
- Service selection
- Template pack resolution
- Manifest schema
- Per-file rendering
- Symlink creation
- Worked example
- Output messages
- Common pitfalls
- Related references
Pipeline
Section titled “Pipeline”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"]
Service selection
Section titled “Service selection”Activation gate
Section titled “Activation gate”A service participates in agent-docs rendering only when both flags are true:
| Gate | Source | Default |
|---|---|---|
| Project-level | services.<name>.enabled (3-layer merged + required service override) | depends on service |
| Agent docs policy | services.<name>.render.ai.enabled | true 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:
| Group | When | Reported as warning? |
|---|---|---|
| Policy skips | The service is disabled at the project level, or render.ai.enabled is false. | no — these are the documented opt-out behavior |
| Actionable skips | The service has no hub directory, or another service won a directory collision. | yes — these usually indicate a misconfiguration |
Directory normalization
Section titled “Directory normalization”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.
Collision resolution: shallowest-wins
Section titled “Collision resolution: shallowest-wins”When more than one surviving service points at the same dir, exactly one wins:
- Walk each service’s
extendschain to compute a depth. - The service with the shallowest chain wins. The chain depth is capped (currently at 32 hops) as a cycle guard.
- 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:
| Command | What is rendered | Whose viewpoint? |
|---|---|---|
render ide | per-variant editor configs (debugger, launch profile) | the variant being worked on right now |
render ai | hub identity and orientation for an AI agent | the canonical owner of the hub |
Explicit [service] argument
Section titled “Explicit [service] argument”dwe render ai <name> treats <name> as a hub anchor (same model as render ide).
Validation order (first failure wins):
- Service not in config.
- Service is disabled at the project level.
- Service has no hub directory, or its hub is the project root.
render.ai.enabledisfalse.
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.
Template pack resolution
Section titled “Template pack resolution”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.templatethat doesn’t exist is a hard error — no silent fallback. - Implicit chain (when
render.ai.templateis unset):<service-name>→ walk theextends: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
...
Manifest schema
Section titled “Manifest schema”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.mdThe 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.
render entries
Section titled “render entries”| Field | Required | Description |
|---|---|---|
from | yes | Path 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). |
to | yes | Destination path relative to the service hub directory. May be nested (e.g. .claude/CLAUDE.md). Must not escape the hub. Empty or .. is rejected. |
symlinks entries
Section titled “symlinks entries”| Field | Required | Description |
|---|---|---|
link | yes | Path of the symlink to create, relative to the service hub directory. May be nested. Must not escape the hub. |
to | yes | The 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.
Manifest validation rules
Section titled “Manifest validation rules”The manifest is validated before any file is written:
| Rule | Applies to |
|---|---|
At least one render or symlinks entry | manifest |
from non-empty, relative, ends in .tmpl | each render |
from does not escape the pack and contains no symlink in any parent | each 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 manifest | each render |
link non-empty, relative, does not escape the hub | each symlink |
link unique among symlinks | each 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 destination | each symlink |
Path comparisons are performed on cleaned paths, so AGENTS.md and ./AGENTS.md are treated as the same destination.
Per-file rendering
Section titled “Per-file rendering”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:
- Reads the template file.
- 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. - Executes the template against the template variables.
- Resolves the destination and runs the path-safety guards.
- Creates any missing parent directories.
- Refuses to overwrite the destination if it is a pre-existing symlink.
- Writes the rendered bytes.
Template variables
Section titled “Template variables”Templates receive the same object shape as IDE templates:
| Variable | Source |
|---|---|
.Project | project: block from workspace.yml |
.Service | canonical 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. |
.Resolved | rendering identity — service whose hub is actually being rendered (collision-policy winner). For AI the winner is the shallowest extender, so .Resolved typically equals .Service. |
.ServiceCfg | effective service config of .Resolved, after extends resolution. |
.Runtime | merged runtime block |
.Cfg | merged 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.Rawin AI templates: any value layered in fromworkspace/local.ymlwill surface in the rendered file and produce per-developer diffs in tracked artefacts. Use.Cfg.Rawfor 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.
Accessing .Cfg.Raw
Section titled “Accessing .Cfg.Raw”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.
Path-safety guards
Section titled “Path-safety guards”The renderer applies the same boundary checks as IDE rendering:
- Path cleanup. Manifest validation already rejected absolute paths,
..escapes, and.results. - Hub containment. The resolved destination must be inside the absolute hub directory.
- No symlinks in the destination path. Existing path components are checked before any directory is created.
- 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.
- 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.
Symlink creation
Section titled “Symlink creation”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:
- Validate that both
linkandtostay inside the hub. - Create the parent directory of the link with the same path-safety guards used for rendered files.
- 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.
- 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: falsefor 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.
Worked example
Section titled “Worked example”Layout:
workspace/services/ main/ service.yml main-debug/ service.ymlworkspace/templates/ai/ default/ manifest.yml AGENTS.md.tmpl .claude/CLAUDE.md.tmplManifest 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.mdTemplate 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: appcontainer: app-maindir: ./services/mainworkspace/services/main-debug/service.yml:
type: appextends: maincontainer: app-main-debugdir: ./services/main # same hub as parent — collisiondwe render ai:
- Selection: both services pass the activation gate (default
render.ai.enabled: true). They sharedir: ./services/main.mainhas the shallower extends chain (depth 0 vsmain-debug’s 1), somainwins.main-debugis reported as a collision skip. - Pack resolution for
main:render.ai.templateis unset; the implicit chain triesworkspace/templates/ai/main/(not found), thenworkspace/templates/ai/default/(used). - Manifest is loaded and validated: two render entries, one symlink. The symlink targets
AGENTS.md, which is one of the render destinations. - Each render entry is processed:
AGENTS.mdand.claude/CLAUDE.mdare written intoservices/main/. - The symlink
services/main/CLAUDE.md → AGENTS.mdis 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.tmpldwe 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).
Output messages
Section titled “Output messages”| Stream | Trigger |
|---|---|
| info | Explicit argument resolved to a different sibling — names the chosen winner and the shared hub directory. |
| warning | A selected service was skipped because it has no hub directory (or its hub is the project root). |
| warning | A selected service was skipped because another service won the directory collision — the winner is named. |
| success | One line per rendered file, naming the relative path inside the project. |
| success | One line per symlink (whether newly created or already correct), showing both the link and its target. |
| info | Nothing 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.
Common pitfalls
Section titled “Common pitfalls”- Pre-existing non-symlink at a managed symlink path. If
CLAUDE.mdalready exists as a regular file (perhaps from a previous manual edit),render airefuses to overwrite it. Delete the file or setrender.ai.enabled: falsefor the service. - Symlink
tomust 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:orsymlink:aborts loading. Fix the spelling. - Empty manifest is rejected. A manifest with both
render: []andsymlinks: []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 aidoes not walk the pack. Files inside the pack that are not referenced bymanifest.ymlare silently ignored at render time. Add an entry torender:to include them.
Related references
Section titled “Related references”services.<name>.render.aiblock —enabled,template, inheritance viaextendsrender ide— companion command with the opposite (deepest-wins) collision policy- Run
dwe render ai --helpfor the live CLI surface