Esc

Each skill teaches your agent one kind of document, what goes in it and in what order, and shows a finished example. Install the ones you need.

  • The invite dialog now says what's wrong with an address, and waits for a valid one before it sends.

    Open Settings › Members, select Invite, type maria@acme, and press Tab.

    Before
    Invite dialog with a red email field and no message12
    1. 1
      Only the border turns red. Nothing says what to fix.
    2. 2
      Send invite still sends to maria@acme.
    Invite dialog with an error under the email field and a disabled Send invite button34
    1. 3
      "Add the domain, like maria@acme.com"
    2. 4
      Send invite waits for a valid address.
    1 / 2
    The changesrc/members/InviteDialog.tsx+3 −2
    <input id="email" aria-invalid={invalid} value={email} onChange={edit} />
    <button className="primary" onClick={send}>Send invite</button>
    <input id="email" aria-invalid={invalid} aria-describedby="email-error" value={email} onChange={edit} />
    {invalid && <p id="email-error" className="error">Add the domain, like {suggestion}</p>}
    <button className="primary" onClick={send} disabled={invalid}>Send invite</button>
    Before/after evidence

    Two comparable screenshots with measured marks, for any change people can see.

  • Review: avatar cache

    Request changes

    One blocking finding: after a large avatar loads, every smaller one on the page shows the large image. The fix is one line.

    Reviewed acme/web#418 at 9f3c2e1.

    1. Small avatars load the 512px image (blocking)

    Section titled "1. Small avatars load the 512px image (blocking)"

    src/avatars/cache.ts:18. The cache key is the user's id alone, so once the profile header loads someone's 512px avatar, the 32px avatars in comments get the same file. Reproduced with a test:

    pnpm test src/avatars1 passed1 failed180 ms

    cache.test.ts › returns a 32px avatar after a 512px one

    expected 512 to be 32

    AssertionError: expected 512 to be 32
          at cache.test.ts:27:31
    Fixsrc/avatars/cache.ts+1 −1
    const key = user.id;
    const key = `${user.id}:${size}`;

    2. The cache never evicts (should fix)

    Section titled "2. The cache never evicts (should fix)"

    src/avatars/cache.ts:9. Read in the code, not measured: the map grows by one entry per user and size for as long as the tab stays open, which on the team directory is thousands of images.

    const cache = new Map<string, Blob>();
    src/avatars/cache.ts

    Cap it, such as an LRU of 500 entries, or decide the tab's lifetime is short enough not to.

    • Ran pnpm test src/avatars: one failure, above.
    • Opened a profile on the preview at 1440px and watched the comment avatars after the header loaded.
    • Server rendering: the cache only runs in the browser, and I didn't confirm the server skips it.
    • Safari's memory use.
    Review document

    A verdict first, then each finding ranked with its location, evidence, and fix.

  • How a Comark document becomes HTML

    Parsing and rendering are two separate calls, and the plugins do their work in the first. That's why a block's problems are known before any HTML exists.

    MarkdownTokensNode treeNodes and issuesHTML

    A plugin can join at three points: its Markdown rules while the text becomes tokens, markdownItPost on the tokens, and post on the tree. The last arrow is the renderer, which only maps each node to a component.

    In code, the diagram is three lines:

    const document = await parseMarkdown(source, { plugins });
    if (document.meta.issues.length) refuse(document.meta.issues);
    const html = await renderHtmlFromDocument(document, { components });
    ts
    1. Every plugin runs here, in the order of plugins.
    2. Each issue is one sentence naming the block and the fix, so a host can refuse a document before it renders.
    3. Components only draw: screenshot maps to the figure's HTML, and nothing is parsed again.
    Visual explanation

    One question answered with the smallest diagram, tree, or screenshot that shows it.

  • Keep both writers' changes

    After this lesson, you can predict how a server handles two changes to the same record, with or without a version check.

    In this example, Ana and Ben open the same help article. Ana corrects a spelling error and saves the article. Ben adds a paragraph to his older copy and saves it. His save removes Ana's correction without a warning. This is a lost update.

    An ETag is a version label, such as "v7", that the server sends with a resource. The If-Match header carries that label back to require the same version before saving a change. If the labels match, the server saves the change. If the labels differ, the server returns 412 Precondition Failed and keeps the resource unchanged.

    1. Ana and Ben each GET /articles/42, and both get ETag: "v7".

    2. PUT /articles/42
      If-Match: "v7"
      http

      The article is still at "v7". The server saves Ana's change and returns 200 OK with ETag: "v8".

    3. PUT /articles/42
      If-Match: "v7"
      http

      The article is now at "v8". The labels differ, so the server returns 412 Precondition Failed. Ana's correction stays.

    4. Ben's editor fetches "v8" and shows Ana's correction. Ben adds his paragraph to that version. He saves again with If-Match: "v8".

    1.

    This API accepts requests without If-Match. Ben's editor sends his PUT without that header. The article is at "v8". What happens?

    Show answer
    The server merges Ben's paragraph into version 8
    The server does not combine the two versions. After a 412 response, the client must get the current version and combine the changes.
    Correct answer
    The server saves Ben's version and removes Ana's correction
    Without If-Match, the server has no version condition to check. The server saves Ben's version. This removes Ana's correction.
    The server refuses the save with a 412 status code
    A 412 response requires a condition that failed. Ben sent no condition, so the server has no labels to compare.
    The client must send its original version label for If-Match to protect the change. An API can require that header. If the header is absent, that API can return 428 Precondition Required.
    2.

    What does a server answer when If-Match names an old version?

    Show answer
    412 Precondition Failed, and it leaves the resource unchanged.

    RFC 9110, section 13.1.1 defines If-Match.

    The next lesson explains weak ETags and why W/"v7" never matches If-Match.

    Teach lesson

    One concept in simplified English, a worked example, and a question to check that you can apply it.

  • A visible change needs proof on a pull request: Before/after evidence.
  • A review is done, and someone has to decide what to do: Review document.
  • Someone asked how a system, a flow, or a change works: Visual explanation.
  • Someone needs to learn one concept and prove it: Teach lesson.
  • Your agent needs a block's syntax, not a whole document: the comark-ui skill.
  • A diff needs reviewing: your review process. These skills write documents. They don't find problems.
  • The document needs a link, comments, or saved answers: your host, such as Drop.

Install them with the skills CLI:

vp dlx skills add onmax/comark-ui

vp comes with Vite+. Without it, pnpm dlx and pnpm exec take the same arguments.

It lists every skill in the repository, comark-ui's included, and installs the ones you pick. Each document skill carries the syntax of the blocks it uses, so it works without comark-ui.

The comark-ui package ships every skill in dist/skills/. The skills CLI's sync command links them from node_modules, so they match the comark-ui version you render with:

pnpm add -D skills comark-ui
vp exec skills experimental_sync -a claude-code -y

Sync links each skill into .agents/skills/ and the directory of each agent you pass with -a, and records them in skills-lock.json. Installing the package alone does nothing, so rerun sync after each upgrade or add it to a prepare script, as the CLI reference shows. The command is experimental, so its name and options may change.

Ask in your own words, such as "Write up your review of #418." Your agent loads the skill whose description matches. To be sure, name it, as in "Use the review-document skill." The skills combine, so a review document can carry a before/after compare as one finding's evidence.

Each skill starts from the finished example rendered on its page, and each step ends with a check the agent passes before the next. Before handing it over, the agent lints the document with vp dlx, or pnpm dlx without Vite+, and looks at the render, so it needs Node and a browser.

An app that serves skills to its own agents imports them from comark-ui, so they match the plugins it renders with:

import { skills, version } from "comark-ui/skills";

const lesson = skills.find((skill) => skill.name === "teach-lesson");
for (const file of lesson.files) serve(`/skills/${lesson.name}/${file.path}`, file.text);
ts

Each skill has a name, a description, its parsed frontmatter, and its files. Each file has a path from the skill's folder and its text, with SKILL.md first. The text is inlined, so a Worker can serve it without reading node_modules. version is the comark-ui version they came from, for a cache key or a digest.

To install a skill as a folder instead, copy the whole folder, since SKILL.md links its references by path:

import { cp } from "node:fs/promises";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";

const folder = dirname(fileURLToPath(import.meta.resolve("comark-ui/skills/review-document/SKILL.md")));
await cp(folder, ".agents/skills/review-document", { recursive: true });
ts