Author a template

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.

Locationapplications/main/src/templates/*.ts
NeedsA reviewed change and a deploy
Closed template composition✅
Version controlled✅

Shape

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.


Decisions to make, in order

1. What starts it

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.

2. Who acts where

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.

3. How a step is allowed to finish

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.

4. Where it branches

{
  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.

5. What happens automatically

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.

6. What the reader sees at the end

resultViewControls[] renders on the completion screen:

resultViewControls: [
  { title: "Payload", data: "{{ generatePayload(context) }}", plainText: true },
]

Patterns worth copying

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"

Shared values

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.

What belongs there, and what does not

Used by 2+ templatesput it in constants.ts
Used onceleave it inline — a constant with one caller is indirection, not reuse
A notify destinationconstants.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.

Share inspectable definitions

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.

Registering a repo template

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.


Testing it

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.


Common mistakes

The four that account for most of them:

  • A step will not complete and nothing looks required — read its completeExpression.
  • A run sits forever on a non-input step — no worker. Use dev:all.
  • Editor edits vanish on save — the step type is missing from flowToTemplate.
  • An expression references a step that has not run — guard it, or it throws every second forever.

Full symptom index: troubleshooting.