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

# Publishing Templates

> Turn a working agent into a template: draft, author, publish, review, and roll out updates

## From agent to template

You never hand-write a template manifest. You build a real agent, and the platform does the heavy lifting: publishing **freezes every primitive** from your promoted agent version automatically, and a **draft composer** infers the rest — connections, persona variables, trigger presets — into your project's `lua.skill.yaml` for you to review like a diff. Your job is the product decisions: which connections are required, what the installer-facing copy says, which automations should be on by default.

This guide follows one agent end to end: **Standup Sidekick**, a small agent that logs the wins a user mentions during the day (`log_win` / `list_wins` tools in a `standup-skill`), nudges them before standup with a scheduled `standup-nudge` job, and has a persona ("You are the Standup Sidekick for Acme Demo Co…").

<Note>
  New to templates? Read [Agent Templates](/marketplace/agent-templates) first for the model — what a template contains, lifecycle, and consent. This page is the hands-on authoring journey.
</Note>

## Step 1 — Build and promote the source agent

Nothing template-specific yet: build Standup Sidekick the normal way.

```bash theme={null}
lua push          # upload skills, jobs, persona
lua version promote <n>
```

Use it until you're happy with it. The promoted version is what publishing will freeze.

## Step 2 — Create the template listing

Run this from the project connected to your source agent:

```bash theme={null}
lua marketplace template create \
  --name standup-sidekick \
  --display-name "Standup Sidekick" \
  --description "Captures your wins during the day and has them ready for standup."
```

Templates are **private by default** — only your organization can find and install them. Add `--visibility public` to head for the public catalog (which adds a [review step](#step-6-visibility-and-review), below).

## Step 3 — Run the draft composer

```bash theme={null}
lua marketplace template draft --template-id tpl_standup
```

The draft composer writes a `template:` section into your `lua.skill.yaml`, composed from what your agent actually is:

| Section                                 | How it's composed                                                                                                                                                                                                                                             |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Skills, webhooks, jobs, triggers, model | Frozen automatically from your promoted agent version at publish — the composer doesn't need to draft these at all                                                                                                                                            |
| `connections`                           | **Inferred** from how your skill code reaches integrations, your webhook event subscriptions, and the connections live on your agent — with sibling platforms proposed for the allow-list and `required` proposed where a skill depends on it unconditionally |
| `personaTemplate`                       | **Detected**: `{{VAR}}` tokens in your persona are extracted into declared variables, with drafted display names and descriptions; hardcoded specifics (a company name) may be proposed for promotion into variables                                          |
| `triggerPresets`                        | **Derived** from your jobs: the schedule verbatim, `enabled` matching each job's current state on your agent, editable-parameter envelopes pre-filled with valid bounds                                                                                       |
| `paramsMeta`                            | Lifted from your env-contract metadata, with types and defaults proposed from examples — `string`, `number` or `boolean` only; `enum` and `model` are authoring decisions the composer never infers                                                           |

Re-running `draft` later (say, after adding a skill for v2) performs an **additive merge**: newly inferred entries are appended, your existing keys are never modified, and the CLI prints a per-section diff of what it added versus kept. A full overwrite of your authored sections requires an explicit `--force`. Use `--source-version <n>` to compose from a specific promoted agent version instead of the active one.

<Tip>
  Inference proposes; you dispose. Required-versus-optional, the platform allow-list, and every installer-facing string are product decisions the composer can only draft. Review the section like any diff before publishing.
</Tip>

## Step 4 — Edit the `template:` section

Here's a realistic authored section for Standup Sidekick after the composer ran and the creator polished it. The persona got two variables, the nudge job's interval became installer-editable, and an optional chat connection was declared for posting nudges:

```yaml theme={null}
template:
  personaTemplate:
    template: |
      You are the Standup Sidekick for {{COMPANY_NAME}}.

      You quietly capture wins your user mentions during the day and keep
      them ready for standup. Tone: {{TONE}}, zero fluff. Never invent wins.
    editable: true            # installers may tweak the resolved prompt at deploy
    vars:
      - name: COMPANY_NAME
        displayName: Your company
        description: Used in the agent's replies and signatures.
        type: string
        required: true
        placeholder: Acme Inc.
      - name: TONE
        displayName: Tone of voice
        description: How the agent phrases its nudges and summaries.
        type: enum
        enum: [brief and encouraging, strictly factual]
        default: brief and encouraging

  connections:
    - key: notify
      capability: chat
      required: false          # offered at deploy, never blocking
      displayName: Notifications channel
      description: "Optional: where standup nudges get posted."
      platforms:
        - type: slack
          name: Slack

  triggerPresets:
    standup-nudge:
      enabled: true            # recommended default — confirmed at deploy, never silent
      editableParams:
        - path: preset.seconds
          label: Nudge every
          help: How often the agent checks for unsummarized wins.
          unit: minutes
          min: 60
          step: 60             # whole minutes only

  paramsMeta:
    STANDUP_HOUR:
      displayName: Standup hour
      description: Local hour of your team's standup, 0–23.
      type: number
      default: 9
```

A few rules worth knowing while you edit:

* **Every installer-facing entry needs `displayName` and `description`.** The deploy screen renders its copy straight from this section — a raw key is never shown as a form label, and publish rejects entries missing them.
* **Keys are identity.** A connection `key`, trigger key, param name, or persona var name is its stable identity across versions — renaming one means "remove + add", which costs installers their setting for it. Keep keys stable across versions.
* **The whole section is serialized on every publish.** When a `template:` section exists, all five subsections (`connections`, `personaTemplate`, `triggerPresets`, `paramsMeta`, `onInstall`) are always sent — an empty or deleted subsection is an explicit *clear*, not "keep the previous version's". Clearing or narrowing anything prompts you with the consequence before publishing (see below).
* **The env contract is declared at publish**, via the `--env-contract` flag — `paramsMeta` carries the display metadata for those variables:

```bash theme={null}
--env-contract "STANDUP_HOUR?=Local hour of the team standup"
```

Every attribute you can author here — with types, defaults, constraints, and the exact lint each one is guarded by — is in the [Template Manifest Reference](/marketplace/template-manifest).

### Lifecycle hooks: `onInstall` and `onUninstall`

A template can declare what its agent does **immediately after being installed** — and what it does to clean up **just before being removed**:

```yaml theme={null}
template:
  onInstall:
    tool: setup_notifications          # deterministic: runs directly, no model in the loop
    instruction: >-
      Introduce yourself and report which channel the notifications
      self-check just posted to.
  onUninstall:
    tool: remove_notification_hooks    # inverse of the setup — undo external state
    instruction: >-
      Tell the user the notification hooks were removed and say goodbye.
```

Each hook takes `tool` and/or `instruction` (at least one), and they run **tool first, then instruction**:

* **`tool`** names a tool from the template's own skills, executed **directly** with empty input — no agent turn, no model — as the installing (or removing) user, with a 180-second budget. Its outcome is recorded on the install. This is the deterministic half: wiring things up, tearing things down.
* **`instruction`** fires one background agent turn, delivered as the agent's message with the lifecycle marker: `[Install: Standup Sidekick] …` or `[Uninstall: Standup Sidekick] …`. This is the conversational half: greeting, reporting, saying goodbye.

**The recommended split: the tool does the work, the instruction is the conversation.** A tool run can't wander off-script, is awaited, and reports success or failure; an agent turn is the right place for the human-facing summary of what just happened — not for cleanup that must not be skipped.

The authoring rules:

* **Both hooks are consent-surface copy.** They're served verbatim on the manifest: the deploy screen shows *"After install, the agent will: …"* and the uninstall confirmation shows *"Before removal, the agent will: …"*. Write instructions for the installer to read — plain text, 1–2000 characters, no `{{var}}` tokens (nothing is substituted). A `tool` must name a tool that exists in the version's frozen skills; publish rejects a name that doesn't resolve.
* **`onInstall` fires at most once per distinct instruction.** Repeat installs and updates that keep the instruction unchanged never re-fire it; changing the instruction in a new version fires it once more on update.
* **`onUninstall` runs before teardown, and never blocks it.** Your cleanup gets the live agent — skills, bound connections, injected trigger URLs all still work. The tool run is awaited; the instruction turn gets a bounded grace before teardown proceeds regardless. Cleanup failure is recorded and reported in the uninstall result, but an uninstall **never** fails because cleanup did — design `onUninstall` as best-effort, and put anything that must happen in the `tool`.
* **Fail-soft, background, as the acting user.** Installs never wait on (or fail because of) `onInstall`; both hooks run with the acting user's identity — an [Inbox push](/api/inbox) inside one reaches the installer's (or remover's) own desk. Make both tools **idempotent**: a re-fired hook or a retried run must be safe.

### Self-wiring webhooks

The best template installs ask for **nothing**: no URL pasting, no provider settings screens. Two shipped pieces make that possible for webhook-driven templates:

1. **The platform injects each install's trigger URLs into the agent's env.** Every webhook trigger in your template gets its per-install URL written as `LUA_TRIGGER_URL__<TRIGGER_KEY>` (the trigger key upper-snaked — `github-pr-assigned` becomes `LUA_TRIGGER_URL__GITHUB_PR_ASSIGNED`), readable with [`env()`](/api/environment) from tools and trigger slots, kept fresh across token rotation, and removed at uninstall. See [LuaTrigger — Agents know their own URL](/api/luatrigger#agents-know-their-own-url).
2. **`onInstall` runs your setup tool on install.** Declare the tool in the hook so the platform executes it deterministically the moment the install completes, and pair it with an instruction so the agent reports the outcome.

A PR-review template wires GitHub this way — a `setup_github_watch` tool that discovers every repo the connected GitHub account administers and creates `pull_request` webhooks pointing at the injected URL:

```typescript theme={null}
// tools/SetupGithubWatch.ts (sketch)
import { LuaTool, env } from 'lua-cli';
import { z } from 'zod';

const TRIGGER_URL_ENV = 'LUA_TRIGGER_URL__GITHUB_PR_ASSIGNED';

export class SetupGithubWatchTool implements LuaTool {
  name = 'setup_github_watch';
  description = 'Wire pull_request webhooks on every repo the connected account administers.';
  inputSchema = z.object({});

  async execute() {
    const triggerUrl = env(TRIGGER_URL_ENV)?.trim();
    if (!triggerUrl) {
      return { ok: false, message: `${TRIGGER_URL_ENV} is not set — was this agent installed from a template?` };
    }
    // Idempotent: an existing hook with the same URL counts as already wired.
    return wireWebhooks(triggerUrl);
  }
}
```

```yaml theme={null}
template:
  onInstall:
    tool: setup_github_watch
    instruction: >-
      Tell the user which orgs and repos setup_github_watch just wired, and
      that a daily job keeps discovery current.
  onUninstall:
    tool: unwire_github_watch          # deletes the hooks the install created
    instruction: >-
      Tell the user the GitHub webhooks were removed.
```

The installer's whole experience: connect GitHub, confirm, and the agent's first message reports which repos it is now watching — and on uninstall, the hooks are deregistered before the install's trigger URLs stop existing. Make the setup tool **idempotent** (safe to re-run) and have it read the URL from the env at call time, never cache it (the platform rewrites the variable if the trigger's token is rotated).

## Step 5 — Publish

```bash theme={null}
lua marketplace template publish \
  --template-id tpl_standup \
  --changelog "v1: win capture, standup nudges, Slack notifications (optional)"
```

Publishing freezes your agent's active promoted version plus the authored `template:` section into an immutable, integer-numbered template version (v1, v2, v3 — not semver). Use `--source-version <n>` to freeze a specific promoted version instead of the active one.

If this publish **clears or narrows** previously authored sections (you removed a declared connection, dropped a persona var), the CLI prints the consequence per section and asks for confirmation. `--yes` auto-confirms *only* that consequence prompt — useful in CI — without skipping any other confirmation the way `--force` does.

### The lint battery

Publish is the quality gate: a version that would fail on an installer's agent — or render an unusable deploy screen — is rejected **at publish**, with actionable errors, and a rejected publish burns no version number. Among what it checks:

* **Schedules must actually be schedulable.** Interval schedules run on whole minutes only — a fractional-minute interval would silently never fire, so it's rejected up front. Publishing Standup Sidekick with `seconds: 90` fails with:

  ```
  ✖ Publish rejected

  triggerPresets.standup-nudge → preset.seconds: 90 is not a whole number
  of minutes. Interval schedules must be a multiple of 60 (minimum 60).
  Did you mean 60 or 120?
  ```

* **Persona variables must be declared, both ways.** Every `{{VAR}}` in the persona template needs a matching `vars` entry, and vice versa — across every persona branch, including voice text. Reserved names (like `persona`) are rejected.

* **Declarations must be self-consistent.** Enum lists non-empty, defaults satisfying their own type, every `editableParams.path` resolving into its trigger entry, optional vars carrying a default.

* **Connections must be real.** Every platform in an allow-list must exist in the integration catalog and match the declared capability; OAuth platforms must declare their scopes (that's what the installer consents to).

* **Portability must be authored, not hoped.** A capability may list multiple platforms only when the skills bound to it reach the connection through the capability, not through platform-specific calls — otherwise the allow-list is narrowed to the platform the code actually uses.

* **No secrets in frozen code.** A hardcoded API key in skill source is caught and rejected, pointing at the file and line, with the fix: declare it in the env contract so each installer supplies their own.

* **No voice yet.** A source agent with voices or device triggers can't publish — voice templates aren't supported in V1, and the error says so rather than shipping a template that deploys half-working.

## Step 6 — Visibility and review

Private templates are installable by your org the moment they're published — this is the fleet path, and it needs no review.

Going **public** adds platform review, and review is **per version**:

* Flipping a private template public enqueues its latest version for review; it lists publicly once approved. Older published versions stay org-only unless republished.
* Every version you publish while public enters review, and isn't installable by others — including through the update badge — until approved.
* **Your own self-test installs are exempt**: you can install a pending version onto your org's agents to verify it before anyone else can.
* A rejection leaves your prior approved versions listed. Versions are immutable — resubmitting means publishing a new version with the fix.

## Step 7 — Versioning and consent

Two kinds of change behave differently when you ship v-next, and it pays to know which you're making:

* **Code-only changes** — you rewrote a tool's implementation, fixed a bug, improved a prompt inside a skill — flow to opted-in installs (`allowCreatorUpdates`) under their standing consent. This is the everyday fleet-update path.
* **Consent-surface changes** — a widened OAuth scope, an added or changed connection, a changed trigger instruction — require **re-consent from every install**, opted-in included. Your push is rejected per target until the installer reviews and accepts the new surface themselves.

Installers also always get a server-computed **update preview** before accepting: your changelog, what's added/removed/changed, scope deltas, and which of their armed automations survive. Their persona edits and schedule tweaks are preserved across your update — see [what survives an update](/marketplace/deploying-templates#updates) for the installer's side.

## Step 8 — Roll out

```bash theme={null}
lua marketplace template publish \
  --template-id tpl_standup \
  --changelog "v2: weekly summary skill, nudge copy fixes"

# Canary against a couple of agents first
lua marketplace template apply \
  --template-id tpl_standup \
  --agents agent_team_a,agent_team_b \
  --force

# Then the consenting fleet
lua marketplace template apply --template-id tpl_standup --all-installed --force
```

There's no separate canary flag — a canary is the same `apply` aimed at a smaller target list. Check the per-target result table (and the agents themselves) before going wide. Pass `--no-wait` to get the run ID immediately and check on it later with `status`.

What a rollout will and won't do on each target:

* **Won't arm anything new.** Renamed or newly added triggers land disabled, awaiting the installer's confirmation. A trigger the installer paused stays paused.
* **Won't strand targets on a new required env key.** Targets missing it flip to a pending-input state with an installer prompt, rather than shipping a broken agent.
* **Won't overwrite installer customizations silently.** Installer-edited personas are skipped; installer-tuned schedule params are re-applied over your new preset; hand-edits to managed primitives surface an explicit confirm instead of a silent revert.
* **Fails loudly.** Any failed target marks the run `failed` and exits non-zero; successful targets are unaffected and a re-run is safe.

```bash theme={null}
lua marketplace template status --template-id tpl_standup   # who runs which version
```

## Step 9 — Retire

* **Deprecate a version** to block new installs of it — existing installs are unaffected, and the changelog carries the reason. You can't deprecate the only approved version of a still-listed public template; unlist first.
* **Unlist the template** to soft-retire it: no new installs, no more creator applies. Existing installs keep running with what they have, can still uninstall, and can still re-run their same installed version to rebind a connection.

There is no hard delete — unlist + deprecate is the complete retirement path, and it keeps the audit trail and install ledger intact for every agent still running your template.

## Related

* [Agent Templates](/marketplace/agent-templates) — the model: what a template contains, lifecycle, consent
* [Template Manifest Reference](/marketplace/template-manifest) — every attribute: fields, types, defaults, constraints, lints
* [Deploying Templates](/marketplace/deploying-templates) — what your installers experience
* [Marketplace Command reference](/cli/marketplace-command)
* [Publishing Skills](/marketplace/creator-guide) — the single-skill equivalent
