Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .agents/skills/author-custom-proposal-template/SKILL.md
Comment thread
khorolets marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
name: author-custom-proposal-template
description: Author or edit a trezu custom-proposal template manifest — the JSON DSL that renders a form members fill to file a SputnikDAO FunctionCall proposal. Use when writing a manifest, wiring fields to args with {{placeholders}}, picking field types, or fixing "must be a tag-safe slug" / "references a {{placeholder}} that no field declares" errors.
---

# Authoring a custom-proposal template manifest

A manifest is JSON that defines a form. Members fill it; trezu files one SputnikDAO
`FunctionCall` proposal from it. Full reference: `docs/CUSTOM_PROPOSAL_TEMPLATES.md`.
Validator: `nt-fe/features/proposal-templates/manifest.ts` (`parseManifest`) — produce
manifests that pass it.

## Shape

```json
{
"version": 1,
"id": "guestbook-tip", // tag-safe slug [A-Za-z0-9_-], unique per DAO,
// NOT create/new/about
"title": "Guestbook Tip",
"summary": "Tip {{amount}}", // optional; may use {{fields}}
"binding": { // the fixed on-chain call
"receiver_id": "guestbook.near",
"method_name": "add_message",
"deposit": "1", // yoctoNEAR, INTEGER STRING
"gas": "30000000000000" // INTEGER STRING
},
"fields": [ // the form inputs
{ "name": "amount", "label": "Amount", "type": "uint", "required": true }
],
"args": { "amount": "{{amount}}" } // method args; {{name}} -> field value
}
```

## Rules that trip authors up

- **`id`**: `[A-Za-z0-9_-]` only, and not a reserved route slug (`create`/`new`/`about`).
- **field `name`**: `[A-Za-z0-9_]` only (placeholder-safe), unique within the manifest.
- **`deposit`/`gas`/`validation.min`/`max` and `uint`/`amount` values are integer
strings** in base units — no decimals, never JSON numbers (NEAR amounts exceed 2^53).
`1.1 NEAR` is `"1100000000000000000000000"`.
- **Every `{{placeholder}}` in `args` or `summary` must reference a declared field**, or
parse fails ("references a {{placeholder}} that no field declares").
- `required` is invalid on a `bool` field. `options` is required on `select`, forbidden
elsewhere. A `default` must match the field `type`. `validation.pattern` only on
`text`/`number`; `min`/`max` only on `uint`/`amount`/`number`.

## Field types

`account` · `token` (omni id like `base-0x…`) · `uint` · `amount` (both u128 integer
strings) · `number` (f64, for counts/ratios, not amounts) · `text` · `select` (needs
`options`) · `bool` · `json`.

## Wiring fields into args

- **Direct value**: `"amount": "{{amount}}"`.
- **Composed / concatenated**: `"recipient": "{{first}}.{{last}}"` → `"alice.near"`.
- **Field inside a JSON-string arg**: `"msg": "{\"receiver_id\":\"{{receiver}}\"}"`.
- **Static value**: a literal with no placeholder (`"app": "trezu"`).
- An input you collect but don't send anywhere (no arg, no summary) is valid but rare —
use only for acknowledgment toggles or forward-compat.

## Output

Emit the manifest JSON, then sanity-check it against the rules above (or `parseManifest`
if you can run it). For a worked non-trivial example, see the doc.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ All coding guidelines, testing practices, and project conventions are documented

- **Subscription System (AI Guide):** [docs/AI_SUBSCRIPTION_GUIDE.md](docs/AI_SUBSCRIPTION_GUIDE.md) - Architecture, key files, payment flows, fee calculations, and implementation details
- **Pricing Reference:** [docs/PRICING.md](docs/PRICING.md) - Plan tiers, features, and database schema
- **Custom Proposal Templates:** [docs/CUSTOM_PROPOSAL_TEMPLATES.md](docs/CUSTOM_PROPOSAL_TEMPLATES.md) - the manifest DSL, architecture, args-first authoring, and ChangePolicy gating. An authoring agent skill lives at [.claude/skills/author-custom-proposal-template](.claude/skills/author-custom-proposal-template/SKILL.md)
218 changes: 218 additions & 0 deletions docs/CUSTOM_PROPOSAL_TEMPLATES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# Custom proposal templates

trezu creates the everyday treasury proposals — payments, transfers, staking — natively,
but a SputnikDAO can call **any method on any contract**. trezu could already *display*
those proposals; it just had no way to *create* them. **Custom proposal templates** close
that gap: a DAO authors a reusable form once, and members fill it to file an arbitrary
SputnikDAO **`FunctionCall`** proposal — no hand-written base64 args, no developer needed.

The form definition is a JSON **manifest** (the DSL described below). The proposal a
template produces still passes the DAO's normal permissions and approvals, so a manifest
never grants authority by itself.

This doc is the canonical reference for the manifest DSL, the architecture, and the
authoring UI. The in-app `/<dao>/custom-templates/about` page and the
`author-custom-proposal-template` agent skill both derive from it.

## What it produces

Filling a template builds one SputnikDAO `FunctionCall` proposal:

```
receiver_id: <binding.receiver_id>
actions: [ FunctionCall {
method_name: <binding.method_name>,
args: <args with every {{placeholder}} substituted, base64-encoded>,
deposit: <binding.deposit>, // yoctoNEAR
gas: <binding.gas>,
} ]
description: "<title>\n\n[trezu-tmpl:<id>]" // the tag is the provenance marker
```

Member-supplied values flow only into `args` (and the human-readable `summary`); the
contract, method, deposit, and gas are fixed by the template author.

## The manifest DSL

A manifest is a JSON object. `version` is `1` (the only shape this schema describes).

| Key | Required | Notes |
|-----|----------|-------|
| `version` | yes | literal `1` |
| `id` | yes | tag-safe slug `[A-Za-z0-9_-]`, unique per DAO. The page URL (`/custom-templates/<id>`) **and** the `[trezu-tmpl:<id>]` description tag. Reserved: `create`, `new`, `about`. |
| `title` | yes | non-blank |
| `description` | no | non-blank if present |
| `icon` | no | non-blank if present |
| `summary` | no | human one-liner shown while filing; may use `{{placeholders}}` |
| `binding` | yes | the fixed on-chain call (below) |
| `fields` | yes | array of form inputs (below) |
| `args` | yes | the call arguments — a JSON object; string values may embed `{{placeholders}}` |

### `binding` — the on-chain call

```json
"binding": {
"receiver_id": "guestbook.near", // the contract
"method_name": "add_message", // the method
"deposit": "1", // yoctoNEAR, integer string (u128-safe)
"gas": "30000000000000" // gas, integer string
}
```

`deposit`/`gas` are **integer strings** in base units — there are no decimals at the
contract boundary (1.1 NEAR = `1100000000000000000000000` yocto). They exceed 2^53, so
they must be strings, never JSON numbers.

### `fields` — the form inputs

Each field is `{ name, label, type, ... }`:

| Field key | Notes |
|-----------|-------|
| `name` | `[A-Za-z0-9_]` — must be `{{placeholder}}`-safe; unique within the manifest |
| `label` | non-blank; shown above the input |
| `type` | one of the types below |
| `required` | optional bool; **not allowed on `bool`** (a toggle always submits) |
| `default` | optional; must match `type` (string for text-ish, number for `number`, boolean for `bool`, an option for `select`, any JSON for `json`) |
| `help` | optional; shown under the input |
| `options` | required for `select`, forbidden otherwise; array of strings |
| `validation` | optional `{ min, max, pattern }` (below) |

Types:

| `type` | Renders / validates as |
|--------|------------------------|
| `account` | a NEAR account id (validated shape) |
| `token` | an omni token id, e.g. `base-0x…` (free text) |
| `uint` | a whole-number string, u128-safe |
| `amount` | a whole-number string, u128-safe (base units — no decimals) |
| `number` | a numeric value (JS number / f64 — for counts/ratios, **not** amounts) |
| `text` | free text |
| `select` | dropdown of `options` |
| `bool` | a toggle |
| `json` | JSON text |

`validation`:
- `min` / `max` — integer strings; only on numeric types (`uint`/`amount`/`number`).
- `pattern` — a regular expression; only on `text` / `number`. Must compile.

> u128 safety: `deposit`/`gas`/`validation.min`/`max` and `uint`/`amount` field values
> stay digit strings end-to-end, never JS numbers, so NEAR amounts (which exceed 2^53)
> survive untruncated.

### `args` — the call arguments

`args` is a JSON object — the literal method arguments. Each string value may contain
`{{name}}` placeholders that reference a declared field by `name`. At fill time the
engine substitutes each placeholder with the member's value:

```json
"args": {
"app": "trezu", // static
"text": "{{message}}", // direct member value
"amount": "{{tip}}", // direct member value (u128)
"meta": "{\"by\":\"{{author}}\"}" // composed: a field inside a string
}
```

A value need not be a string. `args` may hold any JSON — numbers, booleans, `null`,
nested objects, arrays (the visual builder offers these as static value types). Non-string
values are sent to the contract **verbatim**; `{{placeholders}}` are only ever resolved
inside string values, so a number/bool/object passes through exactly as written.

Rules:
- **Every `{{placeholder}}` must reference a declared field.** A dangling reference is
a validation error (attributed to `args` or `summary`).
- An escaped `{{{{literal}}}}` collapses to a literal `{{literal}}` and is never treated
as a placeholder.
- Concatenation / composition is just multiple placeholders in one string
(`"{{first}}.{{last}}"` → `"alice.near"`).
- Amounts stay digit strings, so u128 values never lose precision.

### A minimal example

```json
{
"version": 1,
"id": "set-greeting",
"title": "Set Greeting",
"binding": {
"receiver_id": "guestbook.near",
"method_name": "set_greeting",
"deposit": "0",
"gas": "30000000000000"
},
"fields": [
{ "name": "greeting", "label": "Greeting", "type": "text", "required": true }
],
"args": { "greeting": "{{greeting}}" },
"summary": "Set greeting to {{greeting}}"
}
```

## Authoring (the UI)

`/<dao>/custom-templates/create` (and `…/<slug>/edit`) offer **Visual** and **Code**
modes over the same manifest; both validate through the one `parseManifest`.

### Visual mode is args-first

The call is the unit. Each **argument** is either:

- **Static** — a fixed value (text/number/bool/null/object/array; text may embed
`{{field}}`), or
- **Member input (dynamic)** — its value becomes `{{key}}` and the row expands to that
input's config inline (label, type, required, help, validation). The input's **name
is the argument key**; renaming the key renames the input.

Inputs are **derived from placeholders**: anything you reference (`{{x}}` in a static
value or in `summary`) auto-creates input `x`. Inputs referenced only inside a composed
value — or added manually — appear under **Other inputs**; an input no argument
references is flagged **Unused** (collected but not sent — used for acknowledgment
toggles or forward-compat).

### Code mode

A JSON textarea over the same manifest, with live validation. Useful for paste and
power edits. Switching Code → Visual needs only valid JSON (a partial manifest hydrates
with blanks); Visual → Code serializes the draft back.

## Build one with an AI assistant

Non-developers can generate a manifest by describing the proposal in plain English. The
self-contained, distributable skill at
[`skills/trezu-custom-proposal-template/`](../skills/trezu-custom-proposal-template/SKILL.md)
installs into Claude Code / Claude.ai / Codex (see its `README`) and produces a manifest
to paste into the **Code** tab — it needs no repo access. (The `.claude/skills/` skill,
by contrast, is for agents working *on* the trezu codebase.)

## Permissions

- **Authoring** (create / update / delete a template) is gated on the DAO's on-chain
**`ChangePolicy`** permission — the governance bar, not mere membership.
- **Listing / filling** a template is gated on **membership**.
- Filing the proposal still goes through the DAO's normal `add_proposal` →
approvals → execution. A template grants no authority.

## Where the code lives

Backend (`nt-be`):
- `src/handlers/proposal_templates.rs` — CRUD at `/api/treasury/{dao_id}/proposal-templates`,
`validate_manifest` (structural + slug/reserved checks), ChangePolicy-gated writes.
- Migration: `proposal_templates` table with a `manifest_id` generated column
(`manifest->>'id'`), unique per DAO.

Frontend (`nt-fe/features/proposal-templates/`):
- `manifest.ts` — the zod schema + `parseManifest`, `manifestPlaceholders`,
`substitutePlaceholders`, `manifestIdOf`, reserved slugs.
- `build-proposal.ts` — the engine: manifest + values → `{ kind, description }`.
- `form-schema.ts` — manifest → react-hook-form zod schema (the fill form).
- `components/manifest-form.tsx` — the fill-form renderer.
- `draft.ts` — the visual builder's editable model (`ManifestDraft` ↔ manifest,
`normalizeFields`, the `ArgNode` args tree).
- `args-node.ts`, `error-map.ts` — args value-type helpers; per-input error routing.
- `components/{template-editor,visual-builder,fields-builder,args-tree-editor}.tsx` —
the Code/Visual authoring UI.

Routes (`nt-fe/app/(treasury)/[treasuryId]/custom-templates/`):
`index` · `[slug]` (fill + file) · `create` · `[slug]/edit` · `about` (in-app DSL docs).

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading