# Module SDK

The reference for making a Sundial module: what one is, the folder and
manifest that define it, what every field does to a workspace and to the
agent, how a module is created from, connected to, listed and removed, and
how to test and ship one. Read it before adding anything under `templates/`.
It is rendered publicly at `/docs/sdk` (raw markdown at `/docs/sdk.md`) and
is the **single source of truth** for this vocabulary: when the code changes,
this page changes in the same PR (`tests/ui/module-sdk-doc.test.ts` fails
when the registry and this page drift).

Written for two readers: a person adding a module by hand, and an agent asked
to "add a module for X". If you are the agent, go straight to §2 and §13.

One word per concept:

| Term | Means | In code |
| --- | --- | --- |
| **Module** | A folder in the repo, `templates/<category>/<slug>/`, holding a manifest and the files it seeds. Also called a template or an assistant: same thing, older names. | `TemplateManifest` in `lib/templates/registry.ts` |
| **Manifest** | `manifest.yaml`: name, category, description, the instructions the agent gets, and the optional extras (first file, intro, setup, buttons, checks). | `parseTemplateManifest` |
| **Files** | Everything under `files/`, seeded verbatim into the workspace. Nested paths kept. | `resolveTemplateFiles` |
| **Bundle** | A folder under `templates/_shared/` that several modules include (LaTeX class files, the Lean installer). Flattened to the workspace root. | `shared_bundles` |
| **Skill** | A file at `skills/<id>/SKILL.md` with a `name` and `description` in frontmatter. The agent lists every skill in the workspace and reads one when it applies. | `agent-ts/src/prompt/skills.ts` |
| **Selection action** | A button over selected text that runs a prompt with a skill, answering in a chat or as a comment thread on the selection. | `selection_actions`, `lib/assistants/selection-actions.ts` |
| **Verifier** | A registered check (compile, Lean, claim, audit) a module switches on. Its own contract: [Verification](https://staging.sundial.md/docs/verification). | `verifies`, `lib/workspace/verifiers.ts` |
| **Kind** | The word the gallery shows: template, verifier, skill, or assistant. Derived from the manifest, never declared. | `kindOf` in `app/modules/discover-model.ts` |
| **Create from** | A new workspace seeded with the module. | `POST /api/templates/new` |
| **Connect** | Add the module to a workspace that already exists. | `POST /api/workspace/assistants/connect` |
| **Catalog** | The DB copy of every manifest that the gallery and the APIs read, synced from the repo on deploy. | `assistants` table, `syncAssistantsToDb` |

Code map: `lib/templates/registry.ts` (manifest schema, discovery, file
resolution) · `templates/` (the modules) · `lib/assistants/connect.ts` (the
connect planner) · `app/api/workspace/assistants/connect/route.ts` (connect) ·
`app/api/workspace/modules/route.ts` (list, remove) · `app/api/templates/new/route.ts`
(create from) · `lib/assistants/store.ts` (catalog reads, repo fallback) ·
`lib/assistants/sync.ts` (deploy sync) · `app/modules/discover-model.ts`
(kind, featured, field rows) · `agent-ts/src/prompt/compile.ts` (what the
agent sees) · `tests/templates/registry.test.ts` (the gate).

## 1. What a module is

A module is a folder of files plus a short manifest. That is the whole
mechanism: no plugin code, no UI, no registration table. Adding one to a
workspace seeds its files at the workspace root, appends its instructions to
the agent's system prompt, and, when the manifest says so, installs a button
over selected text or switches on a check. Removing it takes the
instructions, buttons and checks away and leaves the files, because they are
the user's now.

Everything a module can do is one of four things:

- **Seed files.** A paper's `main.tex` and class files, a protocol in
  `AGENTS.md`, a script the agent runs, a starter `question.md`.
- **Instruct the agent.** `system_prompt_addendum` joins every chat's system
  prompt. `AGENTS.md` (if shipped) is read at the start of every turn. A
  `skills/<id>/SKILL.md` is listed to the agent and read on demand.
- **Run a check on selected text.** A selection action: prompt, skill,
  the check it runs (`verifier`), and where the answer lands.
- **Switch on a check.** A verifier id in `verifies`; the check itself lives
  in code, see [Verification](https://staging.sundial.md/docs/verification).

A few modules are built in rather than installed — the LaTeX compiler, daily
notes — and live in code (`lib/workspace/verifiers.ts`,
`components/workspace/daily-notes-card.tsx`), not under `templates/`: they take
no manifest and no Modules row (the compiler just runs; daily notes has its
switch in Settings → Workspace).

What a module cannot do, so you do not go looking: declare a schedule (the
instructions can tell the agent to create one with `schedule_create` when the
user asks), add a palette command, shortcut, sidebar row or view (those are
hard-coded in the shell), substitute dates or names into seeded files (files
are verbatim), or hide a file from other members (the workspace is the
privacy boundary, [Privacy and security](https://staging.sundial.md/docs/security)).

## 2. The smallest module, in five steps

A skill module: one manifest, one skill. Ten minutes.

1. Make the folder. Category `research` for a method the agent follows,
   `project` for a workspace with a toolchain, `loop` for a never-ending
   agent loop, a LaTeX category for a paper starter (§3).

   ```
   templates/research/line-edit/
     manifest.yaml
     files/
       skills/line-edit/SKILL.md
   ```

2. Write the manifest. Five fields are required; `intro_message` is
   required too outside `conference` and `journal` (the test checks).

   ```yaml
   slug: line-edit                # must equal the folder name
   name: Line edit
   category: research             # must equal the parent folder
   description: >-
     A careful line-by-line editing pass that lands as reviewable
     suggestions. Grammar, tightening, and the author's voice kept.
   fields:
     - writing
   intro_message: >-
     Ask me to line-edit any document here. Every change arrives as a
     suggestion you accept or reject; I never rewrite silently.
   suggested_prompts:
     - Line-edit the open document
   system_prompt_addendum: |
     When asked to edit, tighten or proofread prose, read
     `skills/line-edit/SKILL.md` first and follow it. Make every change
     as a suggestion, never a silent rewrite.
   ```

3. Write the skill. Frontmatter `name` and `description` are what the agent
   sees before it decides to read the body, so the description says when the
   skill applies.

   ```markdown
   ---
   name: Line edit
   description: A line-by-line editing pass over a document, landing as suggestions. Use when asked to edit, tighten, or proofread without changing meaning or voice.
   ---

   Read the whole document first, then edit sentence by sentence.

   1. Fix grammar, spelling, and punctuation.
   2. Tighten: cut filler, collapse redundancy, split run-ons.
   3. Preserve the author's voice and meaning exactly.
   4. Finish with the two or three recurring habits you fixed most.
   ```

4. Run the gate and bump the count.

   ```bash
   pnpm test templates/registry
   ```

   `tests/templates/registry.test.ts` pins how many listed modules each
   category has; raise `count('research')` by one. Everything else in that
   file (description length, intro present, locked files shipped, skill
   bundles text-only) checks itself.

5. Try it. With a dev server running, open `/modules/line-edit` and click
   **Use this module**, or create a workspace from a shell:

   ```bash
   curl -sS -X POST http://localhost:3000/api/templates/new \
     -H 'Content-Type: application/json' -H 'Cookie: sd_anon=trythisnow123456' \
     -d '{"slug":"line-edit"}'
   ```

   The gallery list at `/modules` is read from the catalog (§11), so an
   unmerged module is missing from the list but its own page and both APIs
   work through the per-slug repo fallback.

Then open a PR against `dev`. The deploy syncs the catalog and the module is
live on `dev.sundial.md`.

## 3. Layout and categories

```
templates/
  _shared/<bundle>/      Files several modules include (acmart, lean4-setup, …).
                         Every file lands at the workspace root. SOURCE.txt
                         records provenance and is never seeded.
  <category>/<slug>/
    manifest.yaml
    files/               Seeded verbatim, nested paths kept.
```

The category is the folder, and the manifest must agree (`category:` equals
the parent folder, `slug:` equals the folder name; discovery throws
otherwise). Categories are the list `TEMPLATE_CATEGORIES` in
`lib/templates/registry.ts`, mirrored in `agent-ts/src/prompt/templates.ts`:

| Category | For | Rules |
| --- | --- | --- |
| `conference` | A venue's official style, one per venue and year (`icml-2026`). | LaTeX. Addendum must carry live guideline URLs; the agent gets a fetch-the-guidelines preamble. |
| `journal` | A journal's style (`jmlr`, `elsarticle`). | LaTeX. Same as conference. |
| `thesis` | University theses. | LaTeX. Needs `intro_message`. |
| `classic` | Preprints, letters, notes, Tufte (`arxiv-preprint`). | LaTeX. Needs `intro_message`. |
| `talk` | Beamer decks and posters. | LaTeX. Needs `intro_message`. |
| `cv` | CVs and resumes. | LaTeX. Needs `intro_message`. |
| `loop` | A never-ending agent loop: `AGENTS.md` protocol, `goal.md`, `tasks.md`, `attempts.md` (`goal-loop`). | Needs `intro_message`. Locks its rules. |
| `hackathon` | A skills pack for one event (`reagent`). | Needs `intro_message`. |
| `project` | A workspace with a toolchain the sandbox installs on first use (`lean4`, `wikiskill`), or a capability with no toolchain at all: an `AGENTS.md` protocol and nothing else. | Needs `intro_message`. A toolchain means a `setup_command`. |
| `research` | A method the agent follows: skills, a starter doc, a check (`math-research`, `claim-verification`). | Needs `intro_message`. |

The LaTeX categories (`LATEX_TEMPLATE_CATEGORIES`) owe a `main.tex`, a
prebuilt `main.pdf` and its `main.synctex.gz` (§9). Every other category is
free-form. Prefer an existing category: a new one touches `TEMPLATE_CATEGORIES`,
the brain's copy, the count pins in the registry test, and, for documents,
`DOCUMENT_CATEGORIES` in `app/modules/discover-model.ts`.

## 4. The manifest

Hand-rolled validation in `parseTemplateManifest`, not Zod; every rule below
throws at discovery, so `pnpm test templates/registry` is the check. Limits
are exact.

| Field | Type | What it does | Rule |
| --- | --- | --- | --- |
| `slug` | string | Identity everywhere: folder, URL (`/modules/<slug>`), API, `projects.template_slug`. | Required. `^[a-z0-9-]+$`. Equals the folder name. |
| `name` | string | Card title, the `## <name>` heading of its instructions section, the sidebar row. | Required. |
| `category` | string | The folder; picks the agent preamble and the gallery kind. | Required. One of the table above. Equals the parent folder. |
| `description` | string | The card and the module page opener. Rendered as plain text. | Required. 40 to 200 characters, no backticks or backslashes. |
| `system_prompt_addendum` | string | Appended to the system prompt of every chat in a workspace that has the module (§6). | Required. Conference and journal addenda must contain an `https://` URL. |
| `intro_message` | string | Sunny's first message in a workspace created from the module; also posted on connect when the caller names a chat. A seeded assistant message, rendered as markdown like any reply: backticks and lists are fine, keep it short. | Required outside conference and journal (their greeting is built from the venue). |
| `suggested_prompts` | string[] | Chips on the empty chat's hero. | At most 4, each at most 80 characters. |
| `open_path` | string | The file a fresh workspace opens on (`?filePath=`), and the file the sidebar opens after connect. Without it, arrival falls back to the most recently edited file, and a LaTeX-category module gets the tex tour (`LATEX_TEMPLATE_CATEGORIES` gates it in `app/api/templates/new/route.ts` and `lib/templates/anon-template-workspace.ts`). | Must be a shipped path. One value. |
| `fields` | string[] | Card tags and search terms. Only a value in `FIELD_ROWS` (`app/modules/discover-model.ts`) places the module in a gallery row: `math`; `cs`, `ml`, `systems`, `pl`, `hci`, `security`; `physics`; `biology`, `medicine`, `medical`, `neuroscience`, `chemistry`; `writing`, `publishing`, `academic`. Any other value is a tag only; add a row there when a new area needs one. | Lowercase letters. |
| `sources` | `{name, url}[]` | Links on the card and the module page: where the class files, tools or papers come from. | |
| `license` | string | License of vendored class or style files. | Reviewers check it before a bundle lands in `_shared/`. |
| `shared_bundles` | string[] | `_shared/<name>` folders to include. | On a path collision `files/` wins. |
| `locked_files` | string[] | Seeded paths agents may never edit (`files.is_locked`). Humans edit freely and can unlock from the tree. | Every path must ship. |
| `setup_command` | string | Sandbox bash fired at create and connect, before the first question (§8). | Idempotent. Trimmed; empty means none. |
| `default_model` | string | Seeded into `projects.agent_default_model`: the model every chat without its own falls back to. | Must be in `ALLOWED_GATEWAY_MODELS` (`lib/workspace/model-allowlist.ts`). Paper templates declare none. |
| `selection_actions` | object[] | Buttons over selected text (§7). | At most 8. |
| `verifies` | string[] | Verifier ids the module switches on. | Each must be registered (`getVerifier`). |
| `unlisted` | boolean | Hidden from every list and the gallery, still resolvable by slug so existing workspaces keep working. | Retire a module this way, never by deleting it. |
| `year`, `deadline`, `page_limit`, `anonymized` | scalar | Paper-template facts shown on the card. | Optional. |

A selection action:

| Field | Rule |
| --- | --- |
| `id` | Required, kebab-case, unique within the manifest. |
| `label` | Required, at most 24 characters. The action's short name (a check's button wears the check's registry name). |
| `title` | Required, at most 64 characters. The title of the chat a run starts. |
| `skill_id` | Required, kebab-case. `skills/<skill_id>/SKILL.md` must ship, and every file under that folder must be text and use paths relative to the folder (the literal `skills/<skill_id>/` may not appear inside them, because connect can rename the folder; §5 has the convention). The manifest's addendum and action prompts may name `skills/<skill_id>/…` because connect rewrites them (`remapAssistantPaths`). |
| `prompt` | Required, at most 8000 characters. Sent with the selected text. |
| `result` | `chat` (default: a fresh chat) or `thread` (a comment thread anchored on the selection). |
| `enabled_by_default` | Legacy, no effect: an installed action is always on (there is no switch). |
| `model` | The run chat's model. |
| `verifier` | A registered verifier id: the action produces that verifier's verdict, in the annotation lane, with its result instruction appended. |

## 5. The files

`resolveTemplateFiles` walks `files/` after every bundle in `shared_bundles`,
so a path in `files/` overrides the bundle's copy. What it skips: names
starting with a dot, `SOURCE.txt`, LaTeX build artifacts (`.aux`, `.log`,
`.out`, `.toc`, `.bbl`, `.blg`, `.fls`, `.fdb_latexmk`) and docstrip sources
(`.dtx`, `.ins`). Everything else ships, so a `__pycache__/` you left behind
ships too, as blobs. Run shipped Python with `sys.dont_write_bytecode`
and never `py_compile` in the tree.

Text or blob is decided by `lib/sync/policy.json` (`isBlobFile`): a blob
extension is read as base64 and seeded on the blob rail, everything else is
a live document with history. Mime comes from the extension
(`text/markdown`, `application/x-tex`, `application/pdf`, …).

Among text files, markdown (`markdown_extensions` in the same policy:
`.md`, `.mdx`, `.markdown`) is parsed by the one codec
(`lib/markdown/parser.mjs` + `lib/crdt-js/markdown_yjs.mjs`) and
re-serialized canonically on every persist. Every other text extension
(`.json`, `.py`, `.tex`, …) is stored byte for byte. So keep machine-written
state (JSON, logs) in a non-markdown extension, and if a module puts markers
in notes (a wikilink, a `::` separator, a lone `?` line), prove they survive
with `populateFromMarkdown` + `serializeDoc` in a test beside the others in
`tests/collab/`.

Files are verbatim. There is no placeholder syntax: no `{{date}}`, no
`{{title}}`. If a file must name today or the user, the agent writes it on
the first turn; say so in the addendum.

Paths that mean something:

- `AGENTS.md`: read by the agent at the start of every turn (root only;
  nested `AGENTS.md` files are ignored by design). The brain cuts anything
  over 20,000 characters to the first 14,000 and the last 4,000. Lock it
  when it is a protocol the agent must not rewrite (`goal-loop`). On
  connect into a workspace that already has one, its content is appended as
  a `## <name>` section (§10), so write the body without a top heading.
- `skills/<id>/SKILL.md`: listed to the agent under `## Skills` as its
  `name` and `description`, read on demand. Supporting files
  (`references/*.md`, `scripts/*.py`) sit beside it. Keep links relative.
  Refer to a sibling as `<this-skill-folder>/scripts/x.py` and tell the
  agent to resolve it from the `SKILL.md` path it read (`claim-verification`
  is the pattern). Connect may rename the folder to `skills/<id>-<slug>/`
  but keeps its depth (`suffixedPath`), so a script that needs the workspace
  root derives it from its own location, `Path(__file__).resolve().parents[3]`
  for `skills/<id>/scripts/x.py`, and takes a `--root` override for tests.
- `main.tex` with `main.pdf` and `main.synctex.gz`: the paper and its
  prebuilt preview (§9).
- `setup.sh`: what `setup_command: bash setup.sh` runs (§8).

A user can also bring a skill without a module: Modules → Add skill takes a
GitHub or GitLab `SKILL.md` URL or a `.skill` zip
(`POST /api/workspace/skills`, `lib/skills/`). Same file, same folder, no
manifest. Make a module when the skill needs starter files, a button, a
check, or a place in the gallery.

The Modules section lists every `skills/<id>/SKILL.md` in the workspace as
a row, by its frontmatter `name` and `description` (`skills` in
`GET /api/workspace/modules`, read from the workspace's files; a `?pshare=`
guest sees only what the link covers). Three things happen from the rows:

- **New module** writes `skills/<kebab-id>/SKILL.md` with the frontmatter
  and the §2 body shape (`lib/skills/new-module.ts`) through the Add-skill
  route, which suffixes a taken id, and opens it.
- **⋮ → Share** opens the share window scoped to the folder `skills/<id>/`.
  Nothing is special about the link: it is the one minter
  (`lib/workspace/share-links.ts`), a folder grant, and an agent that gets
  it reads the `SKILL.md` and follows it (`/start` says so).
- **Add to my workspace**, shown to a visitor who cannot edit here, copies
  the folder into a workspace of theirs: `POST /api/workspace/modules/import`
  `{fromWorkspaceId, path, pshare, toProjectId}` reads the source through
  the scoped access the link grants and seeds with `planConnectFiles` rules
  (free path as-is, identical skipped, taken name → `skills/<id>-copy/`, a
  taken suffix → 409, never half-installed).

On the desktop the same section renders on a local folder: the rows are the
folder's `skills/*/SKILL.md` (a check module's skill, `claim-verification`,
`out-of-date` or `lean-blueprint`, is drawn as its check row instead,
`folderChecks`, saying `FOLDER_CHECK_WORDS`), New module writes one, and **+ Add** copies a
catalog module's files in with no sign-in — the public
`GET /api/templates/<slug>/files` plus the sidecar's
`POST /projects/<id>/modules {slug}`, which lands the files with the same
planner rules through the file path every save takes. Modules that ship no
files (`seedsFiles` in the listing) are not offered there.

## 6. What the agent sees

At create and at connect the addendum is copied into the workspace row
(`projects.template_addendum_default`, one `## <name>` section per module,
`appendAssistantAddendum`), so a later edit to the manifest never changes a
workspace behind its back. The brain reads that column each turn:

| Source | Precedence |
| --- | --- |
| `projects.template_addendum_override` | First. Settings → Instructions, the user's own text. |
| `projects.template_addendum_default` | Second. The frozen module sections. |
| The manifest on disk (`agent-ts/src/prompt/templates.ts`) | Last, for workspaces older than the column. |

It lands in the system prompt as `## Template Instructions`. For `conference`
and `journal` a preamble precedes it: fetch the live author guidelines before
editing a paper file and follow them exactly; other categories get the
addendum verbatim, so their rules must be self-contained.

Also in the prompt, from the workspace itself: the root `AGENTS.md` (and
`CLAUDE.md`), the `## Skills` list of every `skills/*/SKILL.md`, the names of
workspace secrets, and a runtime tail with the user's `timezone`
(`user_preferences.timezone` of the workspace owner, `UTC` when unset) and
`local_datetime`. Write instructions against these facts: "today is the
local date from Runtime" works; "ask the user their timezone" does not.

The intro is a seeded assistant message, not a model turn: `intro_message`
is Sunny's first message in a fresh workspace (`seedAssistantMessage`), and
connect posts it into the chat named by `announceChatId` when one is given.
Suggested prompts render as chips only on an empty chat and send a normal
turn when clicked, so they must be asks the agent can answer, not UI
commands.

## 7. Selection actions

A selection action runs on selected text. The text bubble shows only
checks: an action that names a `verifier` whose registry spec has
`onSelection` is that check's button (in the check's own words), on the
files the check applies to. An action with no `verifier` has no button; the
agent still has its skill, so a person asks for it in chat. The
run is an ordinary chat turn: the action's `prompt`, the selected text, and
the skill at `skills/<skill_id>/SKILL.md` as the method. With
`result: thread` the answer becomes a comment thread anchored on the
selection, which is how a verifier's verdict sits beside the line it judged;
with a `verifier` the thread is authored as that verifier and its result
contract is appended to the prompt. The definition is snapshotted into the
workspace at install (`workspace_selection_actions`), so editing the
manifest later does not change a button collaborators already use.

```yaml
selection_actions:
  - id: verify-claim
    label: Verify                 # ≤ 24 chars, the short name
    title: Claim Verifier         # ≤ 64 chars, the run chat's title
    skill_id: claim-verification  # files/skills/claim-verification/SKILL.md
    result: thread
    verifier: claim
    enabled_by_default: true
    prompt: >-
      Verify every checkable claim in the selected passage …
```

What the run receives (`invocationContent` in
`app/api/workspace/assistant-actions/invoke/route.ts`), four parts joined by
blank lines:

```markdown
> _from `notes/week-3.md`:_
> the selected text, one quoted line per line

Read `skills/<skill_id>/SKILL.md` first and follow it.

<prompt>

<the verifier's result instruction, when `verifier` is set>
```

A `result: thread` action is a one-click check with a card waiting, so its
run gets what it needs up front instead of the skill line: the paragraph
around the selection, whether the document has a bibliography, and "skip the
skill file". Its chat runs on the action's `model` too.

So the prompt may rely on the quoted path ("append to the note the selection
came from"). With `result: chat` the run starts a fresh chat, so nothing else
from the editor is in context. `GET /api/workspace/assistant-actions?projectId=`
lists the actions a workspace has installed;
`POST /api/workspace/assistant-actions/invoke` is what the button calls.

`claim-verification` is the worked example: one action, one skill, one
verifier. `lean-blueprint` adds a `model` and no `enabled_by_default`.
A generation button is the `result: chat` shape: its prompt appends cards or
notes to the quoted file.

## 8. Setup commands

`setup_command` runs in the sandbox (Modal, one per Sunny, the workspace
volume at `/workspace`), fire-and-forget, at workspace create and at
connect, in the sandbox of the most recent chat (`warmAssistantSetup`). The
agent may run it again on its first turn. So:

- Idempotent, and cheap when nothing is left to do. Take a lock
  (`flock` on a file under `/workspace/.sundial/`) so the warmup and the
  first turn do not race; `wikiskill`'s `setup.sh` is the pattern.
- Persist under `/workspace` (a venv, a toolchain); the sandbox idles down
  after 15 minutes and the volume is what survives.
- Budget for the first turn: `Bash` calls time out at 600 seconds. A long
  install is fine in the warmup, but the first turn must not block on it;
  cap any probe with a timeout and print the decision-relevant lines first.
- Say in `intro_message` that the first run installs something and how
  long it takes.

Combined workspaces warm only the first setup-bearing module.

What a shipped script can rely on, whether the setup runs it or the agent
runs it with `Bash`:

- The shell's cwd is the workspace root (`/workspace`), and the notes on
  disk are live: the sandbox materializes every doc-store edit to disk as
  it lands, so a script reads the current text without a fetch.
- Every write under `/workspace` is mirrored back into the workspace by the
  sandbox tree watcher (`WorkspaceTreeWatcher` in `sandbox/file_server.py`,
  through `sandbox/shared/crdt_sync.py`): text extensions become live
  documents, blob extensions go to the blob rail, and text over 10 MB
  mirrors as a blob. `ignored_paths` in `lib/sync/policy.json`
  (`.sundial/`, `__pycache__/`, `tmp/`, `.venv/`, `*.pyc`, …) never mirror,
  so scratch and locks go there.
- A write made during a `Bash` call lands attributed to that turn
  (`actor='agent'`, the turn and tool call ids), so it shows as a diff card
  and, in a suggest-mode chat, as a suggestion. Markdown is canonicalized on
  the way in (§5). If an edit should read as the agent's own reviewable
  change rather than a script's rewrite, have the agent make it with its
  edit tools and let the script only read.

## 9. Paper templates

A LaTeX module ships `main.tex`, the class or style files it needs (from a
bundle when several venues share them), a `references.bib`, and a prebuilt
`main.pdf` plus `main.synctex.gz` beside the source. The PDF makes the
preview render the moment the workspace opens instead of waiting out a cold
compile; the SyncTeX index keeps double-click inverse search alive, because
seeding the PDF suppresses the arrival compile that would otherwise produce
it. The registry test refuses a listed LaTeX module without both.

The authoring loop:

```bash
pnpm templates:local <slug>     # local latexmk, same engine rule as the pool
pnpm templates:compile <slug>   # through a dev server and the Modal compile pool
pnpm templates:pdfs <slug>      # rebuild files/main.pdf + main.synctex.gz
pnpm templates:thumbs <slug>    # public/templates/thumbs/<slug>.webp for the card
```

Compile-pool facts: bibtex only (no biber), no binary starters apart from the
PDF pair (figures go in TikZ), LaTeX kernel 2021-11 (no `\DeclareKeys`), and
a class that forces `\pdfoutput=1` needs the
`\ifdefined\XeTeXversion\PassOptionsToPackage{xetex}{hyperref}\fi` guard
before `\documentclass` (see `journal/lmcs`). Templates that typeset `\today`
show the build date until the first recompile. Bump `vendored_at` in the
bundle's `SOURCE.txt` when you refresh its files.

## 10. Lifecycle: create, connect, list, remove

**Create from.** `POST /api/templates/new` with `{"slug":"<slug>"}` seeds a
new workspace: files at the root, `intro_message` as the first message,
`open_path` as the file to open, `default_model` on the workspace,
`setup_command` warming, the addendum frozen into the row. Up to four modules
combine with `{"slugs":[…],"layout":"main"}`: the first is the workspace's
identity and sits at the root, the rest nest under `<slug>/`. Auth is a
Bearer token or the anonymous `sd_anon` cookie (the `/start` skill covers
both). Humans get the same through `/modules/<slug>` → **Use this module**,
`/new?template=<slug>`, and `/t/<slug>`, a copy of `/start` with the first
call pre-filled for one module, made for handing to a room of agents.

**Connect.** `POST /api/workspace/assistants/connect` with
`{"projectId","slug"}` (owner or editor; optional `announceChatId`,
`enableActionIds`) adds a module to an existing workspace. The planner
(`planConnectFiles`) is pure and has three rules per path:

1. Path free: seed as-is.
2. Path taken with identical content: skip, and inherit the module's lock if
   it declares one.
3. Path taken with different content: `AGENTS.md` gets the module's content
   appended as a `## <name>` section (`mergeAgentsFile`, a no-op when the
   section is already there); every other file gets a `-<slug>` suffix before
   its first dot, and a skill folder becomes `skills/<id>-<slug>/`. Files
   that belong together (`main.tex`, `main.pdf`, `main.synctex.gz`) rename
   as a group so the preview stays attached. A suffixed name that is itself
   taken by different content is a 409: the module is not half-installed.

Then the addendum section is appended (idempotent), paths inside the
addendum, the action prompts and the setup command are rewritten to any
suffixed names, the actions are snapshotted, the setup command is warmed,
and the intro is posted if a chat was named. The response:

```json
{"connected":{"slug":"…","name":"…","announced":false,
  "added":["skills/x/SKILL.md"],"renamed":{},"identical":[],
  "agentsMerged":false,"openPath":"question.md",
  "selectionActions":[…],"formalizable":null}}
```

Reconnecting is a repair, not an upgrade: an installed module keeps its
frozen instructions.

**List.** `GET /api/workspace/modules?projectId=` returns the module the
workspace was created from plus every connect:
`{"modules":[{"slug","name","category","description","skills","verifies"}]}`
(`skills`: the skill folders it seeded, which the rail lists under it). The
sidebar Modules section and the verifier registry read this, never a slug
list.
`GET /api/workspace/assistant-actions?projectId=` lists the installed
selection actions (§7).

**Remove.** `DELETE /api/workspace/modules` with `{"projectId","slug"}`
drops the install row, the selection actions and the module's section of the
instructions. Files stay. Schedules the agent created stay too, so a
module whose instructions arm one should tell the agent what "stop" means.

## 11. The gallery and the catalog

The card at `/modules` and the page at `/modules/<slug>` come from the
manifest: `name`, `description`, `fields` as tags, `sources` as links, the
thumbnail when `public/templates/thumbs/<slug>.webp` exists, and the file
inventory grouped by role (skill, starter, library, doc, setup, prebuilt).
The **kind** word is derived by `kindOf`: a verifier when the slug is a
verifier's module, a template for the LaTeX categories, an assistant when
it has selection actions, a skill when it seeds `skills/`, a template
otherwise. `KIND_OVERRIDE` in `app/modules/discover-model.ts` is the escape
hatch for the few the rule misreads (`goal-loop` is an assistant with no
actions). `FEATURED` there is the goal-first "Start here" row; it is optional,
per-slug, and edited by hand.

The list is the **catalog**: the `assistants` and `assistant_files` tables,
written from the repo by `pnpm assistants:sync` (`syncAssistantsToDb`),
which the dev and staging deploys run after migrations. Once the table has
rows it wins wholesale; the repo registry is the fallback for an
environment that never synced and, per slug, for any module the table
lacks. Two consequences: an unmerged module is absent from the local list
but present at `/modules/<slug>` and in both APIs; and never run the sync
locally against the shared dev database for unmerged work. `verifies` and
`suggested_prompts` are read from the repo manifest even for catalog rows.
Retire a module with `unlisted: true` or `status: blocked`, never by
deleting the folder, which would let the fallback resurrect it.

Popularity (`assistant_popularity`) orders the gallery by workspaces created
from or connected to each module.

## 12. Tests and proof

Narrowest first, the full gate once before the PR.

| What | Command |
| --- | --- |
| The manifest and files | `pnpm test templates/registry`. Bump the category count. |
| Connect behaviour you changed | `pnpm test templates/assistants-connect` |
| The gallery kind, if you touched `KIND_OVERRIDE` | `pnpm test modules-discover` |
| A shipped Python script | `tests/skills/<name>_test.py`, run by `pnpm test:unit:python` (python3.11, the whole suite; `tests/sandbox` in it needs infra and is not a module author's gate). Narrow: `PYTHONPATH=. PYTHONDONTWRITEBYTECODE=1 python3.11 scripts/dev/test-python.py discover -s tests/skills -p "<name>*_test.py" -t .` (the directory must have an `__init__.py`; prefix `PYENV_VERSION=3.11.13` when python3.11 is a pyenv shim). Fixture-driven; `sys.dont_write_bytecode = True`. |
| A LaTeX module | `pnpm templates:local <slug>`, then `pnpm templates:compile <slug>` against a dev server. |
| This page | `pnpm test module-sdk-doc` |
| Everything | `pnpm test:full` |

Live proof, in a real workspace: `npm run dev`, open `/modules/<slug>`,
**Use this module**, and drive the first turn the intro promises. A module
that installs a toolchain gets its first live turn on the shared dev stack
(the sandbox is Modal, not your machine); a first turn that "times out" on a
fresh sandbox usually means the warmup and the turn serialized on the setup
lock, so rerun the idempotent setup rather than debugging the toolchain.
Verify seeded content with
`GET /api/workspace/files/download?projectId=<id>&path=<path>`.

From a shell, with the same cookie or token on every call: `POST
/api/templates/new` returns `project.id` and `project.initialChatId`; check
the seed with the download route; send the first turn with
`POST /api/workspace/messages` and `{"chatId":"<initialChatId>","content":"<a suggested prompt>"}`;
poll `GET /api/workspace/messages?chatId=<id>&afterSequence=<n>` for the
reply; then download the files the turn should have changed. The turn runs
on the shared dev brain and spends a live model turn, and a cold sandbox
adds about a minute before the first `Bash` call returns.

For a module whose value is visible (a paper that compiles, a check that
lands as a thread, a loop that runs), record the real flow on dev and put
the recording in the PR (`pnpm pr-visuals:upload`).

## 13. For agents: the sequence

You were asked to add a module. Do this, in order, and nothing else.

1. Read this page. A skills module needs §1, §2, §4 to §8, §10 and §12; a
   paper template adds §3, §9 and §11 (§11 otherwise only explains why an
   unmerged module is missing from `/modules`). Then read one sibling of the
   same shape: `research/math-research` for a skills module,
   `research/claim-verification` for a button and a check,
   `project/wikiskill` for a toolchain with a locked protocol,
   `loop/goal-loop` for a loop, `conference/icml-2026` for a paper.
2. Pick the category from §3. Do not add a category.
3. Create `templates/<category>/<slug>/manifest.yaml` and `files/`. Slug
   and category equal the folders. Every required field, `intro_message`
   included. Description 40 to 200 plain characters. Suggested prompts are
   asks, at most four.
4. Put the method in `files/skills/<id>/SKILL.md` with frontmatter, and
   tell the agent in `system_prompt_addendum` when to read it. Put rules
   that must survive a missing skill in the addendum itself. Put a
   per-turn protocol in `AGENTS.md`, without a top heading, and lock it if
   the agent must not rewrite it.
5. Ship any script the agent runs under `files/`, with a test under
   `tests/`, and make it idempotent. If it needs installs, add
   `setup_command: bash setup.sh` with a lock and a fast no-op path.
6. Never write a placeholder into a file, never add UI, never define a
   second parser or catalog. If the module needs something on a schedule,
   write the instruction that has the agent create the schedule when the
   user says so, with the user's timezone from Runtime.
7. Run `pnpm test templates/registry`, raise the category count, run
   `pnpm test:full` once.
8. Prove it live on a dev server: `/modules/<slug>`, **Use this module**,
   the first turn. Fix what the intro promised and the turn did not do.
9. Open a PR against `dev`. No review is required on dev; the deploy syncs
   the catalog.

If this page did not answer a question you had, the answer belongs on this
page: add it in the same PR.

## 14. Worked examples

| Module | Shape | Read it for |
| --- | --- | --- |
| `research/math-research` | Four skills, a starter `question.md`, `open_path`, a self-contained addendum with hard rules. | A skills module. |
| `research/claim-verification` | One skill, one selection action (`result: thread`, `verifier: claim`, on by default), `verifies: [claim]`; `scripts/*.py` beside `SKILL.md`, referenced as `<this-skill-folder>/scripts/x.py`. | A button and a check; a skill that ships scripts. |
| `research/lean-blueprint` | `main.tex` plus the `lean4-setup` bundle, `setup_command`, `verifies: [lean, audit]`, a Formalize action with its own `model`. | A paper kept in step with a proof. |
| `project/lean4` | `unlisted: true`, kept resolvable for existing workspaces. | Retiring a module. |
| `project/wikiskill` | Locked `AGENTS.md`, `check.py` and task data; `setup.sh` with `flock`; `default_model` pinned to a small model. | A toolchain with a protocol. |
| `loop/goal-loop` | Locked `AGENTS.md` and `goal.md`, `default_model`, an addendum with invariants that hold even without the files. | A never-ending loop. |
| `hackathon/reagent` | Four tool skills, `sources` with key links, an intro that says what needs a key. | A skills pack for an event. |
| `conference/icml-2026` | Class files from a bundle, prebuilt `main.pdf` and `main.synctex.gz`, guideline URLs in the addendum, `year`, `deadline`, `page_limit`, `anonymized`. | A paper template. |

## 15. Code map

| Concern | Where |
| --- | --- |
| Manifest schema, categories, validation | `lib/templates/registry.ts` (`parseTemplateManifest`, `TEMPLATE_CATEGORIES`, `LATEX_TEMPLATE_CATEGORIES`) |
| File resolution, bundles, locks | `lib/templates/registry.ts` (`resolveTemplateFiles`, `validateTemplateFiles`, `applyLockedFiles`) |
| The brain's copy of the categories and the manifest fallback | `agent-ts/src/prompt/templates.ts` |
| What the agent sees | `agent-ts/src/prompt/compile.ts` (`templateSection`, `skillsSection`, `runtimeSection`), `agent-ts/src/prompt/load-context.ts` |
| Create from | `app/api/templates/new/route.ts`, `lib/workspace/bootstrap.ts` |
| Connect planner and instruction merge | `lib/assistants/connect.ts` (`planConnectFiles`, `mergeAgentsFile`, `appendAssistantAddendum`, `removeAssistantAddendumIfPresent`) |
| Connect, list, remove | `app/api/workspace/assistants/connect/route.ts`, `app/api/workspace/modules/route.ts` |
| Setup warmup | `lib/assistants/warmup.ts` (`warmAssistantSetup`) |
| Sandbox mirror for scripts | `sandbox/file_server.py` (`WorkspaceTreeWatcher`), `sandbox/shared/crdt_sync.py`, `agent-ts/src/tools/bash.ts`, `lib/sync/policy.json` |
| Selection actions | `lib/assistants/selection-actions.ts`, `app/api/workspace/assistant-actions/route.ts` (list), `app/api/workspace/assistant-actions/invoke/route.ts` (run, `invocationContent`) |
| Verifiers | `lib/workspace/verifiers.ts` (`registerVerifier`), [Verification](https://staging.sundial.md/docs/verification) |
| Catalog reads and the repo fallback | `lib/assistants/store.ts` (`listAssistants`, `getAssistant`) |
| Catalog sync on deploy | `lib/assistants/sync.ts` (`syncAssistantsToDb`), `scripts/assistants-sync.ts`, `.github/workflows/deploy.yml` |
| Gallery kind, featured row, field rows | `app/modules/discover-model.ts` (`kindOf`, `KIND_OVERRIDE`, `FEATURED`, `FIELD_ROWS`), `app/modules/listing.ts` |
| Module page | `app/modules/[slug]/page.tsx`, `app/modules/[slug]/detail-model.ts` |
| Bring-your-own skill | `app/api/workspace/skills/route.ts`, `lib/skills/` |
| Sidebar Modules section | `components/workspace/modules-section.tsx`, `components/workspace/modules-gallery.tsx` |
| Agent-facing entry points | `/start` (`content/sundial.SKILL.md`), `/agent-docs`, `/t/<slug>`, `/api/templates` |
| LaTeX tooling | `scripts/dev/compile_template_local.mjs`, `scripts/dev/check_templates_compile.mjs`, `scripts/dev/build_template_pdfs.mjs`, `scripts/dev/build_template_thumbs.mjs` |
| The gate | `tests/templates/registry.test.ts`, `tests/templates/assistants-connect.test.ts`, `tests/ui/modules-discover.test.tsx`, `tests/ui/module-sdk-doc.test.ts` |
