Angee templates are how a shape of deployment is declared once and re-used, rendered with copier-go. The operator renders three kinds:
- Stack template (
kind: stack) — produces anangee.yaml(and generated runtime files) for a runnable Stack root. Stack templates come in two flavours matching the two layouts: the dev flavour renders the framework-dev stack — the project host at the Stack root with the framework repos asgitSources and asrcWorkspace (plainangee init); the local flavour (local / staging / prod) renders a self-contained docker-compose root (angee stack init). - Workspace template (
kind: workspace) — produces a workspace tree under$ANGEE_ROOT/workspaces/<name>and may declare Sources to materialize. It renders no Stack (see Workspaces and stacks). - Service template (
kind: service) — produces one Service definition (see Service templates).
A fourth kind, project (kind: project), belongs to the Host rather
than the operator: it scaffolds the application repository a Stack runs as
a Source (for the default Host, a Django + React project — see the
Host glossary). It is rendered by copier / the
framework, not angee stack init; the operator lists it but does not
render it.
Every kind is abstract: it only declares which Services to run, which
Sources to materialize, and which inputs the user supplies. Rendering
produces a Stack root, a Workspace directory, a Service, or a project repo
the engine can operate on. A template must contain copier.yml with Angee
metadata under _angee.
Angee's stateful reconciliation preserves Copier templates with
_preserve_symlinks: true: it treats each rendered symlink as a first-class
entry, fingerprinting it by its link target and applying it through a rooted
symlink write, so layered and dry-run writes stay safe. Symlink parents are
governed separately: existing workspace links for declared local Sources have
their resolved target verified before Angee reads or writes through them, and an
undeclared symlink parent is rejected.
_angee:
kind: stack
name: dev_angee:
kind: workspace
name: prangee stack init <template> resolves stack templates.
angee workspace create <name> --template <template> resolves workspace
templates.
A template reference (angee init --template, angee stack init <template>, angee workspace create --template, angee service create --template) takes one of these forms. The examples are stack refs; a
workspace ref uses workspaces/ and a service ref services/.
| Form | Example | Resolves to |
|---|---|---|
<name> |
dev |
stacks/dev, from the local search paths, then the template registry. |
<kind>/<name> |
stacks/dev |
The same, with the kind spelled out. The kind must be the one the command renders: angee init --template workspaces/dev-pr is refused. |
<owner>/<repo>//<path> |
ang-ee/angee-django//templates/stacks/dev |
The template at <path> in that GitHub repository. The double slash separates the repository from the path. |
any of the above + @<ref> |
dev@main |
The same template at a branch or tag of the registry (or of <owner>/<repo>). A pinned ref skips the local search. |
| Absolute path | /home/me/templates/stacks/dev |
That template directory, used as is. |
| GitHub URL | https://github.com/ang-ee/angee-django/tree/main/templates/stacks/dev |
The template path in that repository, at the branch in /tree/<ref>/ or ?ref=<ref>, or the default branch when neither is given. |
A relative path such as ./templates/stacks/dev is not a template
reference: name the template (stacks/dev) or pass an absolute path. An
@<ref> pin cannot contain /; for a branch such as feature/x, use a
GitHub URL with ?ref=feature/x. A reference that matches none of these
forms, or names another kind, fails with a message listing them.
For a short name like dev, stack resolution looks for stacks/dev. Workspace
resolution looks for workspaces/dev.
Current local search includes:
$ANGEE_ROOT/.templates/<kind>/<name>
$ANGEE_ROOT/templates/<kind>/<name>
$ANGEE_ROOT/<kind>/<name>
$ANGEE_ROOT/<name>
ancestor/.templates/<kind>/<name>
$PWD/.templates/<kind>/<name>
$PWD/templates/<kind>/<name>
ancestor-of-PWD/.templates/<kind>/<name>
<kind> is stacks, workspaces, or services.
angee init resolves stacks/dev from the local search paths first, then
from the template registry —
ang-ee/angee-django by
default; ANGEE_TEMPLATE_REGISTRY overrides it with another repository
(URL, owner/repo, or an absolute local path).
HTTP(S) GitHub URLs are supported. The URL must include owner, repo, and a template path.
angee stack init https://github.com/example/templates/tree/main/.templates/stacks/dev
angee workspace create fix-issue-123 --template https://github.com/example/templates/tree/main/.templates/workspaces/prThe resolver clones the repository into the user cache, checks out the
requested branch or ?ref=, and renders the template path.
Every top-level key of copier.yml that is not prefixed with _ is a
question. Angee reads the question metadata into the template descriptor
(angee template get <ref>, GET /templates/{ref}, GraphQL templates)
and drives its input form from it, so what you declare is what the user
sees:
runtime_mode:
type: str
default: process
choices: [process, docker]
help: Run framework application services as local processes or Docker containers.
api_key:
type: str
secret: true
help: Anthropic API key; stored in the stack's secret backend.
seed_users:
type: str
multiselect: true
choices:
Admin account: admin
Demo tenant: demo
help: Fixtures to load on first provision.| Field | Effect in the form |
|---|---|
| Declaration order | Questions are asked in copier.yml order; put the decisions a user must make first and image pins last. |
help |
Shown under the input; keep it to one or two sentences. |
default |
Pre-filled value, shown as (default) until changed. |
choices |
Rendered as a list (a filterable one above eight entries); values are validated in every mode, including --input and --yes. The mapping form (Label: value) shows the label and stores the value. A Jinja string is kept as an expression and falls back to free text. |
multiselect: true |
Multiple choices; the stored value is a JSON array string such as ["admin","demo"]. |
type: bool |
A Yes/No toggle; yes/no/y/n/true/false are accepted from flags and answers files. |
type: int |
Validated as an integer. |
type: path |
Free text; the value is rewritten relative to the stack root as described under Local Resolution. A leading ~ (~ or ~/…) expands to the home of the user running the render, and that absolute path is what the answers record, so a later render elsewhere reuses it. A value passed to stack init or stack update --template (--input, --answers, or a field changed in -i) is read relative to the directory copier renders into: the one ANGEE_ROOT is relative to, which holds the answers file (the project directory for a .angee or a deploy/.angee stack). stack update --template replays a recorded answer as it is, since it is already relative to the stack root, and so it keeps a value passed equal to the recorded one; it rewrites only other values passed on the update and the default of a question added since. The -i form therefore shows a recorded path relative to the stack root, but reads an edited one relative to the directory holding the answers file. |
secret: true |
Masked while typing and in the summary; never written to the answers file, so it is asked again on re-render. |
placeholder |
Hint shown while the input is empty. |
required: true (in _angee.inputs) |
The form refuses an empty value; non-interactive runs name the --input flag to pass. |
when |
A question whose condition is false, given the answers before it, is not asked: the form hides it, line prompts skip it, and it takes its default. A value passed for it is not validated, since copier ignores it too. copier-go evaluates the condition, so it means what it means at render time. |
validator |
Not evaluated by the form; the render runs it on every answer it uses, inputs and defaults alike, as copier does. A failing validator, a value outside choices, or a required question with neither an input nor a default fails the render with code INVALID_INPUT and copier's message naming the question. |
angee passes copier the inputs you give, plus generated values and the path
inputs it rewrites, but not a template's plain defaults. copier works those out
itself: it renders a templated default (default: "{{ name|lower }}"), and on
stack update --template it keeps an answer recorded last time.
_angee.inputs declares metadata-only inputs (generated, immutable,
required) that are not questions; they appear read-only in the form.
When the same name is declared as a question and under _angee.inputs,
the question wins.
Answers recorded by a render (.copier-answers.stack.yml for stacks,
.copier-answers.yml for workspaces and services) can be replayed with
--answers <file> on the create commands, and angee stack update --template -i re-opens the form with the recorded values. The --answers
replay does not hold for the type: path answers of a stack whose
ANGEE_ROOT is not .: the file records them relative to the stack root,
but --answers passes them as new values, relative to the directory copier
renders into, so they are resolved again (.. becomes ../..). Pass those
with --input instead.
Workspace templates may declare inputs, sources to materialize, port pool ranges to ensure, and persistent paths:
_angee:
kind: workspace
name: pr
instance_naming:
pattern: "${inputs.branch | slug | truncate(40)}"
inputs:
branch:
type: string
required: true
sources:
app:
source: app
mode: worktree
branch: "${inputs.branch}"
subpath: app
ensure:
operator.port_pool.workspace:
range: "8100-8199"
persist:
browser-data:
subpath: .browser-data
scope: workspaceWhen the workspace is created, each Source listed under sources: is
materialized (a git worktree, a local mount, etc.) on the configured
branch.
A source's mode, branch, ref and subpath accept ${...}
substitution, so a stack can choose them through inputs without forking
the template (for example mode: "${inputs.slot_mode}"). The resolved
mode must be worktree, clone or empty; anything else fails the
create before any source is materialized. A worktree source whose
branch resolves to empty starts on a detached HEAD at ref.
Stack templates use the same Copier rendering path and must produce an
angee.yaml under the initialized stack root. They are typically much
simpler — just _angee.kind: stack plus the Jinja-templated
angee.yaml and any seed files (env templates, runtime overlays).
A workspace and a stack are separate concerns. A workspace is files: its
rendered template and its source slots. A stack is a manifest and its
runtime, run by its own operator. A stack's services and jobs reach a
workspace through workspace:// mounts, workdir and env values, so
several services and jobs can each use a different workspace:
services:
web:
image: python:3.13
workdir: workspace://pr-42/app
mounts:
- "workspace://pr-42/app:/app"The workspace's status lists them under mountedBy.
A workspace template that declares chain or chain_root is refused, with
an error naming
angee-operator#110.
Workspace templates used to render a stack into the workspace tree that way,
which gave that stack's angee.yaml two writers and its ports two records.
To run a workspace's code in a stack of its own, create that stack as any
other (angee stack init) and point its services at the workspace's files.
A workspace created from a chaining template before that change keeps its
files, the stack inside included, which is now an ordinary stack at that
path. Its status reports no inner stack and destroy works as usual, but
workspace update refuses it: a render of the workspace template alone would
overwrite or delete what the chain rendered (the stack's own
.copier-answers.yml among them), and an update cannot switch the template a
workspace records. Destroy it and create it again from a template that
renders no stack. A stack template's own chain: (a stack composing
templates at its root, as stacks/dev chains projects/web) is unaffected.
copier-go confines a template's includes to its own root, the directory
holding copier.yml, so a template that includes a file from outside it — a
shared partial kept beside the collection, say — would not find it. Declare
how far the template reaches with _angee.include_root, and that ancestor
becomes the boundary instead:
# templates/stacks/dev/copier.yml
_angee:
kind: stack
name: dev
include_root: "../.." # pins templates/, so ../_shared/ resolves{% include "../_shared/AGENTS.md.jinja" %}Include names resolve against the template's root, the directory holding
copier.yml, as upstream Copier resolves them: from stacks/dev,
../_shared/ is stacks/_shared/.
include_root is relative to the template directory and must name one of its
ancestors; an include may read the declared ancestor and nothing above it.
Angee does not infer this from directory names: how you lay out a template
collection is your business, so a collection named hosts/ behaves exactly
like one named stacks/. Leave it unset for a self-contained template.
Putting templates and Sources together, the loop is:
- Sources are declared — your repos are listed under
sources:in the renderedangee.yaml. Angee fetches and caches them. - A Workspace renders a development shape — pick a Workspace template, supply the inputs (branch name, base ref, port ranges). Angee materializes Sources on that branch and renders the template under the workspace tree. Workspaces are a pure file primitive — they never start services.
- Services and jobs run the workspace's code — running services is
always a Stack concern, not a Workspace concern. A stack's services
and jobs reach the workspace through
workspace://mounts,workdirand env (see Workspaces and stacks), and the usualstack up/stack down/stack logscommands drive them. - A production Stack runs the same Sources — point the operator at
a different root with the same
sources:referring to release branches or tags.
The templating system is therefore the only place where "what runs"
needs to change. Promoting a feature to production does not rebuild any
images or rewrite any compose files — it just updates which ref each
Source points at and re-runs angee stack up.
A Copier template with _angee.kind: service renders a single
manifest.Service entry into the outer stack. Use it when an agent
runtime or other reusable service shape needs a Dockerfile, multiple
inputs, and per-instance port allocation.
Metadata fields (_angee:):
| Field | Required | Meaning |
|---|---|---|
kind: service |
yes | Distinguishes service templates from workspace / stack templates. |
name |
yes | Display name of the template. |
name_pattern |
no | Substitution pattern for the resolved service name. Default: agent-${workspace.name}. Resolved against the workspace name + template inputs. |
inputs |
no | Caller-supplied inputs with required / default flags. |
ensure |
no | Port pools the template needs in the outer stack (operator.port_pool.<pool>). The operator allocates one port per declared pool, scoped to service/<name>/<pool>. |
Rendered output: the template must emit a service.yaml containing
exactly one service entry under services:. Anything else (jobs,
volumes, secrets, sources) is rejected. Other files in the rendered
tree — typically docker/Dockerfile and friends — are reconciled into
<stack_root>/services/<service_name>/ so the rendered
build.context: ./services/<service_name>/docker resolves.
Render variables available in Jinja:
| Variable | Source |
|---|---|
service_name |
Resolved from name_pattern (or --name override). |
workspace_name |
--workspace flag. |
workspace_path |
Absolute path to the workspace dir. |
alloc_<pool> |
Allocated port for each pool declared in ensure. |
| Caller inputs | Every key from --input k=v, over the stack's workspace_defaults for the template, over the template's _angee.inputs defaults. |
Secret markers (${secret.foo}) in the rendered output are resolved
at compose-render time, not at service-create time.
Example skeleton:
# templates/services/my-agent/copier.yml
_subdirectory: template
_templates_suffix: .jinja
_angee:
kind: service
name: my-agent
name_pattern: "agent-${workspace.name}"
inputs:
api_key:
required: true
ensure:
operator.port_pool.acp:
range: "3000-3999"
api_key:
type: str# templates/services/my-agent/template/service.yaml.jinja
services:
{{ service_name }}:
runtime: container
build:
context: ./services/{{ service_name }}/docker
ports: ["{{ alloc_acp }}:3007"]
mounts: ["workspace://{{ workspace_name }}:/workspace"]
env:
API_KEY: "{{ api_key }}"Run with:
angee service create \
--template services/my-agent \
--workspace my-pa \
--input api_key=sk-...Template-created services can be refreshed in place:
angee service update agent-my-pa --template
angee service update agent-my-pa --template --input api_key=sk-new --dry-run
angee service update agent-my-pa --template --overwriteAngee tracks the last rendered assets and service.yaml. Updates preserve
independent local manifest edits, apply independent template edits, and report
same-field or asset conflicts before writing. Lists and scalar values are
atomic; maps such as env, structured build, and route merge recursively.
--overwrite selects the newly rendered value for conflicts. The service name,
workspace binding, and allocated ports remain authoritative from current state.
angee service destroy agent-my-pa removes the manifest entry,
releases the port lease, and deletes the build-context dir.
The repo ships a small set of templates under templates/. Today this
is just agent-runtime; more will follow as the host integrations
solidify.
Materialises a single long-running process that an external host —
today, angee-django —
addresses over ACP. The
template is the contract between the operator and any host that wants
to provision per-agent workspaces; the actual runtime binary is
expected to be wired in by the consuming host.
Inputs:
| Name | Required | Purpose |
|---|---|---|
AGENT_ID |
yes | Identifier for this agent runtime instance. Slugged into the workspace name and passed through to the spawned process. |
MCP_URL |
no | URL of the MCP server the agent should connect to. Empty / unset means "no MCP". |
MCP_TOKEN |
no | Bearer token for MCP_URL. v1 stores it in the workspace env file; rotate by re-running the workspace with updated inputs. |
Env contract. The materialised process receives these env vars; this shape is the load-bearing contract for host integrations:
| Env var | Source |
|---|---|
AGENT_ID |
Caller input. |
MCP_URL |
Caller input (may be empty). |
MCP_TOKEN |
Caller input (may be empty). |
ACP_PORT |
Allocated from the host stack's acp port pool — the host stack must declare one in operator.port_pool.acp. |
ACP_TOKEN |
Resolved from ${secret:acp_token} against the operator's secret backend. The host is responsible for provisioning the secret before bringing the workspace up. |
The v1 template renders a placeholder service that prints the contract
and sleeps forever; replace the services.agent.command block in your
fork with the real agent runtime invocation.
Provisioning shape (Django side):
mutation {
workspaceCreate(input: {
template: "agent-runtime"
inputs: [
{key: "AGENT_ID", value: "agent-claude-1"}
{key: "MCP_URL", value: "https://mcp.internal/sse"}
{key: "MCP_TOKEN", value: "..."}
]
}) {
name
path
processComposePort
}
}workspaceCreate only renders the workspace's files (including the inner
angee.yaml) and materializes its sources — it does not start the agent
process. Bring the agent up explicitly with a stack operation against the
workspace's inner root:
angee stack up --root workspaces/agent-claude-1/.angee
# or run a per-workspace operator the host can talk to over HTTP:
angee operator --root workspaces/agent-claude-1/.angee --port 9100Keep this contract in lockstep with
templates/agent-runtime/copier.yml and the consuming host's
provisioning code. Changes to the env var names or semantics need a
coordinated bump on both sides.