Defining an Agent Template
Prerequisite: Anatomy of an Agent, which introduces the template in passing. This guide owns the full contract.
Schema source of truth:
internal/registry/types.go(AgentTemplate). Worked references: the co-locatedtemplate.jsonfiles (e.g.agents/trip-planner/template.json), whichscripts/seed.shsweeps up.
An agent template is the registry’s description of an agent type: the image
to run, how to run it, the relationships to grant it, and which children it may
delegate to. You register one template per agentType; every POST /spawn of
that type is materialised from it.
This guide is tutorial-first — we build a template from a blank file — followed by a reference appendix you can return to.
Tutorial: build a template from scratch
We’ll define a template for a hypothetical report-builder agent and register
it. Start with the two things that name and describe the type:
{ "agentType": "report-builder", "version": "1.0.0", "status": "active", "meta": { "displayName": "Report Builder", "description": "Pulls data from a protected API and emits a report" }}agentType is the key everything else hangs off — it’s what you pass to
/spawn and the registry’s map key. meta is for humans and the dashboard.
(version and status are recorded but not enforced today — see
status callouts.)
1. Tell the platform how to run it — runtimeSpec
"runtimeSpec": { "image": "agent-report-builder:latest", "lifecycle": "short-lived", "resources": { "cpuLimits": "500m", "memoryLimits": "256Mi" }, "envDefaults": { "LOG_LEVEL": "info" } }image— the container the operator runs. It must be loaded into Kind (make kind-load) and built from aDockerfiletarget.lifecycle—short-lived(default) orlong-lived. This single field decides whether the operator creates a Service, whether pod-exit means “Completed”, and whether the agent is chattable from the dashboard. See the lifecycle switch and Chatting with a Long-Lived Agent.supportsChat— settrueon a long-lived agent that serves the/agents/chat/:sessionIdendpoint, to offer the dashboard Chat button. See Chatting with a Long-Lived Agent.resources— CPU/memory limits applied to the agent container.envDefaults— extra env vars injected verbatim. Use these for agent-specific config (poll intervals, feature flags). See env precedence.
2. Grant it authority — authzTemplate
This declares the SpiceDB relationships written when an agent of this type self-registers. The standard grant lets the agent call protected APIs for its tenant:
"authzTemplate": { "spiceDbRelations": [ { "resource": "tenant:{{tenant_id}}", "relation": "agent", "subject": "agent:{{agent_id}}" } ] }{{tenant_id}} and {{agent_id}} are expanded by the registry at registration
time (substitute()). What this grant means
— and how to design more of them — is the subject of
05 — Defining Policy.
Tenanted vs global agents
Whether an agent belongs to a tenant is derived from the presence of a tenant id at spawn, not declared by a flag:
- Tenanted (the common case) — spawned with a
tenantId. Its SVID isspiffe://…/agent/<tenant>/<user>/<type>/<id>, and any{{tenant_id}}relation above is written to SpiceDB. - Global / tenant-agnostic — spawned without a
tenantId. Its SVID drops the tenant/user segments (spiffe://…/agent/<type>/<id>), and the registry skips any relation that references{{tenant_id}}(so a template’s tenant grant goes inert rather than writing a malformedtenant:tuple). Use this for agents that only call tenant-agnostic resource servers.
Because the tenant grant assumes a tenant, set requiresTenant: true on any
template that has a {{tenant_id}} relation — the orchestrator then rejects a
tenant-less spawn with 400 instead of letting it come up “global” with no
tenant grant (which would be silently denied by every tenant-checking API):
"requiresTenant": trueLeave it false (the default, or omit it) only for a genuinely tenant-agnostic
type whose authzTemplate has no {{tenant_id}} relation.
⚠️ This pairing is a convention, not enforced: nothing warns if a template has a
{{tenant_id}}relation but omitsrequiresTenant. Keep them in sync by hand.
3. (Parent agents only) allow spawning / delegation — delegation
Omit this for an agent that never spawns children. Any agent that spawns
children must list them in allowedChildTypes — the orchestrator rejects a
spawn whose child type the parent doesn’t list (deny-by-default), whether or not
authority is delegated. grantableScopes/maxDepth are only needed when the
parent also delegates scopes:
"delegation": { "allowedChildTypes": ["data-fetcher"], "grantableScopes": ["sample-api-b:read"], "maxDepth": 3 }Full treatment in 05 — Defining Policy.
4. Register it
The registry stores templates in memory. Save your template as a template.json
next to your agent (agents/report-builder/template.json, or
agents/go-worker/template.json for the Go worker) — scripts/seed.sh
discovers every co-located template.json and POSTs it, so it survives a registry
restart. Seed with:
make reseed # runs scripts/seed.sh: sweeps up every template.json and POSTs itTo register a one-off without re-seeding everything, you can still POST directly:
# seed.sh port-forwards the registry to localhost:18080curl -sf -X POST http://localhost:18080/v1/templates \ -H 'Content-Type: application/json' \ -d @agents/report-builder/template.jsonOr manage the template declaratively with the
terraform-provider-spawnly
provider — terraform apply upserts it through the same control-plane API, and
terraform destroy disables-then-deletes it. See
Config-as-code with Terraform.
5. Spawn and verify
curl -sf -X POST http://localhost:8080/spawn \ -H 'Content-Type: application/json' \ -d '{"agentType":"report-builder","tenantId":"tenant-1","userId":"user-1","task":"daily report"}'# -> {"workloadName":"report-builder-xxxxx"}
curl -sf http://localhost:8080/v1/agents/report-builder-xxxxx/events | jqThat’s the whole loop: define → register → spawn → observe.
Reference appendix
Full AgentTemplate schema
| Field | Type | Required | Notes |
|---|---|---|---|
agentType | string | ✅ | Unique type key; the registry’s map key and the /spawn agentType. |
version | string | — | Recorded only. Not used for selection (status callouts). |
status | string | — | active | deprecated. Recorded only; not enforced. |
requiresTenant | bool | — | When true, the orchestrator rejects a tenant-less spawn of this type. Default false. Set true whenever authzTemplate has a {{tenant_id}} relation. See tenanted vs global. |
meta.displayName | string | — | Shown on the dashboard. |
meta.description | string | — | Human description. |
runtimeSpec.image | string | ✅ | Container image (must be loaded into Kind). |
runtimeSpec.lifecycle | string | — | short-lived (default) | long-lived. |
runtimeSpec.supportsChat | bool | — | true if the agent serves /agents/chat/:sessionId; gates the dashboard Chat button (long-lived only). See Chatting with a Long-Lived Agent. |
runtimeSpec.resources.cpuLimits | string | — | K8s CPU limit, e.g. 500m. |
runtimeSpec.resources.memoryLimits | string | — | K8s memory limit, e.g. 256Mi. |
runtimeSpec.envDefaults | map | — | Extra env injected verbatim. |
authzTemplate.spiceDbRelations[] | list | — | {resource, relation, subject} with {{tenant_id}}/{{agent_id}}. |
delegation.allowedChildTypes[] | list | — | Child types this type may spawn/delegate to. |
delegation.grantableScopes[] | list | — | Scope ceiling this type may pass down. |
delegation.maxDepth | int | — | Max chain length / delegation depth; enforced at /spawn (chain length) and at token-exchange (delegation depth). 0 = unbounded check skipped. |
Who consumes each field
The template is read by four components at different moments — a template is not “used” in one place:
| Field(s) | Consumer | When |
|---|---|---|
requiresTenant | Orchestrator | At /spawn — rejects the request with 400 if true and no tenantId was supplied. |
runtimeSpec.lifecycle | Orchestrator | At /spawn — copied onto the AgentWorkload (main.go:164). |
runtimeSpec.image, resources, envDefaults | Operator | At pod build (reconciler.go:224). |
authzTemplate.spiceDbRelations | Registry → SpiceDB | At agent self-registration (relations projected into SpiceDB). |
delegation.allowedChildTypes | Orchestrator (via registry /v1/spawn-policy) | At /spawn, when a parentId is present (deny-by-default). |
delegation.maxDepth | Orchestrator (via /v1/spawn-policy) + IdentityServer (via /v1/delegation-policy) | At /spawn (caps total chain length) and at token-exchange (caps delegation depth). |
delegation.grantableScopes | IdentityServer (via registry /v1/delegation-policy) | At token-exchange. |
Substitution tokens
Only two, expanded by the registry when an agent registers
(substitute()):
| Token | Becomes |
|---|---|
{{tenant_id}} | the spawning request’s tenantId |
{{agent_id}} | the agent’s canonical id (AGENT_ID) |
For a global agent (no tenantId), any relation
referencing {{tenant_id}} is skipped at registration rather than written
with an empty tenant.
The lifecycle switch
lifecycle | Service created? | Pod exit 0 means | Used by |
|---|---|---|---|
short-lived (default) | No | Completed (reconciler.go:128) | Scenario 1 |
long-lived | Yes — <AGENT_ID>-svc (reconciler.go:104) | Nothing (stays Running until deleted) | Scenario 2, child in Scenario 3 |
Environment-variable precedence
The operator builds the agent container’s env list in this order
(buildPod): platform-injected
vars (AGENT_ID, TENANT_ID, REGISTRY_URL, the API URLs, …) → AI_* from the
ai-provider Secret → your envDefaults → TASK.
Rule of thumb: use
envDefaultsfor new keys, not to override platform-injected ones. Don’t name anenvDefaultskey the same as a reserved platform variable — relying on override behaviour for duplicate env names is a footgun. The full reserved list is the env table in 00 — Anatomy.
The image / build contract
What the platform expects of a template’s image:
- Built from a
Dockerfiletarget and loaded into Kind (make kind-load). - Node agents bundle the compiled
@spawnly/sdkfrom thebuild-ts-sdkstage (see theweather-monitor/parent-agent/child-agentstages); the Gogo-workerbuilds its own module in thebuild-go-workerstage. - Long-lived agents must listen on port 8080 — that’s the
targetPortof the generated<AGENT_ID>-svcService.
Operating on templates
| Action | How |
|---|---|
| Register / update | POST /v1/templates (upsert by agentType); or terraform apply via the provider. |
| List types | GET /v1/templates. |
| Persist across restarts | Drop a template.json next to your agent (agents/<type>/template.json); run make reseed (scripts/seed.sh). |
| Config-as-code | Manage templates declaratively with terraform-provider-spawnly. |
Status callouts
Honest notes about what is and isn’t enforced today:
- ⚠️ The registry store is in-memory (
newStore). Restarting the registry deletes every template and agent record. Always keep atemplate.jsonnext to your agent and re-seed (make reseed) after redeploying the registry. - ⚠️
versionis informational.getTemplatekeys onagentTypeonly (main.go:43); there is no version selection. A secondPOSTfor the sameagentTypeoverwrites the first. - ⚠️
status: deprecatedis not enforced. Nothing filters deprecated templates from spawn. Treat it as a label, not a guardrail.
Next: 05 — Defining Policy — what the authzTemplate
and delegation blocks actually authorise, and how that policy is enforced.