Templates are code: a TypeScript file in applications/main/src/templates/, reviewed like any
other change and published on deploy. The template viewer at
/templates/editor/[key] shows the published result read-only; there is no in-app editing.
| Location | applications/main/src/templates/*.ts |
| Needs | A reviewed change and a deploy |
Closed template composition | ✅ |
| Version controlled | ✅ |
import type { Template } from "@processos/app-authoring";
export const myTemplate: Template = {
key: "my-process",
name: "My Process",
firstStepKey: "collect_details",
permissions: [],
steps: [ /* … */ ],
};
firstStepKey must match a steps[].key. Array order is irrelevant — the run follows
nextStepKey.
Use a first input activity for the data a person must supply. runInputs and startExpression
are still round-tripped source metadata, not implemented start gates in the compatibility compiler.
The API checks process-write access and compiled start policy; do not use inactive authoring fields
as an authorization boundary.
template.permissions[] gates starting. step.permissions[] gates acting on a step.
input.permissions[] gates one field.
Declare roles[] to give actors readable names in the editor:
roles: [
{ id: "govops", name: "GovOps", permission: "processes:write", color: "#3b82f6" },
]
Editor metadata only — it compiles to the same permissions[] the engine reads.
Prefer completeExpression over mandatory when the rule is conditional or spans
fields:
completeExpression: "trim(input.name).length > 0 && trim(input.wallet).length > 0"
mandatory is a per-field UI affordance and is not enforced on hidden or read-only
fields. completeExpression is server-side and sees everything.
{
key: "review_gate",
type: "condition",
title: "Approved?",
expression: "review.approved === true",
thenStepKey: "execute",
elseStepKey: "collect_details", // ← loop back for rework
nextStepKey: null,
}
elseStepKey pointing backwards is the rework loop. Each pass appends another
ProcessStep, so history shows every attempt.
Pick step types from step types. Read the failure
contract before choosing — a slack_notify failure lets the run continue, a dune
failure stops it.
resultViewControls[] renders on the completion screen:
resultViewControls: [
{ title: "Payload", data: "{{ generatePayload(context) }}", plainText: true },
]
Show a prior answer back. A read-only field with defaultValue:
{ key: "_view_0", type: "string", title: "Feedback from review",
readOnly: true, defaultValue: "${review.notes}" }
Leading _ marks display-only fields by convention.
Conditional fields.
{ key: "reason", type: "string", title: "Why not?",
visibleExpression: "review.approved === false" }
Reusable field groups. A subroutine instead of copy-paste:
{ type: "subroutine", key: "verify", subroutineId: "safe_ui_verification", params: { prefix: "tx" } }
Registry: applications/main/src/templates/shared/.
Group steps visually. phase: "Intake" on consecutive steps sections the rail. The
engine ignores it.
Choices that change with time. A dropdown whose options follow a rule (the next vote dates) declares the rule, not today's output, so the template does not change as the dates move and the server checks a new answer against the same rule:
{ key: "execDate", type: "dropdown", title: "Targeted Executive Vote", mandatory: true,
choices: { recipe: 'cadenceDates("2026-07-30", 14, isoDateAddDays(todayIsoDate(), 23), 10, [])' } }
The recipe may read only the current instant. See choice lists.
Downloadable payloads. downloadFilename on a read-only field beats copy-paste for
anything a user must paste elsewhere:
downloadFilename: "payload-{{ todayIsoDate() }}.json"
applications/main/src/templates/shared/constants.ts holds values more than one template needs — Safe
addresses, token addresses, the IB payment tracker's Notion database, and the notify
destinations.
import { SKYBASE_SAFE_ADDRESS, USDS_ADDRESS, SLACK_CHANNELS } from "./shared/constants";
EVM addresses are stored checksummed. Lowercase at the call site when an API needs
it — observatoryIntegrationBoostUrl() does this — rather than adding a second constant
for the same address.
| Used by 2+ templates | put it in constants.ts |
| Used once | leave it inline — a constant with one caller is indirection, not reuse |
| A notify destination | constants.ts, even if used once, so "where does this platform post" is answerable in one place |
Nothing forces a template through that file. channelId, databaseId and safeAddress
are ordinary string fields; setting them inline still works and is the right call for a
one-off.
The reason to bother: before this existed, the USDS address appeared four times under three names in two casings, and one Notion database had two constant names in two files. Changing either meant knowing every copy.
Reusable Domain and Integration builders return closed Template/Expression data. Compose them
rather than copying JavaScript bodies. Raw script is not an executable activity, even from repo
code. A genuine missing primitive needs an explicit language design, not a native callback escape.
Add the file under applications/main/src/templates/, export it, and add it to REPO_TEMPLATES in
applications/main/src/templates/registry.ts. Nothing else discovers it. On deploy, explicit application
initialization publishes every compiled registration whose execution or presentation digest differs from
the stored latest (and provisions new keys), logging one [templates] published <key> line per key.
Running processes keep the version they started on. Reads do not reseed.
See manage templates.
npm run dev:all # you need the worker, or nothing past the first input step runs
Then walk it end to end — see your first process.
Deliberately break each automated step once. Confirm it fails the way you expect: some types stop the run, notifications do not.
The four that account for most of them:
completeExpression.dev:all.flowToTemplate.Full symptom index: troubleshooting.