> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Template Manifest Reference

> Every attribute of an Agent Template manifest — authored, frozen, and served — with types, defaults, constraints, and the publish lints that guard them

This is the complete attribute reference for Agent Template manifests. For the concepts and lifecycle, read [Agent Templates](/marketplace/agent-templates); for the authoring journey, [Publishing Templates](/marketplace/publishing-templates); for the installer's experience, [Deploying Templates](/marketplace/deploying-templates).

## Where the manifest lives

A template's manifest exists in three forms. Knowing which form an attribute belongs to tells you where you set it, where it's stored, and where an installer reads it:

1. **The authored sections** — what you write. The `template:` section of your project's `lua.skill.yaml` carries the six authored sections (`connections`, `personaTemplate`, `triggerPresets`, `paramsMeta`, `onInstall`, `onUninstall`). The publish request can additionally carry sections that have no yaml home yet: the env contract (declared with `--env-contract`), connection-event triggers (`declaredTriggers`), `outcomes`, `channels`, `features`, and marketplace-skill composition refs. The publish request also carries three version-level fields: `sourceAgentVersion` (which promoted agent version to freeze; defaults to the active one), `changelog`, and `referenceEvalRunId` (the eval run satisfying the publish gate, when an eval set exists).
2. **The frozen published version** — what publish produces. An immutable, integer-numbered version that combines your promoted agent's primitives (skills, webhooks, jobs, processors, triggers, model — frozen **by value**) with your authored sections (validated, normalized, and stored alongside), plus derived integrity digests: `contentHash`, and the `codeHash` / `consentSurfaceHash` pair that powers update diffing and re-consent (creator-side version reads only — the two are never in the public manifest).
3. **The served manifest** — what installers read. `GET /marketplace/templates/:id/versions/:v/manifest` serves a **safe projection** of the frozen version: display metadata and requirements only, never source code, trigger instructions, or values. Fields are lifted by an explicit allow-list — anything not deliberately made public never serves. The serving layer also *enriches* it at read time: platform display names and auth modes from the live integration catalog, scope display copy, next run times, the per-caller `reconciliation` block, and the org `policy` block.

| Attribute group                                                                    | Authored by you                   | Frozen at publish                                | In the served manifest                                                             |
| ---------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------- |
| Metadata (`name`, `displayName`, …)                                                | at `create` / `patch`             | header, not per-version                          | yes                                                                                |
| `agent.model`, `agent.modelSettings`                                               | no — frozen from the source agent | yes                                              | `model` only                                                                       |
| `personaTemplate`                                                                  | yes (yaml)                        | yes (normalized)                                 | yes — the template text is part of the consent surface                             |
| `skills[]` (and webhooks, jobs, processors, triggers)                              | no — frozen from the source agent | yes, by value                                    | display metadata only (skills; schedule/webhook triggers)                          |
| `connections[]`                                                                    | yes (yaml)                        | yes                                              | yes, platform entries enriched                                                     |
| `triggerPresets`                                                                   | yes (yaml)                        | yes (normalized over the full trigger key-space) | merged into `triggers[]`                                                           |
| `declaredTriggers[]`                                                               | yes (publish request)             | yes                                              | display fields only — `event` and `instruction` never serve                        |
| `envContract` + `paramsMeta`                                                       | yes (`--env-contract` + yaml)     | yes                                              | merged into `params[]`                                                             |
| `onInstall` / `onUninstall`                                                        | yes (yaml)                        | yes (normalized)                                 | yes, verbatim — the hooks are part of the consent surface                          |
| `channels[]`, `features[]`                                                         | yes (publish request)             | yes                                              | yes                                                                                |
| `outcomes`                                                                         | yes (publish request)             | yes                                              | creator/version reads and outcome summaries (not the deploy-screen manifest in V1) |
| `contentHash`, `installable`, `deprecated`, `reconciliation`, `policy`, `nextRuns` | never                             | derived                                          | yes — served-only, see [below](#served-manifest-only-fields)                       |

**Inheritance vs. clearing.** On the publish API, an *omitted* authored section inherits the previous version's (re-validated against the new content); an explicit empty value (`[]`, `{}`, `{ units: [] }` for `outcomes`, or `null` for `personaTemplate`) **clears** it. The CLI is stricter: when a `template:` section exists in `lua.skill.yaml`, all six yaml sections are always sent — an absent or empty subsection is an explicit clear, never "keep the previous version's". Omitting `--env-contract` inherits the previous contract. `onInstall` and `onUninstall` follow the same rule: on the publish API an omitted section inherits and an explicit `null` clears; from the CLI, deleting the section from your yaml clears it.

**Keys are identity.** Every keyed entry — a skill key, connection key, trigger key, param name, persona var name, outcome unit key — is its stable identity across versions. Renaming a key means *remove + add*: installers lose their binding, toggle, or setting for the old key. Keep keys stable.

***

## metadata

Template-level, not per-version: set at `lua marketplace template create`, editable later (except `name`).

| Field                                 | Type                  | Required | Default   | Constraints                                   | Description                                                                                      |
| ------------------------------------- | --------------------- | -------- | --------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `name`                                | string                | yes      | —         | unique per creator organization               | Machine name of the template, e.g. `sales-followup`.                                             |
| `displayName`                         | string                | yes      | —         | —                                             | Catalog and deploy-screen title.                                                                 |
| `description`                         | string                | no       | `''`      | —                                             | Catalog card copy.                                                                               |
| `visibility`                          | `public` \| `private` | no       | `private` | flipping to `public` enqueues platform review | Private templates are org-only; public templates list in the catalog once a version is approved. |
| `id`                                  | string (UUID)         | derived  | —         | server-assigned                               | Opaque template id, no prefix.                                                                   |
| `listed`                              | boolean               | derived  | `true`    | —                                             | `false` after unlisting (soft retire).                                                           |
| `latestVersion`                       | integer               | derived  | —         | monotonic                                     | Highest published version number.                                                                |
| `latestApprovedVersion`               | integer               | derived  | —         | absent = no approved version                  | Highest *approved* version — what public installers resolve "latest" to.                         |
| `installCount` / `activeInstallCount` | integer               | derived  | `0`       | —                                             | Lifetime installs / currently-installed count.                                                   |

<Note>
  Versions are **integers** (v1, v2, v3 — not semver), monotonic per template. A rejected publish burns no version number. Catalog categories and icons are not part of the V1 manifest.
</Note>

## agent

| Field             | Type   | Required | Default | Constraints                                                               | Description                                                                                                              |
| ----------------- | ------ | -------- | ------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `model`           | string | frozen   | —       | validated against the installer org's model catalog at install pre-flight | The model frozen from the source agent, e.g. `anthropic/claude-sonnet-4`. The only `agent` field in the served manifest. |
| `modelSettings`   | object | frozen   | —       | —                                                                         | The source agent's model settings, frozen by value. Applied at install; not served in the public manifest.               |
| `personaTemplate` | object | no       | absent  | see below                                                                 | The persona section — authored, not frozen from the source agent.                                                        |

### agent.personaTemplate

The system prompt as a template with `{{VAR}}` slots, each declared, typed, and explained. Authored in the yaml `template:` section.

| Field      | Type                                 | Required | Default | Constraints                                                      | Description                                                                                                                                          |
| ---------- | ------------------------------------ | -------- | ------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `template` | string \| `{ base?, voice?, text? }` | yes      | —       | must keep ≥ 40 literal (non-slot) characters across all branches | The prompt text. The object form matches the persona's channel-aware branches.                                                                       |
| `vars`     | array                                | yes      | —       | exact bijection with `{{TOKEN}}`s (see below)                    | One declaration per `{{slot}}`.                                                                                                                      |
| `editable` | boolean                              | yes      | `false` | enforced server-side at install                                  | When `true`, the installer may edit the resolved prompt at deploy; the edited text wins over substitution and is stored verbatim, never re-expanded. |

Each entry of `vars[]`:

| Field         | Type                                                   | Required          | Default | Constraints                                                                                                              | Description                                         |
| ------------- | ------------------------------------------------------ | ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------- |
| `name`        | string                                                 | yes               | —       | `[A-Za-z_][A-Za-z0-9_]*`; `persona` is reserved (case-insensitive); must not collide with any declared param name        | The `{{TOKEN}}` this declares.                      |
| `displayName` | string                                                 | yes               | —       | non-empty                                                                                                                | Form label — the installer never sees a bare token. |
| `description` | string                                                 | yes               | —       | non-empty                                                                                                                | Help copy under the field.                          |
| `type`        | `string` \| `number` \| `boolean` \| `enum` \| `model` | yes               | —       | `model` holds a provider-prefixed model code; `enum` and `maxLength` are illegal on it, and a `default` must be a string | Drives the input control and value validation.      |
| `required`    | boolean                                                | no                | `false` | a non-required var **must** carry a `default`                                                                            | Whether the installer must answer.                  |
| `default`     | string \| number \| boolean                            | no                | —       | must satisfy its own `type`; for `enum`, must be a member                                                                | Pre-filled answer.                                  |
| `enum`        | string\[]                                              | when `type: enum` | —       | non-empty, unique, strings; illegal on other types                                                                       | The choices.                                        |
| `placeholder` | string                                                 | no                | —       | —                                                                                                                        | Input placeholder, e.g. `Acme Inc.`.                |
| `maxLength`   | integer                                                | no                | `256`   | strings only; 1–2000                                                                                                     | Length cap on the supplied value (injection floor). |

**Substitution rules** (what happens to `{{vars}}` at deploy):

* **Single-pass.** The template is scanned once; each declared token is replaced with the installer's answer. Substituted values are never re-scanned — a value containing `{{OTHER_VAR}}` lands as inert text, not a new slot.
* **Brace-escaped.** Inside supplied values, `{{` becomes `{ {` and `}}` becomes `} }` — a value can never mint a slot or reach runtime substitution targets.
* **The bijection lint.** Every `{{TOKEN}}` in any branch (`base`/`voice`/`text`) must have a `vars[]` entry, and every declared var must appear in at least one branch. Publish rejects both directions.
* **Values are never stored in the manifest.** Answers land on the installed agent's persona; the manifest carries only the declarations.

```yaml theme={null}
template:
  personaTemplate:
    template: |
      You are the sales assistant for {{COMPANY_NAME}}. Chase deals in the
      {{PIPELINE}} pipeline. Never discount past {{MAX_DISCOUNT}}%.
    editable: true
    vars:
      - name: COMPANY_NAME
        displayName: Your company
        description: Used in the agent's replies and signatures.
        type: string
        required: true
        placeholder: Acme Inc.
      - name: PIPELINE
        displayName: Pipeline to work
        description: Which sales pipeline the agent chases.
        type: string
        default: Enterprise
      - name: MAX_DISCOUNT
        displayName: Max discount %
        description: The agent never offers more than this.
        type: number
        default: 15
```

## skills\[]

Skills (like webhooks, jobs, processors, and code triggers) are **frozen from your promoted agent version, by value** — you never author these entries. The served manifest lifts display metadata only; source code, tool schemas, and gating conditions never serve publicly. Webhooks and processors serve nothing in the V1 manifest at all — they're internal wiring, not an installer decision surface.

Served shape per entry:

| Field                | Type                     | Required                   | Default            | Constraints                                                       | Description                                                                               |
| -------------------- | ------------------------ | -------------------------- | ------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `key`                | string                   | always                     | —                  | stable identity; rename = remove + add                            | The skill's name on the source agent.                                                     |
| `displayName`        | string                   | always                     | derived from `key` | —                                                                 | The skill's title; when none was set, the key is humanized (`crm-sync` → `Crm sync`).     |
| `description`        | string                   | always                     | `''`               | —                                                                 | "What this agent can do" copy on the deploy screen.                                       |
| `version`            | string                   | always                     | —                  | —                                                                 | The frozen skill version.                                                                 |
| `source`             | `agent` \| `marketplace` | when present               | `agent`            | —                                                                 | Provenance: frozen from the source agent, or composed from a published marketplace skill. |
| `marketplaceSkillId` | string                   | `source: marketplace` only | —                  | —                                                                 | The referenced marketplace skill's id.                                                    |
| `versionId`          | string                   | `source: marketplace` only | —                  | an immutable version-document id — never a semver, never "latest" | The exact pinned marketplace skill version.                                               |

Marketplace composition is authored on the publish request as `marketplaceSkills: [{ marketplaceSkillId, versionId }]` (max 50) — content is still frozen by value into the version; the refs record where it came from and pin the exact version.

## connections\[]

Declared runtime connection requirements — "this agent needs a CRM" — authored in the yaml `template:` section. Each entry is a **capability** satisfiable by any platform in its allow-list.

| Field         | Type            | Required | Default | Constraints                                                            | Description                                                                                                                                                                      |
| ------------- | --------------- | -------- | ------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key`         | string          | yes      | —       | non-empty, unique; stable identity                                     | The capability key. Referenced by `boundSkills`-owning outcomes evidence (`match.connectionKey`), connection-event triggers (`connectionKey`), and the install's binding ledger. |
| `capability`  | string          | yes      | —       | must equal the **primary** catalog category of every declared platform | Discovery label ("crm", "calendar", "chat"). The `platforms[]` allow-list, not this label, is what decides satisfiability.                                                       |
| `required`    | boolean         | yes      | —       | —                                                                      | `true`: install blocks until satisfied. `false`: offered at deploy, never blocking — primitives depending on a declined optional connection are materialized inert, fail-closed. |
| `ownerType`   | `user` \| `org` | no       | either  | —                                                                      | Constrains which grant identity may satisfy the capability — declare `org` when the connection must be the organization's (finance systems), never someone's personal key.       |
| `displayName` | string          | yes      | —       | non-empty                                                              | The connect chip label ("Your CRM").                                                                                                                                             |
| `description` | string          | yes      | —       | non-empty                                                              | The *why*, shown under the chip ("Used to read deals and write follow-up notes.").                                                                                               |
| `platforms`   | array           | yes      | —       | at least one; unique `type`s                                           | The ANY-OF allow-list — any one platform satisfies the capability.                                                                                                               |
| `boundSkills` | string\[]       | no       | —       | every entry must be a skill key in this version                        | Which of the template's skills depend on this connection. Drives the portability lint and outcome attribution.                                                                   |

Each entry of `platforms[]`:

| Field         | Type      | Required            | Default | Constraints                                                    | Description                                                                                                                                                                                                    |
| ------------- | --------- | ------------------- | ------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`        | string    | yes                 | —       | must be a live integration in the catalog                      | Integration type, e.g. `salesforce`, `hubspot`, `slack`.                                                                                                                                                       |
| `oauthScopes` | string\[] | for OAuth platforms | —       | scope names must exist in the platform's scope-copy vocabulary | The scopes this template needs on that platform — what the installer consents to. **Currently disabled at publish** while granted-scope capture rolls out; a declaration is rejected with a message saying so. |

**In the served manifest**, each platform entry is enriched from the live integration catalog at read time — the frozen declaration stores only `{type, oauthScopes}`:

```json theme={null}
{ "type": "salesforce", "name": "Salesforce", "authSupport": "oauth",
  "oauthScopes": [
    { "scope": "crm_contact_read", "displayName": "Read contacts" },
    { "scope": "crm_deal_write", "displayName": "Create & edit deals" } ] }
```

Raw scope tokens never reach a screen — `oauthScopes` (and `missingScopes` in reconciliation) always serve as `{scope, displayName, description?}` objects with platform-owned display copy.

**Reconciliation semantics, per attribute.** At deploy, the platform reconciles each capability against the connections the installer already holds (personal + the target org's shared ones): a connection whose `type` is in `platforms[]` and whose granted scopes cover the declared `oauthScopes` → `satisfied`, bound without asking; several matches → `multiple-matches`, the installer picks (never auto-picked); a match lacking scopes → `insufficient-scope` with the exact missing scopes; a pre-scope-capture row whose live scope query failed → `verify-access` (informational, resolved at pre-flight); no match → `unmet-required` (blocks) or `unmet-optional` (offered). `ownerType` filters which grants qualify at all. Install proceeds only when every `required` capability is satisfied — re-verified server-side, never trusted from the client.

**The portability lint.** A capability may list *multiple* platforms only when its `boundSkills` reach the connection through the capability itself. A bound skill that calls platform-native tools (e.g. `salesforce_*`-prefixed calls, or a platform-typed integration helper) pins the entry: publish rejects with a migration hint — narrow `platforms[]` to that platform, or migrate the skill to capability-level access. Single-platform entries lint nothing; narrowing is the author's escape hatch.

```yaml theme={null}
template:
  connections:
    - key: crm
      capability: crm
      required: true
      displayName: Your CRM
      description: Used to read deals and write follow-up notes.
      platforms:
        - type: salesforce
        - type: hubspot
      boundSkills: [crm-sync]
    - key: notify
      capability: chat
      required: false
      displayName: Notifications channel
      description: "Optional: where follow-up alerts get posted."
      platforms:
        - type: slack
```

## triggerPresets and the served triggers\[]

Schedules and code triggers are frozen from the source agent; **triggerPresets** is your authored layer over them — recommended defaults, installer-facing copy, and which schedule fields the installer may edit. It's a map keyed by trigger key, covering the union of the version's job, webhook, code-trigger, and connection-event keys. Keys you don't author get defaults at publish: `{ enabled: true }` for job/webhook/code-trigger keys, always-off for connection-event keys.

| Field            | Type    | Required | Default                                                           | Constraints                                                                    | Description                                                                                                                                                             |
| ---------------- | ------- | -------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`        | boolean | yes      | `true` (schedule/webhook) / `false` (connection-event, immutable) | connection-event keys may never author `true`                                  | A **recommended default**, pre-checked in the configure step — confirmed at deploy, never silently applied. Nothing arms without the installer's explicit confirmation. |
| `displayName`    | string  | no       | the primitive's name, else humanized key                          | —                                                                              | Deploy-screen title for the automation.                                                                                                                                 |
| `description`    | string  | no       | the primitive's description                                       | —                                                                              | What enabling *does* ("Looks for deals needing a follow-up and drafts one.").                                                                                           |
| `editableParams` | array   | no       | —                                                                 | not allowed on connection-event keys; timezone auto-declared on every cron job | Which schedule fields the installer may adjust.                                                                                                                         |

Each entry of `editableParams[]`:

| Field   | Type                                  | Required | Default | Constraints                                                                                                            | Description                                                                                              |
| ------- | ------------------------------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `path`  | `preset.seconds` \| `preset.timezone` | yes      | —       | closed allow-list; `preset.seconds` only on interval presets, `preset.timezone` only on cron; one declaration per path | The schedule field this exposes. Cron *expressions* are never editable — no client-side cron math, ever. |
| `label` | string                                | yes      | —       | non-empty                                                                                                              | Form label ("Check every") — a raw path is never rendered.                                               |
| `help`  | string                                | no       | —       | —                                                                                                                      | Copy under the field.                                                                                    |
| `unit`  | string                                | no       | —       | display only                                                                                                           | e.g. `minutes`.                                                                                          |
| `min`   | number                                | no       | `60`    | ≥ 60 and a multiple of 60 (`preset.seconds`)                                                                           | Lower bound.                                                                                             |
| `max`   | number                                | no       | —       | ≥ `min`                                                                                                                | Upper bound.                                                                                             |
| `step`  | number                                | no       | `60`    | multiple of 60 (`preset.seconds`)                                                                                      | Input step.                                                                                              |

**The whole-minutes constraint.** Interval schedules run on whole minutes only: `seconds` must be ≥ 60 and a multiple of 60, both in the frozen schedule and in any authored envelope — a fractional-minute interval would silently never fire, so publish rejects it. Frozen `once` schedules are rejected too (a frozen instant is stale by construction), and a frozen schedule must satisfy its *own* declared envelope.

**The timezone override.** Cron presets carry an IANA timezone — but a frozen cron carries the *creator's* timezone, so every cron job automatically gets a `preset.timezone` editable param (label "Timezone", defaulting to the installer's profile timezone) even if you don't declare one. A Stockholm-authored 08:00 job must not run at Stockholm time for a Nairobi installer.

```yaml theme={null}
template:
  triggerPresets:
    poll-crm:
      enabled: true
      displayName: Check for new deals
      description: Looks for deals needing a follow-up and drafts one.
      editableParams:
        - path: preset.seconds
          label: Check every
          help: How often the agent looks for new deals.
          unit: minutes
          min: 60
          step: 60
```

**The served `triggers[]`** merges the frozen schedules, your presets, and the declared connection-event triggers into one list:

| Field                         | Type                                                           | Present               | Description                                                                                                                                                                  |
| ----------------------------- | -------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key`                         | string                                                         | always                | The trigger key.                                                                                                                                                             |
| `type`                        | `schedule` \| `webhook` \| `connection-event`                  | always                | Scheduled job, webhook-source code trigger, or connection event.                                                                                                             |
| `displayName` / `description` | string                                                         | always / when set     | Authored preset copy, falling back to the primitive's name/description, then the humanized key.                                                                              |
| `enabled`                     | boolean                                                        | always                | The recommended default. Connection-event entries always serve `false`.                                                                                                      |
| `preset`                      | `{ type: cron \| interval, expression?, seconds?, timezone? }` | schedule only         | The frozen schedule.                                                                                                                                                         |
| `connectionKey`               | string                                                         | connection-event only | The capability the event arrives on.                                                                                                                                         |
| `editableParams`              | array                                                          | when declared         | Verbatim from the authored preset (plus the auto-declared timezone param).                                                                                                   |
| `nextRuns`                    | `{ at, timezone }[]`                                           | schedule only         | The next fire times — **computed at read time by the serving layer**, never authored, so the UI can say "runs 2:00 AM New York — 8:00 AM your time" without doing cron math. |

At install, the deploy request answers this section with `triggerOverrides: { <key>: { enabled?, params? } }` — e.g. `params: { "preset.seconds": 600, "preset.timezone": "Africa/Nairobi" }`.

## declaredTriggers\[]

Connection-event triggers — "when a deal is won" — are **authored declarations** (publish request only; no yaml home in V1), since no frozen primitive can carry them. Each materializes at install as a managed trigger plus a webhook subscription on the bound connection.

| Field              | Type                                | Required | Default | Constraints                                                                                                                                        | Description                                                                                         |
| ------------------ | ----------------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `key`              | string                              | yes      | —       | shares the one trigger key-space (clash-checked)                                                                                                   | Stable identity.                                                                                    |
| `connectionKey`    | string                              | yes      | —       | must be a declared `connections[]` key                                                                                                             | Which capability's bound connection emits the event.                                                |
| `displayName`      | string                              | yes      | —       | non-empty                                                                                                                                          | Deploy-screen title ("When a deal is won").                                                         |
| `description`      | string                              | yes      | —       | non-empty                                                                                                                                          | What enabling does.                                                                                 |
| `event.objectType` | string                              | yes      | —       | provider object vocabulary                                                                                                                         | e.g. `deal`.                                                                                        |
| `event.event`      | `created` \| `updated` \| `deleted` | yes      | —       | —                                                                                                                                                  | The change type.                                                                                    |
| `event.filters`    | object (string → string)            | no       | —       | —                                                                                                                                                  | Field filters, e.g. `{ "stage": "won" }`.                                                           |
| `instruction`      | string                              | yes      | —       | non-empty, ≤ 2000 chars, must not interpolate event-payload fields (the fencing lint); part of the consent surface — changing it forces re-consent | The instruction prepended to the fired agent turn ("A deal was won — send a thank-you follow-up."). |

**The event-support join.** Each declared `{objectType, event}` must be deliverable on **every** platform in the referenced capability's `platforms[]` — checked at publish against the live webhook-event listing, with each `filters` key limited to that event's available filters on that platform. A listing fetch failure fails the publish loudly (`could not verify event support — try again`) — never publish-unverified.

Two fences apply everywhere:

* **Never armed by default.** A connection-event trigger always serves (and materializes) disabled; authoring `enabled: true` in its preset is rejected. The installer must switch it on explicitly.
* **`event` and `instruction` never serve publicly.** The served manifest lifts only `key`, `connectionKey`, `displayName`, `description` (as a `triggers[]` entry with `type: "connection-event"`). Event payloads are untrusted input at runtime and instruction text is consent-surface material — it's shown through consent flows, not the anonymous catalog.

## Lifecycle hooks: onInstall and onUninstall

The two optional **lifecycle hooks** are symmetric: `onInstall` runs right after a successful deploy or install-onto-agent; `onUninstall` runs at the start of an uninstall, **before** any teardown — while the template's skills, connection bindings, and injected trigger URLs all still work. Both take the same shape, with **at least one** of the two fields:

| Field         | Type   | Required                | Default | Constraints                                                                                                                   | Description                                                                                           |
| ------------- | ------ | ----------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `tool`        | string | at least one of the two | —       | the bare name of a tool that exists in **this version's frozen skills** — never live agent state                              | A tool executed **directly** (input `{}`), without an agent turn: the deterministic half of the hook. |
| `instruction` | string | at least one of the two | —       | 1–2000 chars after trimming; plain text — `{{var}}` tokens are rejected (nothing is substituted); part of the consent surface | One background agent turn: the conversational half.                                                   |

```yaml theme={null}
template:
  onInstall:
    tool: setup_github_watch
    instruction: 'Report what setup_github_watch just wired, briefly.'
  onUninstall:
    tool: unwire_github_watch
    instruction: 'Tell the user which webhooks were removed and say goodbye.'
```

**Execution order is tool, then instruction.** The recommended split: **the tool does the work, the instruction is the conversation.** A tool run is deterministic — no model in the loop — executed as the acting user (the installer for `onInstall`, the removing user for `onUninstall`) with the same runtime context as a chat-turn tool call and a 180-second budget, and its outcome (success or failure, with a result summary) is recorded on the install. The instruction fires as one background agent turn, prefixed so the agent knows why it woke:

```
[Install: <template displayName>] <instruction>
[Uninstall: <template displayName>] <instruction>
```

### onInstall semantics

* **Consumer-visible.** Both fields are served on the manifest, so the deploy flow's consent screen can show *"After install, the agent will: …"*. Write the instruction as copy an installer will read.
* **At most once per distinct instruction.** Each install runs a given instruction at most once: re-installing or updating to a version with the *same* instruction never fires a second turn; an update that *changes* the instruction fires again once.
* **Fail-soft.** The install/deploy never waits on the turn and never fails because of the hook; failures are recorded on the install for inspection.
* **Runs as the installer.** User-scoped work inside the hook (an [Inbox push](/api/inbox), a memory deposit) reaches the person who installed.

### onUninstall semantics

* **Runs before teardown.** The hook executes while everything the cleanup needs still exists — the version's skills, the bound connections, the injected `LUA_TRIGGER_URL__*` variables. Use it to undo external state the install created: deregister provider webhooks, close out third-party resources, say goodbye.
* **Cleanup is best-effort and never blocks removal.** The tool run is awaited (within its budget); the instruction turn is given a bounded grace and then teardown proceeds regardless — a slow turn can lose its access mid-flight. If cleanup **must** happen, put it in `tool`, not `instruction`.
* **The outcome is reported.** The uninstall result carries the hook's outcome (`{ ran, ok?, error? }`), and it is recorded on the install before teardown proceeds — a failed cleanup is visible, never silent, but it never fails the uninstall.
* **Runs as the removing user.** The tool executes with the identity of whoever is uninstalling (so their connections resolve); with no real acting user (internal removal paths), the tool is skipped and the turn falls back to a system identity.
* **Consumer-visible.** Served on the manifest so the uninstall confirmation can show *"Before removal, the agent will: …"*.

The full authoring pattern — a setup tool wired to `onInstall` and its inverse wired to `onUninstall` — is walked through in [Publishing Templates](/marketplace/publishing-templates#self-wiring-webhooks).

## envContract and params

The **declare-vs-supply** rule: the manifest declares *which* environment variables the template's code expects — names and metadata, **never values**. Values are supplied per install and stored on the agent.

`envContract` — keyed by variable name, declared with `--env-contract "KEY=description"` (required) or `"KEY?=description"` (optional):

| Field         | Type    | Required | Default | Constraints                                                 | Description                         |
| ------------- | ------- | -------- | ------- | ----------------------------------------------------------- | ----------------------------------- |
| `description` | string  | yes      | —       | —                                                           | What the variable is for.           |
| `required`    | boolean | yes      | —       | missing required values block the install with a named list | Whether the install must supply it. |
| `example`     | string  | no       | —       | never a real value                                          | Illustrative example.               |

`paramsMeta` — the typed display layer over the contract, authored in yaml, keyed by the same names. Keys must be a subset of `envContract`'s — and supplying a `paramsMeta` section at all makes coverage total: every contract key must then carry an entry with a `displayName` (rejection: `<KEY>: user-facing params require displayName`). Key-derived display names appear only on versions published without any `paramsMeta` section (API/legacy publishers), so a served manifest never mixes authored and derived entries:

| Field                                            | Type                                                   | Required | Default                                  | Constraints                                                                                                                                                                         | Description                                                                   |
| ------------------------------------------------ | ------------------------------------------------------ | -------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `displayName`                                    | string                                                 | yes      | —                                        | non-empty                                                                                                                                                                           | Form label.                                                                   |
| `description`                                    | string                                                 | no       | falls back to the contract's description | —                                                                                                                                                                                   | Help copy.                                                                    |
| `type`                                           | `string` \| `number` \| `boolean` \| `enum` \| `model` | yes      | —                                        | env values are stored as strings; the type drives validation and coercion. `model` holds a provider-prefixed model code (`anthropic/claude-sonnet-5`) and renders as a model picker | Input type.                                                                   |
| `default` / `enum` / `placeholder` / `maxLength` | —                                                      | no       | as for persona vars                      | same typed-declaration rules as persona vars; on `model`, `enum` and `maxLength` are rejected and `default` must be a string                                                        | One shared vocabulary — persona vars and params render as one configure form. |

A `model` value is **not** checked against the approved-model catalog at install. An unrecognised code falls back to the platform default at request time rather than failing the install, so an org exclusion added after install can never break a template update. The 256-character cap and the control-character check still apply.

**The served `params[]`** merges both, one entry per contract key:

| Field                                                    | Type    | Description                                                                                       |
| -------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------- |
| `name`                                                   | string  | The env variable name.                                                                            |
| `displayName`                                            | string  | From `paramsMeta`; without it, derived from the key (`CRM_DEFAULT_OWNER` → `Crm default owner`).  |
| `derivedDisplay`                                         | boolean | Present (`true`) only when the display name was key-derived — omitted when authored metadata won. |
| `description`                                            | string  | `paramsMeta` description, falling back to the contract's.                                         |
| `required`                                               | boolean | Always from the contract.                                                                         |
| `type`                                                   | string  | From `paramsMeta`; `string` for derived entries.                                                  |
| `example`, `default`, `enum`, `placeholder`, `maxLength` | —       | Passed through when present.                                                                      |

```yaml theme={null}
template:
  paramsMeta:
    CRM_DEFAULT_OWNER:
      displayName: Default deal owner
      description: Fallback owner when a deal has none.
      type: string
    TONE:
      displayName: Tone of voice
      type: enum
      enum: [formal, casual]
      default: casual
    CLASSIFIER_MODEL:
      displayName: Classifier model
      description: Which model reads and sorts incoming items.
      type: model
      default: anthropic/claude-sonnet-5
```

### The reserved `LUA_TRIGGER_URL__` prefix

Environment keys starting with `LUA_TRIGGER_URL__` are **platform-owned** and cannot appear in an env contract — publish rejects them with `ENV_CONTRACT_RESERVED_PREFIX`. At install, the platform writes one `LUA_TRIGGER_URL__<TRIGGER_KEY>` variable per webhook trigger in the template (the trigger key upper-snaked), holding that install's own trigger URL. The keys are tracked as install-introduced — refreshed if the trigger's token is rotated, overwriting any hand-set value of the same name, and removed at uninstall along with the rest of what the install introduced. See [LuaTrigger — Agents know their own URL](/api/luatrigger#agents-know-their-own-url).

## channels\[] and features\[]

**Declare-only runtime dependencies** — things a deploy can never create but the agent needs to fully go live. Declaring them doesn't bind anything; it drives the honest **post-deploy setup checklist** ("connect WhatsApp to go live") and installer health, instead of a false "live". Authored on the publish request.

`channels[]` — messaging/voice channels the template sends or receives on:

| Field         | Type    | Required | Default | Constraints                                                                                                                                                                                                             | Description                                                                                           |
| ------------- | ------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `channel`     | string  | yes      | —       | one of the known channel vocabulary: `whatsapp`, `instagram`, `facebook`, `webchat`, `slack`, `email`, `front`, `messagebird`, `phone`, `teams`, `rcs`, `imessage`, `telegram`, `google`, `meeting`; unique per version | Channel type the agent depends on.                                                                    |
| `required`    | boolean | yes      | —       | —                                                                                                                                                                                                                       | `true`: the agent can't do its job without it — checklist blocks "fully live". `false`: nice-to-have. |
| `displayName` | string  | no       | —       | ≤ 120 chars, non-empty when present                                                                                                                                                                                     | Checklist row title.                                                                                  |
| `description` | string  | no       | —       | ≤ 2000 chars                                                                                                                                                                                                            | Why the agent needs it.                                                                               |

`features[]` — platform feature flags the agent depends on (e.g. the RAG feature for a docs-answering template):

| Field                         | Type    | Required | Default | Constraints                                                    | Description                        |
| ----------------------------- | ------- | -------- | ------- | -------------------------------------------------------------- | ---------------------------------- |
| `feature`                     | string  | yes      | —       | must exist in the platform feature catalog; unique per version | Feature name.                      |
| `required`                    | boolean | no       | `false` | —                                                              | Absent = informational dependency. |
| `displayName` / `description` | string  | no       | —       | same limits as channels                                        | Checklist copy.                    |

<Warning>
  A WhatsApp declaration deserves a scope note in your description: outbound message-template assets are WABA-scoped and need Meta approval — expect days, not minutes, for outreach beyond the 24-hour window.
</Warning>

## outcomes

The template's countable **units of work** — "follow-ups sent", "meetings booked" — declared at publish (publish request only in V1). Counting is **platform-attested**: a unit is recorded only when the platform itself observes the declared evidence — a matching tool call executed on the install's *bound* connection for the named capability, or a platform-recorded event (message delivery, sandbox commerce writes, completed voice calls). Creator code can cause real side effects but cannot mint a count, evidence on an unbound or declined capability is inert rather than counted, and each recorded unit settles when its window passes without reversal — with verification (sampled LLM-judge review before a unit counts as `verified`) owned entirely by the platform: `verification`, `samplingRate`, and `judge` are rejected as creator fields.

`outcomes.units[]` (max 25 per version):

| Field         | Type                                            | Required                                          | Default | Constraints                              | Description                                                                                                                                                           |
| ------------- | ----------------------------------------------- | ------------------------------------------------- | ------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key`         | string                                          | yes                                               | —       | unique, non-empty; stable identity       | The unit's id, e.g. `followup_sent`.                                                                                                                                  |
| `title`       | string                                          | yes                                               | —       | non-empty                                | Display title ("Follow-ups sent").                                                                                                                                    |
| `description` | string                                          | no                                                | —       | —                                        | What counts as one.                                                                                                                                                   |
| `unitLabel`   | string                                          | no                                                | —       | —                                        | Display noun for one counted unit, e.g. `booking`.                                                                                                                    |
| `match`       | object                                          | at least one of `match` / `events`                | —       | see below                                | Tool-call evidence on a bound connection.                                                                                                                             |
| `events`      | array                                           | at least one of `match` / `events`                | —       | see below                                | Platform-recorded event evidence.                                                                                                                                     |
| `dedupe`      | `thread` \| `occurrence` \| `event` \| `entity` | yes                                               | —       | —                                        | How repeat evidence collapses into counts: one per conversation, per job run, per external event, or per external entity (a batch job posting 20 invoices counts 20). |
| `window`      | string                                          | no                                                | `24h`   | `<n>h` or `<n>d`, between `1h` and `30d` | Settlement window — a recorded unit settles when it passes without reversal.                                                                                          |
| `entityPath`  | string                                          | required for `dedupe: entity` with a tool matcher | —       | dot-path starting `input.` or `result.`  | Where in the tool dispatch the external entity id lives. Optional on other modes (enables entity-precise reversal).                                                   |
| `reversals`   | array                                           | no                                                | —       | see below                                | Evidence that *un-counts* a prior unit.                                                                                                                               |

`match` — tool-call evidence:

| Field           | Type      | Required | Constraints                                       | Description                                                                                                                           |
| --------------- | --------- | -------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `tools`         | string\[] | yes      | at least one; ≤ 128 chars each; `*` wildcard only | **Bare** tool-name globs, matched with the runtime server-name prefix stripped (`crm_update_deal`, `calendar_event_*`).               |
| `connectionKey` | string    | yes      | must be a declared `connections[]` key            | The capability the call must have executed on — the unit records only when the dispatch ran on the install's bound connection for it. |

`events[]` — platform-recorded event evidence (a **closed** vocabulary; anything else is rejected at publish):

| Field     | Type   | Required                    | Constraints                                                                       | Description                    |
| --------- | ------ | --------------------------- | --------------------------------------------------------------------------------- | ------------------------------ |
| `event`   | string | yes                         | one of `message.sent`, `order.created`, `basket.checked_out`, `call.completed`    | The recordable platform event. |
| `channel` | string | required for `message.sent` | delivery-emitting channels only (`whatsapp` today); illegal on non-channel events | The emitting channel.          |

`reversals[]` — each entry needs at least one evidence source:

| Field               | Type    | Required                             | Constraints                                | Description                                                                                             |
| ------------------- | ------- | ------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| `match`             | object  | one of `match` / `onDeliveryFailure` | same shape and rules as the unit's `match` | A tool call that signals the outcome was undone (a deal reopened).                                      |
| `onDeliveryFailure` | boolean | one of `match` / `onDeliveryFailure` | —                                          | Reverse on delivery failure (bounce) of the unit's thread.                                              |
| `entityPath`        | string  | no                                   | `input.` / `result.` path                  | Which entity the reversal targets; absent → the unit's most recent recorded outcome on the same thread. |

<Note>
  Reversals are **platform-owned**, never installer-toggleable — declining a trigger must not silently disable clawback while units keep settling. And the lifecycle counters (`recorded` → `settled` → `verified` / `reversed`) are served as separate tiers, never summed: only `verified` may feed pricing or catalog badges.
</Note>

## Served-manifest-only fields

These exist only in the served manifest — derived or attached at read time, never authored:

| Field                       | Type             | Description                                                                                                                                                                                                     |
| --------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` / `latestVersion` | integer          | The version served / the newest published version (resolved from version records, never a counter).                                                                                                             |
| `contentHash`               | string           | sha256 over the canonical frozen content. Echo it back on install as the **hash-seal**: pre-flight re-verifies the content still matches what you consented to, and answers `409 CONTENT_HASH_MISMATCH` if not. |
| `installable`               | boolean          | The derived install verdict: the version is not deprecated, and — for public templates — approved.                                                                                                              |
| `latestApprovedVersion`     | integer          | Highest approved version; absent when none is.                                                                                                                                                                  |
| `deprecated`                | boolean          | Present (`true`) when this version is deprecated — blocks *new* installs of it only.                                                                                                                            |
| `reconciliation`            | object \| `null` | The per-caller connection reconciliation, pre-computed at read time (below). `null` for anonymous callers.                                                                                                      |
| `policy`                    | object           | Present only when the resolved org's template policy blocks this template: `{ blocked: true, reason, message }` — `reconciliation` stays `null` for a blocked template.                                         |

### The reconciliation block

```json theme={null}
"reconciliation": {
  "summary": { "satisfied": 3, "total": 4 },
  "capabilities": {
    "crm": { "status": "satisfied",
      "connection": { "id": "conn_x", "integrationType": "salesforce", "ownerType": "user" } },
    "calendar": { "status": "multiple-matches",
      "candidates": [
        { "id": "conn_a", "integrationType": "googlecalendar",
          "platformDisplayName": "Google Calendar", "ownerType": "user",
          "accountLabel": "stefan@heylua.ai", "default": true },
        { "id": "conn_b", "integrationType": "googlecalendar",
          "platformDisplayName": "Google Calendar", "ownerType": "org",
          "accountLabel": "Acme (org)", "default": false } ] },
    "email": { "status": "insufficient-scope",
      "connection": { "id": "conn_y", "integrationType": "gmail", "ownerType": "user" },
      "missingScopes": [ { "scope": "email_message_write", "displayName": "Send email" } ] },
    "notify": { "status": "unmet-optional", "connectFrom": ["slack"] }
  }
}
```

Per capability key:

| Field           | Type                                  | Description                                                                                                                                                                                                              |
| --------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `status`        | enum                                  | `satisfied` · `multiple-matches` · `insufficient-scope` · `verify-access` · `unmet-required` · `unmet-optional`.                                                                                                         |
| `connection`    | `{ id?, integrationType, ownerType }` | The matched connection. `id` is redacted on rows the caller may see but not use.                                                                                                                                         |
| `candidates`    | array                                 | On `multiple-matches`: full objects (`id`, `integrationType`, `platformDisplayName`, `ownerType`, `accountLabel?`, `default`) — the picker renders from these fields alone, defaulting per the deploy target's identity. |
| `missingScopes` | array                                 | On `insufficient-scope`: the missing scopes as enriched `{scope, displayName}` objects — never raw tokens.                                                                                                               |
| `connectFrom`   | string\[]                             | On unmet statuses: the platform types the installer may connect.                                                                                                                                                         |
| `reason`        | string                                | Machine-readable detail, e.g. `scope-query-failed` behind `verify-access`.                                                                                                                                               |

Every install-time rejection (a 409 on the deploy exchange) carries a **freshly recomputed** reconciliation block — the UI never needs a blind re-fetch.

***

## A complete example

The authored `template:` section of `lua.skill.yaml` for a Sales Follow-up agent — all four yaml sections:

```yaml theme={null}
template:
  personaTemplate:
    template: |
      You are the sales assistant for {{COMPANY_NAME}}. Chase deals in the
      {{PIPELINE}} pipeline. Never discount past {{MAX_DISCOUNT}}%.
    editable: true                      # installer may tweak the resolved prompt
    vars:
      - name: COMPANY_NAME
        displayName: Your company
        description: Used in the agent's replies and signatures.
        type: string
        required: true
        placeholder: Acme Inc.
      - name: PIPELINE
        displayName: Pipeline to work
        description: Which sales pipeline the agent chases.
        type: string
        default: Enterprise             # optional vars must carry a default
      - name: MAX_DISCOUNT
        displayName: Max discount %
        description: The agent never offers more than this.
        type: number
        default: 15

  connections:
    - key: crm                          # stable identity — rename = remove + add
      capability: crm                   # discovery label; platforms[] decides
      required: true                    # install blocks until satisfied
      displayName: Your CRM
      description: Used to read deals and write follow-up notes.
      platforms:                        # ANY ONE satisfies the capability
        - type: salesforce
        - type: hubspot
      boundSkills: [crm-sync]           # these skills must use capability-level
                                        # access, or publish narrows the list
    - key: notify
      capability: chat
      required: false                   # offered at deploy, never blocking
      displayName: Notifications channel
      description: "Optional: where follow-up alerts get posted."
      platforms:
        - type: slack

  triggerPresets:
    poll-crm:                           # a frozen interval job on the agent
      enabled: true                     # recommended default — confirmed at deploy
      displayName: Check for new deals
      description: Looks for deals needing a follow-up and drafts one.
      editableParams:
        - path: preset.seconds          # closed path list; cron expressions never
          label: Check every            # editable
          help: How often the agent looks for new deals.
          unit: minutes
          min: 60
          step: 60                      # whole minutes only

  paramsMeta:                           # display layer over the env contract
    CRM_DEFAULT_OWNER:                  # declared: --env-contract "CRM_DEFAULT_OWNER=…"
      displayName: Default deal owner
      description: Fallback owner when a deal has none.
      type: string
    TONE:
      displayName: Tone of voice
      type: enum
      enum: [formal, casual]
      default: casual
    CLASSIFIER_MODEL:                   # declared: --env-contract "CLASSIFIER_MODEL?=…"
      displayName: Classifier model
      description: Which model reads and sorts incoming items.
      type: model
      default: anthropic/claude-sonnet-5

  onInstall:                            # consumer-visible: "After install, the agent will: …"
    instruction: >-
      Introduce yourself, then check the CRM connection by listing the three
      most recent open deals and reporting what you found.

  onUninstall:                          # consumer-visible: "Before removal, the agent will: …"
    tool: remove_crm_followup_notes     # deterministic cleanup — a tool from this version's skills
    instruction: >-
      Tell the user the follow-up tracking notes were removed from the CRM
      and say goodbye.
```

The publish-request-only sections for the same template — connection-event triggers, runtime dependencies, and outcomes:

```json theme={null}
{
  "declaredTriggers": [
    { "key": "deal-won", "connectionKey": "crm",
      "displayName": "When a deal is won",
      "description": "Drafts a thank-you follow-up the moment a deal closes.",
      "event": { "objectType": "deal", "event": "updated",
                 "filters": { "stage": "won" } },
      "instruction": "A deal was won — send a thank-you follow-up." }
  ],
  "channels": [
    { "channel": "whatsapp", "required": false,
      "description": "Follow-ups can go out on WhatsApp once a number is connected." }
  ],
  "outcomes": {
    "units": [
      { "key": "followup_sent", "title": "Follow-ups sent",
        "description": "A drafted follow-up approved and delivered to the prospect.",
        "unitLabel": "follow-up",
        "match": { "tools": ["crm_update_deal"], "connectionKey": "crm" },
        "events": [ { "event": "message.sent", "channel": "whatsapp" } ],
        "dedupe": "thread", "window": "24h",
        "reversals": [ { "onDeliveryFailure": true } ] },
      { "key": "deal_advanced", "title": "Deals advanced",
        "description": "A deal moved forward a stage after the agent's follow-up.",
        "match": { "tools": ["crm_update_deal"], "connectionKey": "crm" },
        "dedupe": "entity", "entityPath": "result.dealId", "window": "7d" }
    ]
  }
}
```

And the served manifest an installer's deploy screen reads (excerpt):

```json theme={null}
{
  "id": "9f2c1a8e-…", "name": "sales-followup",
  "displayName": "Sales Follow-up Agent",
  "description": "Chases deals with drafted follow-ups.",
  "version": 3, "latestVersion": 3, "visibility": "public",
  "contentHash": "sha256:…",                       // echo on install: the hash-seal
  "agent": { "model": "anthropic/claude-sonnet-4" },

  "skills": [
    { "key": "crm-sync", "displayName": "CRM sync",
      "description": "Reads deals and writes follow-up notes.", "version": "1.4.0" },
    { "key": "scheduler", "displayName": "Meeting scheduler",
      "description": "Books follow-up calls on your calendar.", "version": "2.0.1",
      "source": "marketplace",
      "marketplaceSkillId": "mskill_abc", "versionId": "mver_123" }
  ],

  "connections": [
    { "key": "crm", "capability": "crm", "required": true,
      "displayName": "Your CRM",
      "description": "Used to read deals and write follow-up notes.",
      "platforms": [                                // enriched from the catalog at read time
        { "type": "salesforce", "name": "Salesforce", "authSupport": "oauth" },
        { "type": "hubspot", "name": "HubSpot", "authSupport": "both" } ],
      "boundSkills": ["crm-sync"] },
    { "key": "notify", "capability": "chat", "required": false,
      "displayName": "Notifications channel",
      "description": "Optional: where follow-up alerts get posted.",
      "platforms": [ { "type": "slack", "name": "Slack", "authSupport": "oauth" } ] }
  ],

  "triggers": [
    { "key": "poll-crm", "type": "schedule",
      "displayName": "Check for new deals",
      "description": "Looks for deals needing a follow-up and drafts one.",
      "enabled": true,                              // recommended default, confirmed at deploy
      "preset": { "type": "interval", "seconds": 300 },
      "editableParams": [
        { "path": "preset.seconds", "label": "Check every",
          "help": "How often the agent looks for new deals.",
          "unit": "minutes", "min": 60, "step": 60 } ],
      "nextRuns": [ { "at": "2026-08-24T14:05:00Z",  // computed at read time
                      "timezone": "Europe/Copenhagen" } ] },
    { "key": "deal-won", "type": "connection-event", "connectionKey": "crm",
      "displayName": "When a deal is won",
      "description": "Drafts a thank-you follow-up the moment a deal closes.",
      "enabled": false }                            // event + instruction never serve
  ],

  "params": [
    { "name": "CRM_DEFAULT_OWNER", "displayName": "Default deal owner",
      "description": "Fallback owner when a deal has none.",
      "required": true, "type": "string" },
    { "name": "TONE", "displayName": "Tone of voice", "required": false,
      "type": "enum", "enum": ["formal", "casual"], "default": "casual" }
  ],

  "personaTemplate": {
    "template": "You are the sales assistant for {{COMPANY_NAME}}. …",
    "vars": [ { "name": "COMPANY_NAME", "displayName": "Your company",
                "description": "Used in the agent's replies and signatures.",
                "type": "string", "required": true, "placeholder": "Acme Inc.",
                "maxLength": 256 } ],
    "editable": true
  },

  "channels": [
    { "channel": "whatsapp", "required": false,
      "description": "Follow-ups can go out on WhatsApp once a number is connected." }
  ],

  "onInstall": {
    "instruction": "Introduce yourself, then check the CRM connection by listing the three most recent open deals and reporting what you found."
  },
  "onUninstall": {
    "tool": "remove_crm_followup_notes",
    "instruction": "Tell the user the follow-up tracking notes were removed from the CRM and say goodbye."
  },

  "latestApprovedVersion": 3,
  "installable": true,
  "reconciliation": { "summary": { "satisfied": 1, "total": 2 },
                      "capabilities": { "…": "see the reconciliation block above" } }
}
```

***

## What the lints check

Publish is the quality gate: every violation is collected and returned in **one** response with field-path messages, and a rejected publish burns no version number. What you'll see, by field:

| Field                                                                             | Rejection you'll see                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| any frozen string field (skill code, job prompts, descriptions, env descriptions) | `SECRET_DETECTED: <pattern-name> in <kind>/<key>` — a hardcoded credential pattern; the location is named, never the value. Declare it in the env contract instead.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| any frozen code pointer                                                           | `CODE_BLOB_MISSING: <kind>/<key> references <hash>` — frozen code that could not be hydrated at apply time.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `jobs[].schedule`                                                                 | `SCHEDULE_INVALID:` — a `once` schedule (a frozen instant is stale by construction); interval seconds not a whole number of minutes ≥ 60; a cron that doesn't compile (5 fields required).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `triggerPresets.<key>`                                                            | `TRIGGER_PRESET_INVALID:` — no such trigger in this version; `enabled: true` on a connection-event key; `editableParams` on a connection-event key.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `triggerPresets.<key>.editableParams[]`                                           | `TRIGGER_PRESET_INVALID:` — a `path` outside `preset.seconds` / `preset.timezone`; duplicate path; missing `label`; `preset.seconds` on a non-interval (or `preset.timezone` on a non-cron) schedule; `min`/`step` not multiples of 60; `max < min`; the frozen schedule violating its own declared envelope.                                                                                                                                                                                                                                                                                                                                                                                                       |
| `personaTemplate.template`                                                        | `PERSONA_TEMPLATE_INVALID:` — a `{{TOKEN}}` with no `vars[]` entry; fewer than 40 literal author characters beside the slots.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `personaTemplate.vars[]`                                                          | `PERSONA_TEMPLATE_INVALID:` — a var appearing in no branch; a duplicate var name; a reserved name (`persona`); an invalid name; missing `displayName`/`description`; a name colliding with a declared param; `enum` empty/duplicated or on a non-enum type; a `default` not matching its type; a non-required var without a `default`; `maxLength` outside 1–2000 or on a non-string.                                                                                                                                                                                                                                                                                                                               |
| `paramsMeta.<key>`                                                                | `PERSONA_TEMPLATE_INVALID:` — no matching `envContract` entry; missing `displayName`; the same typed-declaration self-consistency rules as vars.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `connections[]`                                                                   | `CONNECTION_DECLARATION_INVALID:` — empty/duplicate `key`; missing `displayName`/`description`; non-boolean `required`; invalid `ownerType`; empty `platforms` or duplicate platform types; a `boundSkills` entry naming no skill in the version; scope declarations while scope capture is disabled.                                                                                                                                                                                                                                                                                                                                                                                                               |
| `connections[].platforms` (multi-platform)                                        | the portability lint: `Skill '<key>' calls platform-native tools ('<match>') — narrow connections['<key>'].platforms to that platform or migrate the skill to useCapability('<key>')`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `declaredTriggers[]`                                                              | field-path errors — key clashes with the version's trigger key-space; missing `displayName`/`description`/`instruction`; `instruction` over 2000 characters or interpolating event-payload fields; `connectionKey` naming no declared connection; an event not deliverable on every declared platform, or a filter that platform doesn't offer.                                                                                                                                                                                                                                                                                                                                                                     |
| `channels[]` / `features[]`                                                       | field-path errors — unknown channel/feature name (the known vocabulary is listed in the message); duplicates; non-boolean `required`; `displayName` over 120 or `description` over 2000 characters.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `outcomes`                                                                        | field-path errors (`outcomes.units[2].window: …`; evidence-attribution failures carry `OUTCOMES_MANIFEST_INVALID:`) — `verification`/`samplingRate`/`judge` present (platform-owned); more than 25 units; missing/duplicate unit `key` or missing `title`; an invalid `dedupe` mode; `window` outside `1h`–`30d` or malformed; a unit with neither `match` nor `events`; empty or over-long (128+ char) tool globs; a `connectionKey` that isn't a declared connection; an `event` outside the recordable vocabulary; a missing/wrong `channel` on `message.sent`; duplicate event evidence; an `entityPath` not starting `input.`/`result.` (or missing for `dedupe: entity`); a reversal with no evidence source. |
| `onInstall` / `onUninstall`                                                       | `ON_INSTALL_INVALID:` / `ON_UNINSTALL_INVALID:` — not an object; a field other than `tool` or `instruction`; neither field present; a `tool` that is empty or names no tool in this version's frozen skills; an empty or over-2000-character instruction; `{{var}}` tokens in the instruction (nothing is substituted — write plain text).                                                                                                                                                                                                                                                                                                                                                                          |
| `envContract` keys                                                                | `ENV_CONTRACT_RESERVED_PREFIX:` — a contract key under the reserved `LUA_TRIGGER_URL__` prefix (platform-written per webhook trigger at install; the installer could never supply it).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| source agent with voices or device triggers                                       | `UNSUPPORTED_SOURCE:` — templates don't freeze voice or device surfaces in V1; the error names each surface and says to remove it from the source agent before publishing.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |

## Related

* [Agent Templates](/marketplace/agent-templates) — the model: what a template contains, lifecycle, consent
* [Publishing Templates](/marketplace/publishing-templates) — the authoring journey this reference backs
* [Deploying Templates](/marketplace/deploying-templates) — how installers experience each section
* [Marketplace Command reference](/cli/marketplace-command)
