@qvac/skills 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -27
- package/bundled.d.ts +4 -0
- package/bundled.js +84 -0
- package/hash.d.ts +2 -0
- package/hash.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
# qvac
|
|
1
|
+
# @qvac/skills
|
|
2
2
|
|
|
3
|
-
Skills for
|
|
3
|
+
Skills for QV.AC.
|
|
4
4
|
|
|
5
|
-
A skill is a directory under `skills/`
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
`*.applescript`, `cli.schema.json`.
|
|
5
|
+
A skill is a directory under `skills/` with a `SKILL.md` in it, plus whatever
|
|
6
|
+
else it needs alongside — reference notes, scripts, a schema. The `SKILL.md`
|
|
7
|
+
front matter is what the harness reads to build its catalog.
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
reads `node_modules` directly.
|
|
9
|
+
The package ships the tree itself and a bundled copy of it, so consumers that
|
|
10
|
+
can't read a filesystem still get the same bytes.
|
|
13
11
|
|
|
14
12
|
## Entry points
|
|
15
13
|
|
|
@@ -20,29 +18,22 @@ reads `node_modules` directly.
|
|
|
20
18
|
| `@qvac/skills/hash` | `SKILLS_HASH` alone | no |
|
|
21
19
|
| `@qvac/skills/skills/*` | a single file by path | no |
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
`node_modules`, so it imports `./bundled` and
|
|
21
|
+
All of them work on Node and Bare. `skillsDir` only points at anything real
|
|
22
|
+
where the package was installed to disk, though — a Bare worklet ships no
|
|
23
|
+
`node_modules`, so it imports `./bundled` and writes the tree out itself.
|
|
26
24
|
|
|
27
|
-
`
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
`SKILLS_HASH` is sha256 over the sorted `path\0content\0` stream, truncated to
|
|
32
|
-
16 hex chars. It is the content address consumers key a materialized directory
|
|
33
|
-
on, so it must match `hashBundledSkills` in the harness byte for byte.
|
|
34
|
-
|
|
35
|
-
## Release
|
|
25
|
+
`SKILLS_HASH` is a sha256 over the sorted `path\0content\0` stream, truncated
|
|
26
|
+
to 16 hex chars. Consumers key a materialized directory on it, so it has to
|
|
27
|
+
match `hashBundledSkills` in the harness byte for byte.
|
|
36
28
|
|
|
37
|
-
|
|
38
|
-
publishes to npmjs through OIDC trusted publishing, so there is no token to
|
|
39
|
-
rotate.
|
|
29
|
+
## Development
|
|
40
30
|
|
|
41
|
-
```
|
|
42
|
-
npm
|
|
31
|
+
```sh
|
|
32
|
+
npm test # node + bare
|
|
33
|
+
npm run lint
|
|
43
34
|
```
|
|
44
35
|
|
|
45
|
-
|
|
36
|
+
`bundled.js` and `hash.js` are generated from `skills/` by `scripts/build.mjs`
|
|
46
37
|
|
|
47
38
|
## License
|
|
48
39
|
|
package/bundled.d.ts
ADDED
package/bundled.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Autogenerated by scripts/build.mjs from skills/. Do not edit.
|
|
2
|
+
export { SKILLS_HASH } from './hash.js'
|
|
3
|
+
|
|
4
|
+
export const SKILLS = {
|
|
5
|
+
"apple-notes/SKILL.md": "---\nname: apple-notes\ndescription: Read, search, create, edit, append to, and delete notes in the macOS Notes app through bundled AppleScript operations.\ntools: [exec(osascript)]\nplatform: [darwin]\nmetadata:\n {\n 'openclaw':\n {\n 'requires': { 'bins': ['osascript'] },\n 'setup':\n {\n 'summary': 'Apple Notes works through macOS automation (osascript), which ships with macOS — nothing to install. The first command triggers a one-time permission prompt to let this app control Notes.',\n 'routes':\n [\n {\n 'kind': 'instructions',\n 'label': 'Allow Notes automation',\n 'description': \"macOS asks once for permission to control Notes. Commands fail until it's granted.\",\n 'steps':\n [\n 'Use the skill once — macOS shows a permission prompt to allow control of Notes.',\n 'Click Allow.',\n 'If it was denied, enable it under System Settings → Privacy & Security → Automation.'\n ]\n }\n ]\n }\n }\n }\n---\n\n# Apple Notes (osascript)\n\nDrive the macOS Notes app through the bundled AppleScript operations, one per\n`exec` call: `osascript {{SKILL_DIR}}/<file>.applescript <args...>`.\n`{{SKILL_DIR}}` resolves to this skill's absolute directory. Never use\n`osascript -e` or any other AppleScript file: the exec grant permits only the\nbundled Notes operations.\n\n## Load the Recipe File First\n\nThis file carries no commands. The working recipes live in two reference files —\nload the one for the job with the `skill` tool BEFORE calling `exec`, then copy\nits command and change only the arguments:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Finding or reading notes** — \"list my notes\", \"what notes do I have\",\n \"find / search notes about X\", \"read / open / summarize note X\": call the\n `skill` tool with `name: \"apple-notes\"` and `file: \"references/read.md\"`.\n- **Changing notes** — \"add a note\", \"add to note X\", \"rewrite / replace note\n X\", \"delete note X\": call the `skill` tool with `name: \"apple-notes\"` and\n `file: \"references/write.md\"`. It also covers how to get the note id that\n edit and delete need.\n\nMost requests are ONE command. Run that single command, read its output, then\nanswer — do not chain extra searches to \"double-check\" or enumerate. An\nempty-query search already lists every note; never enumerate notes by trying\nseveral queries.\n\n## When to Use\n\n- User explicitly mentions \"Notes\" (capital N), \"Notes app\", or \"Apple Notes\".\n- User wants a personal note that syncs to iCloud and shows up on their iPhone/iPad.\n- Note creation needs rich content (formatting, attachments) that Reminders cannot hold.\n\n## When NOT to Use\n\n- User wants a to-do or reminder (use the `apple-reminders` skill).\n- User wants a markdown vault, backlinks, or knowledge graph (use the `obsidian` skill).\n- User wants project tracking or shared docs (use Notion or another tool).\n- User just wants to dump text to a file (use the filesystem tools).\n\n## Setup\n\n- No install required. `osascript` ships with macOS.\n- Grant automation permission the first time: macOS prompts the user to allow\n the parent terminal/app to control Notes. If denied, tell the user to enable it\n under System Settings → Privacy & Security → Automation.\n\n## Output Policy\n\n- Note bodies are HTML: strip tags or convert to plain text before answering\n unless the user asked for the raw markup. If the note is empty, say so.\n- Present search results as note names; keep the raw ids for follow-up\n edit/delete calls rather than showing them to the user.\n- `execution error: Not authorized to send Apple events` → tell the user to\n grant Automation permission in System Settings.\n- If the Notes app is not open and the operation fails, tell the user to open\n Notes once (it usually auto-launches on first call).\n",
|
|
6
|
+
"apple-notes/append-note.applescript": "-- Append an HTML fragment to a note found by name. argv: noteName, htmlFragment\non run argv\n set noteName to item 1 of argv\n set extra to item 2 of argv\n tell application \"Notes\"\n set targetNote to first note whose name is noteName\n set body of targetNote to (body of targetNote) & extra\n end tell\nend run\n",
|
|
7
|
+
"apple-notes/cli.schema.json": "{\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"x-positionals\": [\"script\"],\n \"x-rest\": \"args\",\n \"x-resource-files\": [\n \"create-note.applescript\",\n \"edit-note.applescript\",\n \"append-note.applescript\",\n \"read-note.applescript\",\n \"search-notes.applescript\",\n \"delete-note.applescript\"\n ],\n \"x-resource-effects\": {\n \"read-note.applescript\": \"read\",\n \"search-notes.applescript\": \"read\"\n },\n \"required\": [\"script\", \"args\"],\n \"properties\": {\n \"script\": {\n \"type\": \"string\",\n \"description\": \"absolute path to a bundled Apple Notes operation\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"minItems\": 1,\n \"maxItems\": 3,\n \"description\": \"argv passed to the bundled script\"\n }\n }\n}\n",
|
|
8
|
+
"apple-notes/create-note.applescript": "-- Create a Notes note. argv: name, htmlBody, [folder]\n-- Notes writes the `name` property as the body's first line, so creating with\n-- both name and body renders the title twice. Create from body, then rename.\non run argv\n set noteName to item 1 of argv\n set noteBody to item 2 of argv\n tell application \"Notes\"\n if (count of argv) > 2 then\n set newNote to make new note at folder (item 3 of argv) with properties {body:noteBody}\n else\n set newNote to make new note with properties {body:noteBody}\n end if\n set name of newNote to noteName\n end tell\nend run\n",
|
|
9
|
+
"apple-notes/delete-note.applescript": "-- Delete a note by its stable CoreData id. argv: noteId\n-- Id-based, not name-based: names collide, and delete is destructive. The note\n-- moves to Recently Deleted (restorable for 30 days). Get the id from\n-- search-notes.applescript.\non run argv\n set noteId to item 1 of argv\n tell application \"Notes\"\n delete note id noteId\n end tell\nend run\n",
|
|
10
|
+
"apple-notes/edit-note.applescript": "-- Replace a note's body by CoreData id. argv: noteId, replacementHtmlBody\n-- The replacement body must include the title as its first heading; replacing\n-- body without it also replaces the note's visible title.\non run argv\n set noteId to item 1 of argv\n set replacementBody to item 2 of argv\n tell application \"Notes\"\n set body of note id noteId to replacementBody\n end tell\nend run\n",
|
|
11
|
+
"apple-notes/read-note.applescript": "-- Read a note's HTML body. argv: noteRef — a CoreData id (from search-notes,\n-- exact and preferred) or a note name. Name matching tries an exact title, then\n-- a contains match (Notes derives the title from rich text, so it can carry\n-- stray whitespace). Both paths — an id that no longer resolves and a name\n-- that matches nothing — report cleanly instead of raising an AppleScript\n-- error (-1728 / -1719) on a missing note or an empty selection.\non run argv\n set noteRef to item 1 of argv\n tell application \"Notes\"\n if noteRef starts with \"x-coredata://\" then\n -- An id copied from a truncated search line, or one whose note was\n -- deleted, no longer resolves; report cleanly rather than raising -1728.\n try\n return body of note id noteRef\n on error\n return \"No note found matching: \" & noteRef\n end try\n end if\n set matches to notes whose name is noteRef\n if (count of matches) is 0 then\n set matches to notes whose name contains noteRef\n end if\n if (count of matches) is 0 then\n return \"No note found matching: \" & noteRef\n end if\n return body of item 1 of matches\n end tell\nend run\n",
|
|
12
|
+
"apple-notes/references/read.md": "# Finding and Reading Notes\n\nTwo bundled operations cover every read request. Run one per `exec` call as\n`osascript {{SKILL_DIR}}/<file>.applescript <args...>`.\n\n| Request | Command |\n| ----------------------------------------- | --------------------------------------------------- |\n| \"list my notes\" / \"what notes do I have\" | `search-notes.applescript \"\"` — run it exactly ONCE |\n| \"find / search notes about X\" | `search-notes.applescript \"X\"` |\n| \"read / open / summarize note X\" | search first, then `read-note.applescript \"<id>\"` |\n\n## Search Notes\n\nPass a search term to `search-notes.applescript` (argv: query). It returns one\nmatch per line as `<id>\\t<name>` for every note whose name OR body contains the\nterm. Use the `name` to read a note, or the `id` to edit or delete it — this is\nthe only bundled way to obtain a note's stable id.\n\n```bash\nosascript {{SKILL_DIR}}/search-notes.applescript \"deadline\"\nosascript {{SKILL_DIR}}/search-notes.applescript \"\"\n```\n\nAn empty query (`\"\"`) matches every note, so it doubles as \"list all notes\" — a\nsingle call returns the complete list. Run search ONCE per request; do not fire\nseveral searches (`\"\"`, then `\"meeting\"`, then `\"\"` again) to enumerate or\nre-confirm. A large library can exceed the default output cap: pass\n`maxOutputChars` (up to 65536) on the exec call, and if the output still ends in\n`… [truncated]`, tell the user the list is partial rather than presenting it as\nthe whole library.\n\n## Read a Note\n\nPass a note reference to `read-note.applescript` (argv: noteRef). It returns the\nnote's HTML body. The reference is either a CoreData id or a note name:\n\n- **Prefer the id** from `search-notes.applescript` — it is exact. To read a\n specific note, search first, then read by the id from that result.\n- A **name** works too, but is a fallback: Notes derives titles from rich text,\n so exact-match can miss. Do not invent a name; use one search returned.\n\n```bash\nosascript {{SKILL_DIR}}/read-note.applescript \"x-coredata://.../ICNote/p67\"\nosascript {{SKILL_DIR}}/read-note.applescript \"Meeting Notes\"\n```\n\nWhen the user names one note, read exactly ONE note — the single best match\nfrom search — then answer and STOP. Do not read other notes to \"explore\" or\ncompare, and never walk a previous list of notes reading them one by one. Only\nread more than one note when the user explicitly asked about several.\n\nA reference that matches nothing — a name with no match, or an id that no\nlonger resolves (deleted, or copied from a truncated search line) — returns\n`No note found matching: ...` (not an error); search again and retry with the id\nfrom that result. The body is HTML; strip tags or convert to plain text before\nanswering unless the user asked for the raw markup. A long note can exceed the\ndefault output cap: pass `maxOutputChars` (up to 65536) on the exec call, and if\nthe body still ends in `… [truncated]`, tell the user it is partial rather than\nsummarizing it as complete.\n\n## Common Mistakes\n\n- Using `title of note` — Notes has no `title` property. Read or search `name`.\n- Running several searches for one request — one `search-notes \"\"` is the whole list.\n- Reading every note from a list when the user asked about one.\n",
|
|
13
|
+
"apple-notes/references/write.md": "# Creating, Editing, and Deleting Notes\n\nFour bundled operations cover every change. Run one per `exec` call as\n`osascript {{SKILL_DIR}}/<file>.applescript <args...>`.\n\n| Request | Command |\n| ------------------------------- | ---------------------------------------------------------------- |\n| \"add a note\" | `create-note.applescript \"Title\" '<h1>Title</h1>...'` |\n| \"add to note X\" | `append-note.applescript \"X\" '<p>...</p>'` |\n| \"rewrite / replace note X\" | search for its id, then `edit-note.applescript \"<id>\" '<h1>…'` |\n| \"delete note X\" | search for its id, then `delete-note.applescript \"<id>\"` |\n\n## Getting a Note's Id\n\nEdit and delete are id-based, not name-based (names collide). Get the stable\nCoreData id from `search-notes.applescript` (argv: query) — it returns one\n`<id>\\t<name>` line per matching note. Run it ONCE, then use the id.\n\n```bash\nosascript {{SKILL_DIR}}/search-notes.applescript \"Daily Log\"\n```\n\n## Create a Note\n\nApple Notes bodies are HTML, so they contain double quotes (e.g. `href=\"...\"`).\nALWAYS pass the title and body as ARGUMENTS to `create-note.applescript` (argv:\nname, body, optional folder). Wrap the HTML body in SINGLE quotes at the shell\nlevel so its double quotes need no escaping. Never embed a body inside\n`osascript -e '...'`: three nested quoting layers drop the closing `\"`/`}` and\nproduce `syntax error: Expected \"}\"` and no note.\n\nPlain note in the default folder:\n\n```bash\nosascript {{SKILL_DIR}}/create-note.applescript \"What is Bitcoin?\" '<h1>What is Bitcoin?</h1><p>Bitcoin is <strong>digital gold</strong>. See <a href=\"https://bitcoin.org/\">bitcoin.org</a>.</p>'\n```\n\nIn a specific folder (pass the folder name as a third argument):\n\n```bash\nosascript {{SKILL_DIR}}/create-note.applescript \"Sprint Planning\" '<h1>Sprint Planning</h1><p>Items:</p><ul><li>Item 1</li></ul>' \"Work\"\n```\n\nA note whose whole content is one line of text: that text IS the title, so the\nbody is the `<h1>` alone. Adding a `<p>` copy of it shows the text twice.\n\n```bash\nosascript {{SKILL_DIR}}/create-note.applescript \"Buy milk and eggs\" '<h1>Buy milk and eggs</h1>'\n```\n\nRules:\n\n- Use `<h1>` for the title (it shows as the note's first line), `<p>` for\n paragraphs, `<ul>/<li>` for lists, `<b>/<i>` for emphasis. The `name`\n argument names the note without adding a line to the body.\n- Write every piece of content ONCE. Never follow the `<h1>` title with a `<p>`\n that restates it — the note then shows the same text twice.\n- The HTML body MUST be wrapped in single quotes `'...'`. HTML attribute quotes\n (`\"`) are then safe and need NO backslash escaping.\n- If the body must contain an apostrophe `'`, close-escape-reopen it:\n `...don'\\''t...`.\n\n## Append to a Note\n\nPass the note name and the HTML fragment to append to\n`append-note.applescript` (argv: noteName, htmlFragment):\n\n```bash\nosascript {{SKILL_DIR}}/append-note.applescript \"Daily Log\" '<p>New entry: ...</p>'\n```\n\n## Edit a Note\n\nGet the id first (see above), then pass the id and the COMPLETE replacement\nbody to `edit-note.applescript` (argv: noteId, replacementBody):\n\n```bash\nosascript {{SKILL_DIR}}/edit-note.applescript \"x-coredata://.../ICNote/p67\" '<h1>Daily Log</h1><p>Updated content</p>'\n```\n\nThe replacement body must include the title as its first heading or line;\nreplacing `body` without it also replaces the note's visible title.\n\n## Delete a Note\n\nGet the id first (see above), then pass it to `delete-note.applescript` (argv:\nnoteId):\n\n```bash\nosascript {{SKILL_DIR}}/delete-note.applescript \"x-coredata://.../ICNote/p67\"\n```\n\nDeletion moves the note to Recently Deleted; the user can restore it from Notes\nfor 30 days. Confirm the target with the user before deleting — there is no undo\nprompt.\n\n## Common Mistakes\n\n- Treating the body as plain text — it is HTML. Use HTML tags in the body argument.\n- Repeating the title inside the body — write it once as the leading `<h1>`.\n- Embedding an HTML body inside `osascript -e '...'` — the nested quotes break.\n Use the bundled script and pass name/body as arguments.\n- Forgetting to wrap the HTML body argument in single quotes — its `\"` attribute\n quotes will then break the shell command.\n- Editing or deleting by name — both need the id from `search-notes.applescript`.\n",
|
|
14
|
+
"apple-notes/search-notes.applescript": "-- Find notes whose name or body contains a term. argv: query\n-- An empty query returns EVERY note (list-all) — AppleScript's `contains \"\"` is\n-- false, so the empty case is handled explicitly rather than via the filter.\n-- Emits one match per line as `id<tab>name`, so the caller can then read by\n-- name or edit/delete by the stable id (there is no other bundled way to get\n-- a note's id). The query binds as data via argv, never into the script text.\non run argv\n set q to item 1 of argv\n set out to \"\"\n tell application \"Notes\"\n if q is \"\" then\n set matches to every note\n else\n set matches to every note whose name contains q or body contains q\n end if\n repeat with n in matches\n set out to out & (id of n) & tab & (name of n) & linefeed\n end repeat\n end tell\n return out\nend run\n",
|
|
15
|
+
"apple-reminders/SKILL.md": "---\nname: apple-reminders\ndescription: Manage Apple Reminders through the local remindctl CLI — view, create, complete, and delete reminders, and manage lists, with date filters and JSON/plain output. macOS only.\ntools: [exec(remindctl)]\nplatform: [darwin]\nmetadata:\n {\n \"openclaw\":\n {\n \"requires\": { \"bins\": [\"remindctl\"] },\n \"install\":\n [\n {\n \"id\": \"homebrew\",\n \"kind\": \"shell\",\n \"bins\": [\"brew\"],\n \"manual\": true,\n \"label\": \"Install Homebrew\",\n \"command\": \"/bin/bash -c \\\"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\\\"\"\n },\n {\n \"id\": \"brew\",\n \"kind\": \"brew\",\n \"formula\": \"steipete/tap/remindctl\",\n \"bins\": [\"remindctl\"],\n \"label\": \"Install remindctl via Homebrew\"\n },\n {\n \"id\": \"authorize\",\n \"kind\": \"shell\",\n \"command\": \"remindctl authorize\",\n \"label\": \"Grant Reminders access\"\n }\n ],\n \"setup\":\n {\n \"summary\": \"Apple Reminders works through the remindctl CLI. Install it with Homebrew, then grant Reminders access once.\",\n \"routes\":\n [\n {\n \"kind\": \"install\",\n \"label\": \"Install remindctl\",\n \"helpUrl\": \"https://github.com/steipete/remindctl\",\n \"steps\":\n [\n \"Install Homebrew if you don't have it.\",\n \"Run: brew install steipete/tap/remindctl\",\n \"Run: remindctl authorize and allow Reminders access.\",\n \"Reopen this Skills page — apple-reminders should show as ready.\"\n ]\n }\n ]\n }\n }\n }\n---\n\n# Apple Reminders\n\nManage Apple Reminders through the local `remindctl` CLI. The CLI talks to the\nmacOS Reminders app and its lists, so reminders created here sync to the user's\niPhone and iPad.\n\n## Load the Recipe File First\n\nThis file carries no commands. The working commands live in two reference files\n— load the one for the job with the `skill` tool BEFORE calling `exec`, then\ncopy its command and change only the arguments:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Viewing reminders and lists** — \"what are my reminders\", \"what's due\n today / tomorrow / this week\", \"what's overdue\", \"show my lists\", \"show my\n Work reminders\": call the `skill` tool with `name: \"apple-reminders\"` and\n `file: \"references/view.md\"`.\n- **Changing reminders and lists** — \"remind me to X\", \"add X to my Y list\",\n \"mark N done\", \"delete reminder\", \"create / delete a list\": call the `skill`\n tool with `name: \"apple-reminders\"` and `file: \"references/edit.md\"`. It also\n covers finding the id a complete or delete needs.\n\nMost requests are ONE command. Run that single command with `exec`, then answer\nfrom its output.\n\n## Always Use the remindctl CLI — Never Shell Out\n\nReminders live in the macOS Reminders database. ALWAYS view and edit them with\n`remindctl` commands via the `exec` tool. NEVER use `cat`, `ls`, `find`, `grep`,\n`sqlite3`, filesystem paths, or AppleScript to reach the Reminders store — those\nbypass `remindctl` and are wrong even when they appear to work. If a `remindctl`\ncommand fails, correct its arguments and retry the `remindctl` command; do not\nswitch to shell or file tools.\n\nUse `exec` only, one `remindctl` command per call. Do not chain with `&&`, `;`,\nor pipes.\n\n## When to Use\n\n- The user explicitly mentions \"reminder\" or the \"Reminders app\".\n- Creating personal to-dos with due dates that should sync to iPhone/iPad.\n- Viewing or managing Apple Reminders lists.\n\n## When NOT to Use\n\n- Calendar events or appointments — those are not Reminders.\n- Project or work task tracking — use Notion, GitHub Issues, or the task queue.\n- The user says \"remind me\" but means a local alert or notification in this\n app, not the Reminders app — ask: \"Do you want this in Apple Reminders (syncs\n to your phone) or as a local alert here?\" and use this skill only for the\n Apple Reminders answer.\n- Notes — use Obsidian, Notion, or Apple Notes.\n\n## Setup and Availability\n\n- macOS only. On any other platform, report that Apple Reminders is unavailable\n and stop.\n- Requires the `remindctl` binary: `brew install steipete/tap/remindctl`.\n- The Reminders app must have granted access. Check with `remindctl status`;\n request access with `remindctl authorize`.\n- If a command fails because `remindctl` is missing or access is not granted,\n report that clearly and ask the user to install or authorize — do not fall\n back to another tool.\n\n## Output Policy\n\n- Keep results small: the reminder title, its list, and its due date.\n- After a successful command, finish with a concise visible answer.\n- Do not dump every list or the full reminder database unless the user\n explicitly asks.\n",
|
|
16
|
+
"apple-reminders/cli.schema.json": "{\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"x-positionals\": [\n \"date\"\n ],\n \"properties\": {\n \"date\": {\n \"type\": \"string\"\n },\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n },\n \"today\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"tomorrow\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"week\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"overdue\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"all\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"list\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"create\": {\n \"type\": \"boolean\"\n },\n \"delete\": {\n \"type\": \"boolean\"\n },\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-positionals\": [\n \"name\"\n ]\n },\n \"add\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"list\": {\n \"type\": \"string\"\n },\n \"due\": {\n \"type\": \"string\"\n },\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-positionals\": [\n \"title\"\n ]\n },\n \"complete\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"ids\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"x-rest\": \"ids\"\n },\n \"delete\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"force\": {\n \"type\": \"boolean\"\n },\n \"ids\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"x-positionals\": [\n \"id\"\n ],\n \"x-rest\": \"ids\"\n },\n \"status\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {},\n \"x-effect\": \"read\"\n },\n \"authorize\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {}\n }\n }\n}\n",
|
|
17
|
+
"apple-reminders/references/edit.md": "# Creating, Completing, and Deleting Reminders\n\nOne `remindctl` command per `exec` call, no chaining. Run `add` in the\nforeground (the default): the exit code confirms whether the reminder was\ncreated — never claim success unless the command succeeded.\n\n| Request | Command |\n| -------------------------------- | ------------------------------------------------ |\n| \"remind me to X\" | `remindctl add --title \"X\"` |\n| \"remind me to X tomorrow\" | `remindctl add --title \"X\" --due tomorrow` |\n| \"add X to my <List> list\" | `remindctl add --title \"X\" --list Personal` |\n| \"mark X done\" | `remindctl all --json` for its id, then `remindctl complete <id>` |\n| \"delete reminder X\" | `remindctl all --json` for its id, then `remindctl delete <id> --force` |\n| \"create a list called X\" | `remindctl list X --create` |\n| \"delete the list X\" | `remindctl list X --delete` |\n\n## Create reminders\n\n```bash\nremindctl add \"Buy milk\"\nremindctl add --title \"Call mom\" --list Personal --due tomorrow\nremindctl add --title \"Meeting prep\" --due \"2026-02-15 09:00\"\n```\n\n`--due` accepts `today`, `tomorrow`, `YYYY-MM-DD`, `YYYY-MM-DD HH:mm`, and ISO\n8601 (`2026-01-04T12:34:56Z`). Without `--list` the reminder lands in the\ndefault list.\n\n## Finding the id\n\n`complete` and `delete` take reminder ids, and there is NO search command —\n`remindctl search`, `remindctl show`, `remindctl find` all fail. Get the id\nfrom a JSON view: `remindctl all --json` lists every reminder in every list,\nincluding completed ones, one object per reminder with `id`, `title`,\n`listName`, `dueDate`, `isCompleted`. Pick the entry whose `title` matches\nwhat the user named and copy its `id` (a short prefix like `4A83` is enough).\nNarrow with `remindctl list <Name> --json` or `remindctl today --json` when the\nlist or day is known.\n\n```bash\nremindctl all --json\nremindctl list Personal --json\n```\n\n## Complete / delete\n\n```bash\nremindctl complete 4A83\nremindctl complete 1 2 3\nremindctl delete 4A83 --force\n```\n\nOnly delete a reminder when the user explicitly asks; prefer `complete` for\nfinished to-dos. `--force` skips the confirmation prompt, which cannot be\nanswered from `exec`.\n\n## Lists\n\n```bash\nremindctl list Projects --create\nremindctl list Work --delete\n```\n\n## Answering\n\nOnly the commands shown in this file exist; if one fails, fix its arguments\nrather than inventing another subcommand. Confirm with the title, list, and due date of what changed. If the command\nfails because `remindctl` is missing or access is denied, report that and ask\nthe user to install or authorize; never switch to another tool.\n",
|
|
18
|
+
"apple-reminders/references/view.md": "# Viewing Reminders and Lists\n\nOne `remindctl` command per `exec` call, no chaining. Pick the command from the\nrequest and answer from its output.\n\n| Request | Command |\n| ---------------------------------------- | --------------------------- |\n| \"what are my reminders\" / \"due today\" | `remindctl today` |\n| \"what's due tomorrow\" / \"this week\" | `remindctl tomorrow` · `remindctl week` |\n| \"what's overdue\" | `remindctl overdue` |\n| \"everything\" / \"all my reminders\" | `remindctl all` |\n| \"what's due on <date>\" | `remindctl 2026-01-04` |\n| \"show my reminder lists\" | `remindctl list` |\n| \"show my <List> reminders\" | `remindctl list Work` |\n\n## View by date\n\n```bash\nremindctl today\nremindctl tomorrow\nremindctl week\nremindctl overdue\nremindctl all\nremindctl 2026-01-04\n```\n\nDate filters accept `today`, `tomorrow`, `yesterday`, `YYYY-MM-DD`,\n`YYYY-MM-DD HH:mm`, and ISO 8601 (`2026-01-04T12:34:56Z`).\n\n## Lists\n\n```bash\nremindctl list\nremindctl list Work\n```\n\n`remindctl list` alone names the lists; `remindctl list <Name>` shows the\nreminders in that list, including ones without a due date.\n\n## Output formats\n\n```bash\nremindctl today --json\nremindctl today --plain\nremindctl today --quiet\n```\n\nUse `--json` when you need structured fields (ids, due dates) before a\nfollow-up command; the `id` field is what `complete` and `delete` take.\n`remindctl all --json` covers every list including completed reminders. There\nis no search command — filter the JSON view by `title` yourself.\n\n## Answering\n\nReport the title, its list, and its due date for each reminder — not the whole\ndatabase. An empty result means nothing is due: say so plainly. If the command\nfails because `remindctl` is missing or access is denied, report that and ask\nthe user to install or authorize; never switch to another tool.\n",
|
|
19
|
+
"asana/SKILL.md": "---\nname: asana\ndescription: Asana tasks, projects, and comments via the official Asana MCP server (OAuth). Also handles pasted app.asana.com task links.\ntools: [mcp_call]\nplatform: [darwin, linux, win32]\ncredentials: [asana_mcp_access_token]\nallow_list: [https://mcp.asana.com/v2/mcp]\nmcp_reads: [get_task, get_tasks, get_my_tasks, get_projects, search_tasks, search_objects]\n---\n\n# Asana\n\nOne wired transport: the official Asana MCP server (v2), authorized with a pre-registered Asana OAuth app.\n\nUse the `mcp_call` tool against `https://mcp.asana.com/v2/mcp`. Authentication uses a pre-registered Asana MCP app (client ID and secret) with PKCE — the user connects \"Asana\" from the Asana skill's setup, which stores the access token under the credential key `asana_mcp_access_token`. If the credential is connected, act immediately and call the tool(s) — do NOT ask the user for a token. If the credential is missing (a tool result reports it), tell the user to connect \"Asana\"; do not attempt to drive OAuth yourself.\n\nSessions are automatic: `mcp_call` runs the initialize handshake itself and threads the session for you. Do NOT call `initialize` or `notifications/initialized`, and do NOT pass `sessionId`.\n\nIf a call returns `Status: 401`, the token is expired/invalid — tell the user to reconnect \"Asana\" from the Asana skill's setup.\n\n## Workflow\n\nPass the **tool name as `method`** and the **tool's args as `params`** directly. The MCP `url` must be a top-level field beside `method` and `params` — never put `url` inside `params`. Do NOT build a `{ \"name\": …, \"arguments\": … }` envelope, and do NOT pass `tokenCredentialKey` — the runtime wraps the envelope and injects the bearer token by host for you.\n\nV2's tool set evolves. Call `tools/list` once before the first Asana operation in a conversation, then use the exact returned name and schema. Common tools include:\n\n| tool | use for |\n| --- | --- |\n| `get_my_tasks` | list tasks assigned to the connected user |\n| `get_tasks` / `search_tasks` | list or search tasks |\n| `get_task` | fetch one task |\n| `create_tasks` / `update_tasks` | create or update tasks |\n| `search_objects` | find projects, users, teams, tags, or portfolios |\n| `get_projects` | list projects in the authorized workspace |\n\nIf a tool is absent, do not invent or fall back to a V1 `asana_*` name. Explain the V2 limitation. If a call returns an input validation error, use the `tools/list` schema and retry the same tool once.\n\n### Example: list my tasks\n\n```json\n{\n \"url\": \"https://mcp.asana.com/v2/mcp\",\n \"method\": \"get_my_tasks\",\n \"params\": {}\n}\n```\n\n## Tool choice and pasted links\n\n- Use `mcp_call` only. NEVER use `http_request` for Asana, and NEVER fetch `app.asana.com` URLs — they serve the browser login page, not data.\n- When the user pastes an Asana link, extract the task gid and pass it to the matching MCP tool: the number after `/task/` (`…/project/<p>/task/1215448540812360` → `1215448540812360`), or the last path segment in the older `app.asana.com/0/<project>/<task>` form.\n\n## Output Policy\n\n- Always quote the task `gid` when reporting tasks back so follow-up actions stay deterministic.\n- Surface workspace and project names, not just GIDs, when human-friendly.\n- For list responses, show name, assignee, due date, and completion state. Fetch full detail only on request.\n- Confirm with the user before deleting anything — task deletion is permanent and takes subtasks with it.\n- Do not ask for or echo credentials in chat. If a tool result reports the credential is missing, tell the user to connect \"Asana\" from the Asana skill's setup.\n",
|
|
20
|
+
"diagrams/SKILL.md": "---\nname: diagrams\ndescription: Draw diagrams in the chat - flowcharts, sequence, state and ER diagrams, Gantt charts, pie charts, mindmaps and timelines - written as Mermaid code blocks the app renders. Use when the user asks to draw, diagram, sketch, chart, plan, visualize, or map a process, flow, schedule, architecture, or relationship.\naliases: [diagram, mermaid, flowchart, mindmap]\nplatform: [darwin, linux, win32, ios, android]\n---\n\n# Diagrams\n\nDraw a diagram by writing one ```mermaid code block. The app renders it as a\npicture automatically - never describe the rendering, never apologize about\nbeing text-only, never paste ASCII art. A diagram, chart, or plan is ONLY a\nMermaid block in your reply: never run `exec`, Python, matplotlib, or fpdf2,\nnever call an image tool, and never deliver it as a PDF or image file.\n\nYour reply STARTS with the fence. No preamble, no plan, no \"I'll create a\ndiagram showing...\" - never announce or describe a diagram instead of drawing\nit. Decide the type silently, load its recipe, write the code block, close its\nfence, then add one short sentence saying what it shows - after the closing\nfence, never inside it. A complete reply looks like this:\n\n```mermaid\nflowchart TD\n A[\"User sends a message\"] --> B{\"Needs a tool?\"}\n B -->|yes| C[\"Run the tool\"]\n B -->|no| D[\"Answer directly\"]\n```\n\nFlow of a message through the assistant.\n\n## Pick the Type, Then Load Its Recipe\n\nEach type's syntax lives in its own reference file, named by the Mermaid\nkeyword. Pick the type from the ask, then call the `skill` tool with\n`name: \"diagrams\"` and `file: \"references/<type>.md\"` in the SAME turn you\ndraw, BEFORE writing the fence - even when you drew another diagram earlier in\nthis chat. Copy the recipe's syntax exactly; a diagram written from memory is\nthe usual cause of a parse error. Each load is a real `skill` tool call -\nprinting the call as JSON or text in your reply loads nothing. After the load,\nreply with the fenced block directly: no further tool calls of any kind.\n\n| The ask | Type | File |\n| ---------------------------------------------------- | ------------------- | --------------------------- |\n| steps, decisions, a process, \"map / structure this\" | `flowchart TD` | `references/flowchart.md` |\n| a pipeline left to right | `flowchart LR` | `references/flowchart.md` |\n| a family tree, org chart, reporting lines | `flowchart TD` | `references/flowchart.md` |\n| who calls whom over time, requests and replies | `sequenceDiagram` | `references/sequence.md` |\n| modes and transitions | `stateDiagram-v2` | `references/state.md` |\n| tables and their relations | `erDiagram` | `references/er.md` |\n| code types, classes, inheritance | `classDiagram` | `references/class.md` |\n| a schedule or plan with durations | `gantt` | `references/gantt.md` |\n| dated events in order | `timeline` | `references/timeline.md` |\n| shares of a whole, percentages | `pie` | `references/pie.md` |\n| a brainstorm, idea tree, \"mindmap\" | `mindmap` | `references/mindmap.md` |\n\nUse `gitGraph` ONLY when the user names it. Never use experimental or beta\ndiagram types, and never invent a keyword a recipe does not show.\n\n## Hard Rules (every type)\n\n- One diagram per code block, and the fence language is exactly `mermaid`. The\n first line inside the fence is the type keyword from the table. Default to\n ONE block; use two only when the recipe's budget forces an overview plus one\n detail. Never more than two.\n- Except in mindmaps, node ids are letters, digits, and underscores, starting\n with a letter, never reused. Mindmap nodes have no ids.\n- Except in mindmaps, every label with a space, punctuation, or brackets goes\n in double quotes: `A[\"Send request (HTTP)\"]`. Never leave bare `()[]{}` `:`\n `;` inside a label. Mindmap labels are unquoted text.\n- Never write lowercase `end` as a node or label - write `\"End\"`. Line breaks\n inside a label are `<br/>`, never `\\n`.\n- No `%%{init}%%` directives, no `%%` comments, no `classDef` or `style`\n lines. The app themes the diagram itself.\n- Keep every label at 40 characters or fewer, one node per concept, with its\n attributes inside that node's label. Dates, roles, counts, and statuses are\n never standalone nodes.\n- Syntax in one line per type, so a skimmed recipe still lands: flowchart\n `A[\"x\"] --> B{\"y?\"}` with `-->|yes|` labels; sequence `A->>B: msg` and\n `B-->>A: reply`; state `[*] --> Idle` and `Idle --> Run : start`; pie\n `\"Sleep\" : 8` (quoted label, plain number); gantt `Name :id, 2026-09-01, 5d`\n or `after id`; timeline `2024 : Event`; mindmap `root[Topic]` then every\n other line indented deeper than the root, unquoted, no ids (a line at the\n root's indentation is a second root and fails).\n\n## When NOT to use\n\n- Images, scenes, logos, or anything artistic - use the `image-generation`\n skill instead.\n- Plots of numeric data - use `pie` for shares, otherwise a markdown table.\n- Family trees, org charts, and reporting lines - `flowchart TD`, not\n `mindmap`; a mindmap is for ideas around a topic, not people in a hierarchy.\n\n## If the diagram fails\n\nWhen a diagram cannot render, the app sends its parse error back to you once\non its own, and the user may send it again. Re-load the type's recipe and fix\nby rewriting the ENTIRE code block, never a partial patch.\n\n| Error contains | Fix |\n| --- | --- |\n| `Lexical error` / `Unrecognized text` | Remove every backslash in front of a quote and rewrite the block |\n| `Expecting 'taskData'` | A gantt line is neither a header keyword nor a complete `Name :id, start, Nd` task - fix it or delete it |\n| `Expecting ...` at a flowchart or sequence label | Put the whole label in double quotes |\n| `got 'end'` | Rename the node label to `\"End\"` |\n| `Maximum text size` or edge limit | Shrink the diagram or split it in two |\n| `No diagram type detected` | Start the fence with one type keyword from the table |\n| `Duplicate id` | Give every node a fresh unique id |\n",
|
|
21
|
+
"diagrams/references/class.md": "# Class Diagram\n\nFor code types: classes, their members, inheritance.\n\nBudget: 7 classes.\n\n```mermaid\nclassDiagram\n class Animal {\n +String name\n +speak()\n }\n Animal <|-- Dog\n```\n\nRules:\n\n- Members go inside `class Name { }`, one per line, `+` public and `-`\n private, methods end with `()`.\n- Inheritance is `Parent <|-- Child`; composition `Whole *-- Part`;\n association `A --> B`.\n- Class names are single tokens; no quotes, no `classDef` lines.\n\n## Now draw\n\nThis recipe is all you need. Your next output is the reply itself: one fenced\nMermaid code block (fence language `mermaid`), then one sentence. Do not call\nany tool - not `exec`, not `skill` again, not an image tool. A tool call here\nmeans the diagram was never drawn.\n",
|
|
22
|
+
"diagrams/references/er.md": "# ER Diagram\n\nFor tables and their relations.\n\nBudget: 8 entities.\n\n```mermaid\nerDiagram\n USER ||--o{ ORDER : \"places\"\n ORDER ||--|{ LINE_ITEM : \"contains\"\n```\n\nRules:\n\n- Entities are UPPER_CASE single tokens.\n- `||--o{` reads \"one to zero-or-many\"; `||--|{` \"one to one-or-many\";\n `||--||` \"one to one\".\n- The relationship label follows the colon in plain double quotes typed directly (no backslash in front).\n- Attributes are optional; if used, list them inside `ENTITY { string name }`\n blocks with one `type name` per line.\n\n## Now draw\n\nThis recipe is all you need. Your next output is the reply itself: one fenced\nMermaid code block (fence language `mermaid`), then one sentence. Do not call\nany tool - not `exec`, not `skill` again, not an image tool. A tool call here\nmeans the diagram was never drawn.\n",
|
|
23
|
+
"diagrams/references/flowchart.md": "# Flowchart\n\nFor steps, decisions, processes, pipelines, and any \"map this / structure\nthis\" ask - also family trees, org charts, and reporting lines. `flowchart TD`\nreads top-down; `flowchart LR` left-to-right for a pipeline.\n\nBudget: 12 nodes and 16 edges. Over budget, simplify; if the detail is\nessential, one overview block plus one detail block, never more.\n\n```mermaid\nflowchart LR\n A[\"Request\"] --> B{\"Valid?\"}\n B -->|yes| C[\"Process\"]\n B -->|no| D[\"Reject with error\"]\n C --> E[\"Respond\"]\n```\n\nRules:\n\n- Nodes are `id[\"Label\"]`; decisions are `id{\"Question?\"}` diamonds.\n- Arrows are always `-->`; the label form is `-->|yes|`. Never `->>` here.\n- Ids: letters, digits, underscores, starting with a letter, never reused.\n- Every label goes in plain double quotes `\"`, 40 characters or\n fewer, `<br/>` for a line break. Never write lowercase `end` - use `\"End\"`.\n- One node per person or concept, details inside its label; no separate nodes\n for dates, roles, counts, or statuses.\n\n## Now draw\n\nThis recipe is all you need. Your next output is the reply itself: one fenced\nMermaid code block (fence language `mermaid`), then one sentence. Do not call\nany tool - not `exec`, not `skill` again, not an image tool. A tool call here\nmeans the diagram was never drawn.\n",
|
|
24
|
+
"diagrams/references/gantt.md": "# Gantt Chart\n\nFor a schedule or plan with durations.\n\nBudget: 12 tasks across 4 sections.\n\n```mermaid\ngantt\n title MVP delivery\n dateFormat YYYY-MM-DD\n excludes weekends\n section Planning\n Requirements :a1, 2026-09-01, 5d\n Design :a2, after a1, 7d\n section Build\n Core platform :b1, after a2, 21d\n API :b2, after a2, 17d\n section Launch\n Release :c1, after b1, 2d\n```\n\nRules:\n\n- The only header lines are `title`, `dateFormat YYYY-MM-DD`, `axisFormat`,\n `excludes weekends`, and `section Name`. Nothing else is a header.\n- EVERY other line is a task and must be `Name :id, start, duration` - start\n is a date or `after otherId`, duration is like `5d` or `2w`. A line that is\n not one of these fails with `Expecting 'taskData'`.\n- No colons inside task names, no quotes, no arrows.\n- Ids are unique short tokens (`a1`, `b2`); `after` may name several ids\n separated by spaces.\n\n## Now draw\n\nThis recipe is all you need. Your next output is the reply itself: one fenced\nMermaid code block (fence language `mermaid`), then one sentence. Do not call\nany tool - not `exec`, not `skill` again, not an image tool. A tool call here\nmeans the diagram was never drawn.\n",
|
|
25
|
+
"diagrams/references/mindmap.md": "# Mindmap\n\nFor a brainstorm or idea tree around one topic. Not for people in a hierarchy\n- that is a `flowchart TD`.\n\nBudget: 3 levels, 10 nodes, 2-4 balanced top-level branches.\n\n```mermaid\nmindmap\n root[Launch plan]\n Marketing\n Blog post\n Social campaign\n Engineering\n Release build\n Monitoring\n```\n\nRules:\n\n- The first line is `mindmap`; the second is the rectangular `root[Topic]`.\n- One node per line, two more spaces of indentation per level; the tree shape\n comes only from indentation. Every line after `root[...]` is indented deeper\n than the root - a line at the root's indentation is a second root and fails\n with `There can be only one root`.\n- Labels are unquoted plain text with no ids, no quotes, no arrows,\n no brackets except the `root[...]` form.\n- One node per item with its details in that node; no attribute leaves.\n\n## Now draw\n\nThis recipe is all you need. Your next output is the reply itself: one fenced\nMermaid code block (fence language `mermaid`), then one sentence. Do not call\nany tool - not `exec`, not `skill` again, not an image tool. A tool call here\nmeans the diagram was never drawn.\n",
|
|
26
|
+
"diagrams/references/pie.md": "# Pie Chart\n\nFor shares of a whole: hours in a day, budget split, percentages.\n\nBudget: 8 slices; combine the remainder as `\"Other\"`.\n\n```mermaid\npie title Time spent\n \"Coding\" : 60\n \"Review\" : 25\n \"Meetings\" : 15\n```\n\nRules:\n\n- The first line is `pie title Your title` (or just `pie`).\n- Every slice is `\"Label\" : number` - the label in plain double quotes typed\n directly (never unquoted, never with a backslash in front), then a\n space-colon-space, then a plain number with no unit or `%` sign.\n- One slice per line; values need not add up to 100.\n\n## Now draw\n\nThis recipe is all you need. Your next output is the reply itself: one fenced\nMermaid code block (fence language `mermaid`), then one sentence. Do not call\nany tool - not `exec`, not `skill` again, not an image tool. A tool call here\nmeans the diagram was never drawn.\n",
|
|
27
|
+
"diagrams/references/sequence.md": "# Sequence Diagram\n\nFor who calls whom over time: requests and replies between an app, a server,\na browser, a service.\n\nBudget: 5 participants and 12 messages.\n\n```mermaid\nsequenceDiagram\n participant App\n participant Server\n App->>Server: POST /login\n Server-->>App: 200 with token\n Note over App: stores the token\n```\n\nRules:\n\n- Declare every `participant` first, as a bare name without quotes.\n- A call is `A->>B: message`; a reply is `B-->>A: message`. Never use the\n flowchart `-->` arrow here.\n- A note is `Note over A: text` (or `Note over A,B: text`).\n- Message text follows the colon unquoted; keep it short. No backslashes anywhere.\n- No ids, no brackets, no `end` except to close a Mermaid `loop`/`alt` block\n you opened.\n\n## Now draw\n\nThis recipe is all you need. Your next output is the reply itself: one fenced\nMermaid code block (fence language `mermaid`), then one sentence. Do not call\nany tool - not `exec`, not `skill` again, not an image tool. A tool call here\nmeans the diagram was never drawn.\n",
|
|
28
|
+
"diagrams/references/state.md": "# State Diagram\n\nFor modes and transitions: a device, a job, a session moving between states.\n\nBudget: 8 states.\n\n```mermaid\nstateDiagram-v2\n [*] --> Idle\n Idle --> Running : start\n Running --> Idle : stop\n Running --> [*] : shutdown\n```\n\nRules:\n\n- The first line is exactly `stateDiagram-v2`.\n- `[*]` is both the start and the end marker.\n- A transition is `From --> To : label`; the label is optional and follows a\n colon, unquoted.\n- State names are single words or `snake_case`; for a spaced display name use\n `state \"Waiting for input\" as Waiting` once, then `Waiting` everywhere.\n- No backslashes, no `classDef`, no `%%` lines.\n\n## Now draw\n\nThis recipe is all you need. Your next output is the reply itself: one fenced\nMermaid code block (fence language `mermaid`), then one sentence. Do not call\nany tool - not `exec`, not `skill` again, not an image tool. A tool call here\nmeans the diagram was never drawn.\n",
|
|
29
|
+
"diagrams/references/timeline.md": "# Timeline\n\nFor dated events in order: a history, a roadmap already dated.\n\nBudget: 8 events.\n\n```mermaid\ntimeline\n title Product history\n 2024 : Prototype\n 2025 : Public beta : First paying customers\n 2026 : Version 1.0\n```\n\nRules:\n\n- `title` first, then one line per period as `period : event`, with another\n ` : event` for each extra event in the same period.\n- Periods are years, dates, or short phase names; events are short unquoted\n text.\n- No arrows, no ids, no quotes.\n\n## Now draw\n\nThis recipe is all you need. Your next output is the reply itself: one fenced\nMermaid code block (fence language `mermaid`), then one sentence. Do not call\nany tool - not `exec`, not `skill` again, not an image tool. A tool call here\nmeans the diagram was never drawn.\n",
|
|
30
|
+
"excel/SKILL.md": "---\nname: excel\ndescription: Create, edit, or read Excel spreadsheets (.xlsx) with openpyxl — deliver workbooks as chat attachments, or read an attached one to summarize it or answer questions in the chat. Computes values in Python; can also write live formulas and embed images. Opens in Numbers and Google Sheets too.\naliases: [xlsx, spreadsheet, workbook]\ntools: [exec(python)]\nplatform: [darwin, linux, win32]\nmetadata:\n {\n \"openclaw\":\n {\n \"setup\":\n {\n \"summary\": \"Runs openpyxl in the in-process Python runtime, from packages that ship with the app. The first use waits for the runtime to start.\"\n }\n }\n }\n---\n\n# Excel\n\nBuild or edit an `.xlsx` workbook by running openpyxl through the `exec` tool with\n`language: \"python\"`. Declare the workbook in `outputs` and it comes back as a chat\nattachment the user can save. To answer *from* a workbook instead of building one,\nrun a read call — no `outputs` — and reply in the chat.\n\n## Load the Recipe File First\n\nThis file contains no Python. The working recipes live in three reference files —\nload the one for the job with the `skill` tool BEFORE writing any Python, then\ncopy its recipe and change the content:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Creating a new workbook** (no existing file involved, including one that embeds\n an image): call the `skill` tool with `name: \"excel\"` and\n `file: \"references/create.md\"`.\n- **Editing an attached workbook** (change cells, add/insert/delete rows, columns or\n sheets, or embed an image into one that already exists): call the `skill` tool with\n `name: \"excel\"` and `file: \"references/edit.md\"`.\n- **Answering from an attached workbook** (a summary, a question answered,\n values pulled into the chat — the deliverable is your reply, not a file): call\n the `skill` tool with `name: \"excel\"` and `file: \"references/read.md\"`.\n- **Read then build** (e.g. \"summarize this workbook into a new file\"): load\n `references/read.md` and the create or edit file — the read call runs first.\n\nNever write the Python from memory. The recipes carry required patterns (the\nfill-in template, staging rules, guard asserts) that fail in non-obvious ways\nwhen improvised; loading the file is one cheap read-only call.\n\n## When to Use\n\n- The user asks for a spreadsheet, workbook, `.xlsx`, Excel, or Numbers/Sheets-openable file.\n- The user attaches a spreadsheet and wants cells changed, rows/columns/sheets added or removed, or data extracted from it.\n- The user attaches a spreadsheet and asks what it holds — a summary, a question\n answered, or values pulled out into the chat.\n- The user wants a workbook that embeds an image — one generated in this chat or\n one they uploaded. Both recipe files carry it.\n\n## When NOT to Use\n\n- The user wants a table in the chat built from content already in the\n conversation — no workbook involved — write a markdown table. Answering or\n summarizing from an attached workbook **is** this skill: load\n `references/read.md`.\n- The user wants a comma-separated text file only — write the `.csv` directly with Python's `csv` module (`.csv` is an allowed output), no openpyxl needed.\n\n## Values vs Formulas — decide before writing\n\nThis runtime has no spreadsheet engine: openpyxl writes a formula as text and\ncomputes nothing. A formula cell has **no value** until the user opens the file\nin a spreadsheet app and it recalculates. So pick the mode from what the user\nwants:\n\n- **They want numbers** (a report, totals, statistics, cleaned data): compute in\n Python and write **literal values**. This is the default.\n- **They want a live spreadsheet** (totals that update when they edit cells):\n write formulas — and say so in the reply, because the formula cells look empty\n in the chat preview: \"the total is a live formula, so it shows up once you\n open the file in Excel, Numbers, or Sheets.\"\n- Never write a formula and then read it back expecting a number, and never\n \"verify\" a formula by reloading the file — there is nothing to verify.\n\nWriting both is fine: literal values everywhere, plus a `=SUM(...)` total row if\nthe user wants it to stay live.\n\n## Rules for Every Job\n\n**You build it, not the user.** Deliver the workbook, never the recipe. Do NOT\nprint the python source in chat, do NOT tell the user to install openpyxl, run\na script, or open a terminal — they have no terminal in this chat and the code\nwould not run there. The workbook exists only if an `exec` call with `outputs`\nsucceeds and returns the attachment.\n\n**Success = stop.** When `exitCode` is `0` and `attachments` lists the `.xlsx`,\nthe workbook is done — do not call `exec` again, not to \"confirm\", not to\n\"improve\", not to reload the file to \"check the formulas\". Exactly one\nsuccessful *build* call per request (a no-`outputs` read that precedes a build\ndelivers nothing and is not one of them, but it belongs before the build, never\nafter). Reply with a single line: file name + the sheet/row summary from stdout.\nIf the result has `missingOutputs`, read stderr first: an `AssertionError` there\nmeans a guard stopped the save on purpose and its message names what to fix;\nonly when stderr is clean check the `save()` name matches the declared output\nand rerun once.\n\n**Failures are fixed in the code, not around it.** If a run fails, fix the\nPython against the loaded reference file's recipes and Errors table and call\n`exec` again. If two consecutive calls fail with the same error, re-read the\ntraceback line-by-line before a third. An error is never a fault in openpyxl or\nthe runtime — keep `packages: [\"openpyxl==3.1.5\"]`, never wrap source in\n`python -c` or shell, never \"debug\" with `os.listdir` or no-op scripts.\n\n**The runtime is sealed.** No shell (`ls`, `cat` raise `SyntaxError` — the\n`command` is Python source) and no network (`requests` and `urllib` fail — a\nURL to a spreadsheet cannot be downloaded; ask the user to attach the file).\nThe working directory starts empty on every call: a file from an earlier call\nis gone unless staged again via `inputs`, and a file you write but do not\ndeclare in `outputs` is discarded.\n\n**Never overwrite a staged input.** Edits always save under a new output name.\n",
|
|
31
|
+
"excel/references/create.md": "# Creating a Workbook (openpyxl)\n\nCreate a new `.xlsx` from scratch by running Python through the `exec` tool.\nA new workbook needs **no** `inputs` — do not invent attachment ids. **Exactly\none** `exec` call per user request when that call succeeds.\n\n**A workbook that already exists in this chat is never rebuilt here.** \"Add a\nrow\", \"change a cell\", \"extend the sheet\" — any request that starts from an\nexisting `.xlsx` is an EDIT: load `references/edit.md` and stage the workbook\nby its `attachmentId`. Building a fresh workbook for an edit request throws\naway everything the user already has.\n\n## The exec call\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"openpyxl==3.1.5\"],\n \"outputs\": [\"report.xlsx\"],\n \"command\": \"...\"\n}\n```\n\n- `packages` — `[\"openpyxl==3.1.5\"]` on every call. Pin the version; this exact\n version ships with the app and installs with no network; any other version\n has to be downloaded, which fails on a device that is offline. A workbook that\n embeds an image also needs `\"pillow\"`, **deliberately unpinned** — the runtime owns\n its version and `\"pillow==12.2.0\"` matches nothing, which sends openpyxl to PyPI\n with it. `[\"openpyxl==3.1.5\", \"pillow\"]` ships whole and installs offline.\n- `outputs` — `[\"report.xlsx\"]`. `wb.save(\"report.xlsx\")` must match the\n declared output name. `.xlsx` and `.csv` are allowed; `.xlsm` is not. Never\n pass an absolute path to `save()`.\n- `command` — the multi-line Python source, with real newline characters. Never\n collapse it to one line joined by `;` — a `for`/`if`/`with` after a semicolon\n is a `SyntaxError`.\n- No `inputs` key at all — **except** to embed an image, the one file a create\n call stages. See Embedding an Image.\n\n## The Recipe\n\nStart from this. It is a complete, working workbook — bold headers, data rows,\nnumber formats, column widths — saved under the declared output name. Copy it\nand change the content; do not assemble a workbook from memory.\n\n**Write the program flat — no `def`, no helper functions, no classes.** Top-level\nstatements only, in the order the sample shows. Every real failure of this skill\nhas come from a model writing a generator function and then wiring it up wrong: a\nlist filled inside a function and read outside it (`NameError: name 'data' is not\ndefined`), or a function that was never called, which saves an empty sheet and\ndelivers a blank file to the user.\n\n**Type the data out as literal rows, however long the series.** Ten years is ten\ntuples — write them. Never build periods with `datetime`/`timedelta` arithmetic\nand never increment a month by hand: `month + 1` past December raises\n`ValueError: month must be in 1..12, not 13`. A year is the plain number `2016`\nand a month is the plain string `\"2016-03\"` — neither needs a date object.\n\n**Give the granularity the user asked for.** \"Prices from 2016-2025\" is one row\nper year — ten rows. Do not expand it into 120 monthly rows they did not ask for.\n\n**Import once, then use that exact name.** After\n`from openpyxl import Workbook` the constructor is `Workbook()` — writing\n`openpyxl.Workbook()` raises `NameError: name 'openpyxl' is not defined`, because\nthat import never binds the module. Copy the sample's import block as-is.\n\n**Keep every underscore in API names.** `load_workbook`, `create_sheet`,\n`number_format`, `column_dimensions`, `column_letter`, `iter_rows`, `max_row`,\n`merge_cells` — stripping them to `loadworkbook` / `createsheet` fails.\n\n**Fill the template in — do not rewrite it.** The block below is the whole\nprogram. Change only its named slots: `SHEET_TITLE`, `HEADERS`, `ROWS`,\n`MONEY_COLUMNS`, and `OUTPUT` (which must match the declared output). Every other\nline stays character for character. In practice every failed run has come from\ncode invented *around* this template — an extra per-row styling loop, a\nhand-built summary row, a second unpack of the same data — not from the template\nitself. If a row needs a number format, put its column letter in\n`MONEY_COLUMNS`; that is the only knob.\n\nNothing goes after `print(...)`, and no comparison, `sorted`, `min`/`max` or\nrunning total goes between the lines. Values that need working out are worked out\nas you type `ROWS`, and anything the user should notice about the numbers belongs\nin your chat reply, not in more Python. Comparing a year to a label is where the\nlast run died: `TypeError: '<=' not supported between instances of 'int' and 'str'`.\n\n```python\nfrom openpyxl import Workbook\nfrom openpyxl.styles import Font\n\nSHEET_TITLE = \"Q1 Sales\"\nHEADERS = [\"Region\", \"Units\", \"Unit Price\", \"Revenue\"]\nROWS = [ # every tuple already complete, one per row\n (\"North\", 120, 9.99, 1198.80),\n (\"South\", 80, 12.50, 1000.00),\n (\"East\", 200, 7.25, 1450.00),\n]\nMONEY_COLUMNS = [\"C\", \"D\"] # money-formatted columns; [] for none\nOUTPUT = \"report.xlsx\" # must match the declared output exactly\n\nwb = Workbook()\nws = wb.active # a new workbook already has one sheet\nws.title = SHEET_TITLE\n\nws.append(HEADERS)\nfor cell in ws[1]: # ws[N] is row NUMBER N — never ws[a_list]\n cell.font = Font(bold=True)\n ws.column_dimensions[cell.column_letter].width = 14\nws.freeze_panes = \"A2\" # header stays visible while scrolling\n\nfor row in ROWS: # append whole rows — never unpack them\n ws.append(list(row))\n\nfor letter in MONEY_COLUMNS:\n for cell in ws[letter][1:]: # ws[\"C\"] is a column; [1:] skips its header\n cell.number_format = '#,##0.00'\n\nassert ws.max_row > 1, \"no data rows — fix the code, never deliver an empty sheet\"\nwb.save(OUTPUT)\nprint(f\"{len(wb.sheetnames)} sheets, {ws.max_row} rows\")\n```\n\nCompute derived columns while writing `ROWS`, not in a loop over them: the\nrevenue above is typed into each tuple. Unpacking rows to build other rows\n(`for a, b in ROWS:`) is what raises\n`ValueError: not enough values to unpack` the moment one tuple is a different\nlength.\n\nOnly when the user wants a **live** total, append this after the loop — nothing\nelse changes:\n\n```python\nlast_data_row = ws.max_row # capture BEFORE appending the total row\nws.append([\"Total\", None, None, f\"=SUM(D2:D{last_data_row})\"])\n```\n\nCapture `ws.max_row` **before** appending a row whose formula refers to the data —\n`max_row` grows with every append, so building the range afterwards produces a\nformula that includes its own cell (a circular reference the user sees as an error\nin Excel). Remember this runtime computes nothing: the formula cell looks empty\nin the chat preview, so say in the reply that the total shows up once the file\nis opened in a spreadsheet app.\n\nEvery data table gets the three lines the sample shows — bold header row,\n`ws.freeze_panes = \"A2\"`, and column widths. Without them the user opens a wall\nof `####` columns and loses the header the moment they scroll.\n\nWhen the user asks for made-up, sample, or random data, generate it so it holds\ntogether: a high is above its close, a low is below it, dates run in order,\npercentages sum to about 100. Drawing each cell independently from a random range\nputs a low of 64,000 next to a high of 21,000 in the same row, and the user reads\nthat as a broken file rather than as placeholder data.\n\nNumber formats are strings on the cell: `'#,##0.00'` for money-style decimals,\n`'0.0%'` for percentages (store the fraction, e.g. `0.31`, not `31`), `'yyyy-mm-dd'`\nfor dates.\n\n## Dates\n\nOnly when the user needs real date sorting or filtering — a year or a month label\nis a plain number or string, and needs none of this. Copy both lines together;\nthe import is the half that gets forgotten:\n\n```python\nfrom datetime import date # NOT `import datetime`, NOT `from datetime import datetime`\n\nws[\"A2\"] = date(2016, 1, 1) # a bare date(...) call — never datetime.date(...)\nws[\"A2\"].number_format = 'yyyy-mm-dd'\n```\n\n`datetime.date(2016, 1, 1)` after `from datetime import datetime` raises\n`TypeError: descriptor 'date' for 'datetime.datetime' objects doesn't apply to a\n'int' object`, and with no import at all it raises `NameError`. Both mean the same\nthing: use the two lines above exactly as written.\n\n## Several Sheets\n\nWhen the user asks for N sheets — \"a tab per region\", \"ten sheets of facts\" — write\n**one flat list of rows** and let the code group it. Each row names its own sheet as\nits first value, exactly like `ROWS` above, so there are no nested lists to author:\n\n```python\nfrom openpyxl import Workbook\nfrom openpyxl.styles import Alignment, Font\n\nHEADERS = [\"#\", \"Fact\"] # the columns AFTER the sheet name\nWIDTHS = [6, 70] # one width per header column\nROWS = [ # (sheet, then one value per header column)\n (\"Vision\", 1, \"Parrots see ultraviolet light that humans cannot.\"),\n (\"Vision\", 2, \"Their color vision uses four cone types, not three.\"),\n (\"Vision\", 3, \"UV patterns on feathers help them choose mates.\"),\n (\"Speech\", 1, \"Parrots mimic sound with a syrinx, not vocal cords.\"),\n (\"Speech\", 2, \"African greys can attach meaning to words.\"),\n (\"Speech\", 3, \"Wild flocks carry regional dialects.\"),\n]\nOUTPUT = \"facts.xlsx\"\n\nwb = Workbook()\nspare = wb.active # removed at the end; every sheet is created below\nwritten = {}\nfor row in ROWS:\n title = row[0]\n if title in written:\n ws = wb[title]\n else:\n ws = wb.create_sheet(title)\n ws.append(HEADERS)\n for cell in ws[1]:\n cell.font = Font(bold=True)\n ws.column_dimensions[cell.column_letter].width = WIDTHS[cell.column - 1]\n ws.freeze_panes = \"A2\"\n written[title] = 0\n ws.append(list(row[1:])) # row[1:] drops the sheet name — never append row\n written[title] += len(row) - 1\n ws.cell(row=ws.max_row, column=len(HEADERS)).alignment = Alignment(wrap_text=True)\n\nwb.remove(spare)\nassert written, \"no rows were written\"\nfor title in written:\n rows_written = written[title] // len(HEADERS)\n assert rows_written >= 3, f\"sheet {title} holds {rows_written} row(s) — write at least 3\"\nwb.save(OUTPUT)\nprint(f\"{len(wb.sheetnames)} sheets: \" + \", \".join(f\"{t}={written[t]}v\" for t in written))\n```\n\n**Append `list(row[1:])`, never `row` and never `[row]`.** `row` still carries the\nsheet name, and `[row]` puts the whole tuple in one cell — that is what\n`ValueError: Cannot convert (1, 'Parrots see …') to Excel` means. The slice is the\nonly unpacking this program does; do not add `for a, b in ROWS` loops of your own,\nwhich raise `ValueError: too many values to unpack`.\n\nA formula that references a sheet whose name contains a space must quote it:\n`=SUM('Raw Data'!B1:B2)`.\n\n**Do not invent a nested shape.** A list of `(title, [rows…])` pairs is the single\nmost common way this call fails: the inner list reaches a cell and openpyxl refuses\nit. One flat list, sheet name first.\n\nThe count printed per sheet is **written values**, not rows. A row count proves\nnothing: `ws.max_row` grows when a cell is merely styled, so a sheet that got\nalignment but no values still reports four rows while holding nothing.\n\n## Embedding an Image\n\n**The image must be staged in `inputs` in the same `exec` call.** The working\ndirectory starts empty, so a file name that is not in `inputs` does not exist.\n`XLImage(\"photo.png\")` without an `inputs` entry whose `path` is `photo.png` raises\n`FileNotFoundError`, and rerunning the same command fails the same way.\n\n**`path` is a name you choose, not a name you derive.** It has nothing to do with\nthe attachment id: `photo.png` is a fine `path` for an attachment whose id is\n`9f2c4ab1…`. Two mistakes to never make:\n\n- Turning an id into a file name — `XLImage(\"9f2c4ab1….png\")`, or opening the bare\n id. An id goes in `attachmentId`, never in a path and never in the Python.\n- Opening a name that merely appeared in the conversation. A `generate_image` result\n carries a `fileName` — what the picture was called when it was made, not a file on\n disk. Take the `attachmentId` from that result and ignore the rest; nothing is\n readable until an `inputs` entry stages it under a `path` you chose.\n\n**A `generate_image` result anywhere in the conversation IS the image the user\nmeans.** \"Add this image\" points at the picture this chat already produced, in an\nearlier turn as much as this one; the user's own message carries no attachment and\ndoes not need to. Stage that id and build. Do not ask for an image the conversation\nalready has, and never generate a replacement to embed in its place.\n\nAn image the user **uploaded** has no id to copy. Stage it with `path` only and no\n`attachmentId` key; the first id-less entry is the first image of their latest\nmessage.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"openpyxl==3.1.5\", \"pillow\"],\n \"inputs\": [{ \"attachmentId\": \"<id from the generate_image result>\", \"path\": \"photo.png\" }],\n \"outputs\": [\"report.xlsx\"],\n \"command\": \"...\"\n}\n```\n\nThe class lives in `openpyxl.drawing.image` and collides with PIL's `Image`, so\nalias it. `add_image` anchors the picture's top-left corner at one cell:\n\n```python\nfrom openpyxl.drawing.image import Image as XLImage\n\nimg = XLImage(\"photo.png\") # the path from inputs, nothing else\nimg.width, img.height = 320, 320 # pixels\nws.add_image(img, \"A1\") # A1 is the top-left corner, not a range\n```\n\n`add_image` is a **worksheet** method — `wb.add_image(...)` raises `AttributeError`.\nThe picture floats above the grid rather than filling a cell, so leave the rows it\ncovers empty.\n\n**One `add_image` call, on one sheet.** \"Add this image\" means the workbook carries\nthe picture once — put it on the first sheet, or on a sheet of its own. Calling\n`add_image` inside the loop that builds the sheets embeds a fresh copy per sheet.\n\n**Check the call before sending it.** Every image file name in `command` must also\nbe the `path` of an `inputs` entry in that same call, and `packages` must include\n`\"pillow\"`. The reverse holds too: if `inputs` stages an image, `command` must call\n`add_image`. Never wrap `XLImage` or the import in a `try`/`except` that saves\nanyway, and never draw a substitute with PIL — the image already exists.\n\nMerged cells: after `ws.merge_cells(\"A1:C1\")` only the top-left anchor is\nwritable — `ws[\"A1\"] = \"Title\"` works, while writing `B1` or `C1` raises\n`AttributeError: 'MergedCell' object attribute 'value' is read-only`. Write the\nanchor, always.\n\n## Errors\n\n- Never print the workbook's bytes or base64 — stdout is capped and the file\n travels through `outputs`. A build call prints only a short summary line\n (e.g. `2 sheets, 5 rows`).\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`load_workbook`, not `loadworkbook`) must stay. Do not switch to\n `python -c` or change the package pin.\n- `ModuleNotFoundError: No module named 'openpyxl'` means `packages` was missing\n or wrong — add `[\"openpyxl==3.1.5\"]` and rerun. Never try to install it.\n- `attachment … not found in this chat` means `inputs` listed an id that is not in\n this chat (often a copied placeholder). For a new workbook, omit `inputs`\n entirely and rerun.\n- `ValueError: Cannot convert [...] to Excel` means a list or tuple reached a cell:\n the code appended `row` or `[row]` instead of `list(row[1:])`, or invented a\n nested `(title, [rows…])` shape. Flatten it — see Several Sheets.\n- `ImportError: You must install Pillow to fetch image objects` means an image was\n embedded without `\"pillow\"` in `packages` — openpyxl does not install it. Add it\n and rerun.\n- `ModuleNotFoundError: No module named 'PIL'` means the code imported PIL itself,\n usually to draw a picture that already exists as an attachment. Stage the\n attachment through `inputs` instead.\n- `AttributeError: 'Workbook' object has no attribute 'add_image'` — `add_image` is\n a worksheet method: `ws.add_image(img, \"A1\")`.\n- `FileNotFoundError` on a 32-character hex name means an attachment id was opened\n as a path. The id belongs in `attachmentId`; open the `path` you chose.\n- A delivered workbook with no picture in it, when the user asked for one, means the\n image was never staged or `add_image` was never called. Rebuild it in one call.\n- `ImportError: cannot import name 'NumberFormat' from 'openpyxl.styles'` — there\n is no such class. A number format is a plain string on the cell:\n `cell.number_format = '#,##0.00'`. The only names to import from\n `openpyxl.styles` are `Font`, `Alignment`, `PatternFill`, `Border`, `Side`.\n- `TypeError: expected string or bytes-like object, got 'list'` means a worksheet\n was indexed with a list — `ws[row]` where `row` holds the values just appended.\n `ws[...]` takes a row number (`ws[1]`) or a range string (`ws[\"A1:C1\"]`). To\n style the row you just appended, index it by number: `ws[ws.max_row]`.\n- `NameError: name 'openpyxl' is not defined` means the code called\n `openpyxl.Workbook()` after `from openpyxl import Workbook` — call `Workbook()`.\n- `NameError` on any other name means a variable was defined inside a `def` and\n read outside it. Delete the function and inline its body at top level.\n- `ValueError: month must be in 1..12, not 13` means the code did month\n arithmetic. Type the periods out as literal values instead.\n- `AssertionError: sheet <name> holds N row(s) — write at least 3` means a sheet got\n fewer facts than the request implies. Add rows to that sheet and rerun; do not lower\n the assert. **Row counts do not prove content**: `ws.max_row` counts a cell that was\n merely styled, and is 1 on a truly empty sheet and never 0, so a header-only sheet\n can report `4 rows` while holding nothing. Append values before styling anything.\n- A bare `Sheet` among the sheet names is the untouched default sheet, left as a\n blank first tab in front of the data. Either is a failed turn even at `exitCode 0`\n — the user gets a blank or near-blank workbook. Rebuild it with literal rows.\n- On an `AttributeError` from openpyxl the API name is wrong, and on a `TypeError`\n about missing positional arguments a required argument was left out — fix either\n against this file's examples. Do not retry the same call, and do not switch to a\n shell.\n- `'MergedCell' object attribute 'value' is read-only` means the write hit a merged\n non-anchor cell — write the range's top-left cell instead.\n- A formula cell that reads `None` or shows empty in a preview is not a bug —\n this runtime computes nothing; the value appears when the user opens the file.\n Do not rewrite the workbook to \"fix\" it.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the `.xlsx`, stop tool use and\nanswer with one line: file name + the sheet/row summary from stdout. Exactly one\nsuccessful `exec` per request.\n",
|
|
32
|
+
"excel/references/edit.md": "# Editing an Attached Workbook (openpyxl)\n\nEdit a workbook that is already in this chat by running Python through the\n`exec` tool: change cells, add or insert rows, add columns or sheets, delete\nrows, columns, or sheets. Stage the workbook as an input, modify it, and save\nunder a **new** output name such as `revised.xlsx` — never overwrite the staged\ninput.\n\nMacro-enabled files (`.xlsm`) can be staged as inputs and read, but this\nruntime cannot deliver `.xlsm` back — macros never survive. Save the edit as\n`.xlsx` and tell the user the macros were not preserved.\n\n## Staging the Workbook\n\n**A workbook you built earlier in this chat is edited exactly like any other\nattachment — through its id.** The `exec` result that produced it carried\n`attachments: [{ attachmentId, fileName, byteLength }]`; scroll back, copy that\n`attachmentId` character for character, and stage it. The working directory is\nwiped between calls, so a file you saved last turn is not on disk — without a\nstaged input `load_workbook(\"report.xlsx\")` raises\n`FileNotFoundError: [Errno 44] No such file or directory`.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"openpyxl==3.1.5\"],\n \"inputs\": [{ \"attachmentId\": \"<the id from the earlier exec result>\", \"path\": \"existing.xlsx\" }],\n \"outputs\": [\"revised.xlsx\"],\n \"command\": \"...\"\n}\n```\n\n**A workbook the user uploaded is staged the same way — by its id.** The\n`[Attached file …]` line on their message names it:\n\n```\n[Attached file \"budget.xlsx\" (application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) — attachmentId: 4f9c2ab1]\n```\n\nCopy that id verbatim into `attachmentId`, exactly as for a workbook a tool\nproduced. This holds for **`.csv` and `.xlsm` uploads too**, not just `.xlsx`: an\nid-less entry resolves to an uploaded *image*, so any `inputs` entry whose `path`\nnames a data or document file is rejected outright. Only an uploaded **image** is\nstaged with `path` alone and no `attachmentId` key.\n\nStaged files land in the working directory under the bare `path` names —\nreference `load_workbook(\"existing.xlsx\")` by that name only.\n`attachment … not found in this chat` means you invented an id or the file is not\nattached. Re-copy the exact id from the `exec` result or the `[Attached file …]`\nline that names the workbook; if no id appears anywhere in the chat, ask the\nuser to attach the file again.\n\n`wb.save(\"revised.xlsx\")` must match the declared output name. Keep the\n`command` source multi-line with real newlines — never collapse it with `;`.\n\n## Adding an Image to an Existing Workbook\n\nStage two files: the workbook by its `attachmentId`, and the picture. Add `\"pillow\"`\nto `packages` — **deliberately unpinned**, since the runtime owns its version and a\npin sends openpyxl to PyPI with it.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"openpyxl==3.1.5\", \"pillow\"],\n \"inputs\": [\n { \"attachmentId\": \"<id of the workbook>\", \"path\": \"existing.xlsx\" },\n { \"attachmentId\": \"<id from the generate_image result>\", \"path\": \"photo.png\" }\n ],\n \"outputs\": [\"revised.xlsx\"],\n \"command\": \"...\"\n}\n```\n\n`path` is a name you choose; it has nothing to do with the attachment id, and a\n`fileName` seen in a tool result is not a file on disk. A `generate_image` result\nanywhere in the conversation is the image the request points at — stage that id\nrather than asking the user to attach it again.\n\n```python\nfrom openpyxl.drawing.image import Image as XLImage\n\nimg = XLImage(\"photo.png\") # the path from inputs, nothing else\nimg.width, img.height = 320, 320 # pixels\nws.add_image(img, \"A1\") # a worksheet method; A1 is the top-left corner\n```\n\nOne `add_image` per workbook — inside a loop over sheets it embeds a copy per sheet.\nNever wrap the import or the call in a `try`/`except` that saves anyway.\n\n## The Golden Rule of Edits\n\n**The staged sheet already has its header and all its data.** Editing never\nre-creates them: no `HEADERS`, no `ROWS`, no copy of the create template. Append\nonly what is genuinely new, and change only the cells you were asked to change.\nRe-appending the header and the rows writes the whole table a second time and the\nuser opens a file where every row appears twice — the create template belongs to\nthe create path (`references/create.md`) and nowhere else.\n\nAdding one row is the whole program:\n\n```python\nfrom openpyxl import load_workbook\n\nwb = load_workbook(\"existing.xlsx\")\nws = wb.active # wb[\"Sheet Name\"] to pick another\n\nbefore = ws.max_row # the sheet is already this long\nws.append([2026, 82500, 135000, 107500])\n\nwb.save(\"revised.xlsx\") # NEW name, matching the declared output\nprint(f\"{before} rows in, {ws.max_row} rows out\")\n```\n\nThat print is the check: one added row means the count goes up by exactly one. If\nit roughly doubles, the run re-appended the existing data — fix it and rerun\nrather than delivering a workbook with the table in it twice.\n\n## Putting the Row Where It Belongs\n\nAppending is right when the table has no order, and wrong when it has one: a sheet\nrunning 2016…2026 with 2015 stuck on the end reads as broken. When the new row\nbelongs inside an existing order, insert it at that position instead.\n\nYou already know the position — 2015 sorts above 2016, and the data starts at row 2\n— so write the index as a number. Do not scan, sort, or compare anything to work it\nout:\n\n```python\nfrom openpyxl import load_workbook\n\nwb = load_workbook(\"existing.xlsx\")\nws = wb.active\n\nROW = [2015, 278, 930, 45.5]\nAT = 2 # 2015 goes above 2016 — row 1 is the header\n\nassert not any(m.max_row >= AT for m in ws.merged_cells.ranges) and not any(\n isinstance(c.value, str) and c.value.startswith(\"=\") for r in ws.iter_rows() for c in r\n), \"a merged range at or below AT, or a formula — append instead, their ranges do not move\"\n\nbefore = ws.max_row\nws.insert_rows(AT)\nfor column, value in enumerate(ROW, start=1):\n cell = ws.cell(row=AT, column=column, value=value)\n cell.number_format = ws.cell(row=AT + 1, column=column).number_format\n\nwb.save(\"revised.xlsx\")\nprint(f\"{before} rows in, {ws.max_row} rows out\")\n```\n\n`AT` is never `1` — that would push the header down into the data.\n\nThe two lines that look optional are the ones that matter. An inserted cell starts\nwith no number format, so without the copy the new row shows a bare `278` in a\ncolumn of `278.00`s; taking the format from `AT + 1` uses the row that used to sit\nthere. And `insert_rows` moves cells but neither formula ranges nor merged ranges,\nso a `=SUM(B2:B11)` total would go on summing the old span and quietly leave the\nnew row out, while a `Total` merged across `A8:B8` would stay pinned to row 8 as\nits row slid to 9 — the assert stops both before anything is saved.\n\nThe two halves are scoped differently on purpose. A merged range is disturbed only\nif it sits at or below `AT`, which is why the check is `m.max_row >= AT` rather than\n\"any merged cell\": a title merged across `A1:C1` is untouched by an insert further\ndown, and failing on it would push you to append out of order for no reason. A\nformula gives no such signal — one in `D1` can reference `B2:B11` — so any formula\nat all is enough to stop the insert.\n\nWhen the assert fires, do not delete it. Append the row at the end with the\nprevious template and tell the user the table kept its file order so their totals\nstay correct.\n\n## Other Edits — Touch Only What Changes\n\n```python\nfrom openpyxl import load_workbook\nfrom openpyxl.styles import Font\n\nwb = load_workbook(\"existing.xlsx\")\nws = wb[\"Q1 Sales\"] # or wb.active; wb.sheetnames lists them\n\nws.cell(row=1, column=5, value=\"Margin %\").font = Font(bold=True)\nfor row, margin in [(2, 0.31), (3, 0.42), (4, 0.18)]:\n cell = ws.cell(row=row, column=5, value=margin)\n cell.number_format = '0.0%'\n\nws[\"B2\"] = 150 # update a cell in place\n\nnotes = wb.create_sheet(\"Notes\")\nnotes[\"A1\"] = \"Updated unit counts for North\"\n\nwb.save(\"revised.xlsx\") # NEW name, matching the declared output\nprint(f\"{len(wb.sheetnames)} sheets: {wb.sheetnames}\")\n```\n\n`wb[\"Sheet Name\"]` raises `KeyError` when the name does not exist — when unsure,\nprint `wb.sheetnames` in the same run that edits, pick from it, and never guess.\n\n## Deleting Rows, Columns and Sheets\n\nopenpyxl deletes for real, so none of this needs XML work. `ws.delete_rows(index)`\nand `ws.delete_cols(index)` take a **1-based** index and an optional count, so\n`ws.delete_rows(5, 3)` drops rows 5, 6 and 7 together; a sheet goes with\n`del wb[\"Notes\"]`. Row 1 is the header — `delete_rows(1)` throws it away, and data\nrows start at 2, exactly as for an insert.\n\n**Delete from the bottom up.** Each delete shifts everything below it, so a loop\nover ascending indices removes the wrong rows after the first: dropping rows 3 and\n5 top-down deletes row 3, then deletes what used to be row 6. Collect the row\nnumbers first and walk them in reverse — the same rule applies right-to-left for\n`delete_cols`:\n\n```python\nfrom openpyxl import load_workbook\n\nwb = load_workbook(\"existing.xlsx\")\nws = wb.active\n\nDROP = (2021, 2023) # the column-A values whose rows go\n\nbefore = ws.max_row\ntargets = [r for r in range(2, ws.max_row + 1) if ws.cell(row=r, column=1).value in DROP]\nassert targets, f\"no row matched {DROP} — check the values are numbers, not strings\"\n\nassert not any(m.max_row >= min(targets) for m in ws.merged_cells.ranges) and not any(\n isinstance(c.value, str) and c.value.startswith(\"=\") for r in ws.iter_rows() for c in r\n), \"a merged range at or below the first deleted row, or a formula — their ranges do not move\"\n\nfor row in reversed(targets): # bottom-up; ascending order deletes the wrong rows\n ws.delete_rows(row)\n\nwb.save(\"revised.xlsx\") # NEW name, matching the declared output\nprint(f\"{before} rows in, {ws.max_row} rows out\")\n```\n\nThe `assert targets` line is what stops a no-op being delivered, and it belongs\nbefore the loop rather than after it. Without it, values that match nothing leave\nthe sheet untouched and the workbook still saves at `exitCode 0` with an\nattachment indistinguishable from a real delete. With it the run raises, nothing\nis written, and the result carries `missingOutputs` instead. The usual cause is a\ntype mismatch — the string `\"2021\"` is not the number `2021` — or a wrong column\nindex. **An assert that fires is a failed turn to diagnose, not a workbook to\ndeliver**: fix the match and rerun, and never delete the assert to get a file out.\nThe printed `rows in / rows out` line is then the reply line, not the check, and it\nstill costs no extra call — both live in the run that does the deleting.\n\nThe second assert is the `insert_rows` hazard in reverse, and it covers the same two\nthings with the same scoping. `delete_rows` moves cells but leaves formula text\nalone, so a `=SUM(B2:B11)` total goes on summing eleven rows of a table that now\nholds nine, pulling in blanks or the wrong cells. It leaves merged ranges alone too:\na `Total` merged at `A8:B8` keeps covering row 8 after a row above it is deleted, and\na title merged across `A1:C1` still claims three columns after a `delete_cols`. Both\nare silent — no error, and the damage only shows when the user opens the file.\n\nMerges are again checked from the first deleted row down (`m.max_row >= min(targets)`),\nso a banner above every deletion does not block the edit, while any formula anywhere\ndoes. When it fires, do not delete it: say which rows you would have removed and ask\nwhether to drop the merges and formulas too, or tell the user the deletion has to\nhappen in Excel, where the ranges follow. Note the column case is not covered by that\nrow check — if you are calling `delete_cols` on a sheet with horizontal merges, treat\nany merged range as a stop.\n\n## Reading Cell Values During an Edit\n\n`load_workbook` has two modes, and neither gives both formulas and values:\n\n- `load_workbook(\"f.xlsx\")` — formula cells hold the formula **string**\n (`\"=SUM(D2:D4)\"`).\n- `load_workbook(\"f.xlsx\", data_only=True)` — formula cells hold the value the\n last spreadsheet app **cached** when it saved. A file that openpyxl itself wrote\n has no cache, so these cells read `None`.\n\nPlain data cells read the same either way. When a formula cell reads `None` under\n`data_only=True`, the file was never recalculated by a spreadsheet app — compute\nthe number in Python from the data cells instead of hunting for it.\n\n**Never `save()` a workbook opened with `data_only=True`.** That mode loads values\nin place of formulas, so saving writes the values back and every formula the user\nhad is gone — silently, at `exitCode 0`, with an attachment that looks fine. A\nworkbook you intend to save is always opened plainly:\n\n```python\nfrom openpyxl import load_workbook\n\nvalues = load_workbook(\"existing.xlsx\", data_only=True) # read numbers here\nwb = load_workbook(\"existing.xlsx\") # edit and save this one\n```\n\nRead from `values`, write to `wb`, and save `wb`. One open, one job.\n\nThat is reading in service of an edit. Reading for the *user* — a summary or an\nanswer delivered as chat text — is its own flow with its own call shape: load\n`references/read.md`.\n\n## Errors\n\n- Never print the workbook's bytes or base64 — stdout is capped and the file\n travels through `outputs`. A build call prints only a short summary line.\n Never pass an absolute path to `save()`.\n- `attachment … not found in this chat` — `inputs` listed an id that is not in\n this chat (often a copied placeholder). Only stage real ids from prior tool\n results or `[Attached file …]` lines.\n- `an id-less input stages an uploaded image, and this path names a document` —\n an `.xlsx` was staged with no `attachmentId`. Spreadsheets are always staged\n by id.\n- `no uploaded image in this chat — attach an image or pass an attachmentId` —\n an id-less input was sent when the user uploaded no image at all.\n- `FileNotFoundError: [Errno 44] No such file or directory` on a workbook you\n saved in an earlier call means it was never staged: the working directory is\n fresh every call. Add the file to `inputs` with its `attachmentId`.\n- A delivered workbook whose formulas have turned into blanks means it was opened\n with `data_only=True` and then saved. Open a second, plain workbook to edit.\n- A delivered workbook whose table appears twice means the edit re-appended the\n header and rows onto the staged sheet. An edit adds only what is new.\n- `AssertionError: a merged range at or below AT, or a formula — append instead …`\n means a merged range sits at or below the insertion row, or the sheet has a formula\n whose range `insert_rows` would not move. Append the row at the end instead and say\n why in the reply.\n- `AssertionError: a merged range at or below the first deleted row, or a formula …`\n is the same hazard on a delete, and has no safe fallback: say which rows you would\n remove and ask the user how to handle the merges and formulas.\n- `AssertionError: no row matched …` means the delete found nothing, usually because\n the compared values are strings on one side and numbers on the other. Diagnose the\n match and rerun; do not remove the assert, because the workbook it would deliver\n is the staged one unchanged.\n- Rows that disappeared from the wrong places mean the delete loop ran over\n ascending indices. Collect the targets first and delete in reverse.\n- A row that renders unlike the rest of its column — `278` among `278.00`s — was\n inserted without copying `number_format` from the row below it.\n- `'MergedCell' object attribute 'value' is read-only` means the write hit a merged\n non-anchor cell — write the range's top-left cell instead.\n- `\".xlsm\" is not an allowed output type` means the run tried to deliver a\n macro-enabled file — save as `.xlsx` and tell the user macros were not preserved.\n- `KeyError` on `wb[\"Sheet Name\"]` — the sheet name does not exist; print\n `wb.sheetnames` in the run that edits and pick from it.\n- `ModuleNotFoundError: No module named 'openpyxl'` — add\n `[\"openpyxl==3.1.5\"]` to `packages` and rerun. Never try to install it.\n- `ImportError: You must install Pillow to fetch image objects` means an image was\n embedded without `\"pillow\"` in `packages` — openpyxl does not install it.\n- `FileNotFoundError` on a 32-character hex name means an attachment id was opened as\n a path. The id belongs in `attachmentId`; open the `path` you chose.\n- On an `AttributeError` from openpyxl the API name is wrong, and on a `TypeError`\n about missing positional arguments a required argument was left out — fix either\n against this file's examples. Do not retry the same call, and do not switch to a\n shell.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the `.xlsx`, stop tool use and\nanswer with one line: file name + the `rows in / rows out` (or sheets) summary\nfrom stdout. Exactly one successful `exec` per request. If the result has\n`missingOutputs`, read stderr first — an `AssertionError` there means a guard\nstopped the save on purpose and its message names what to fix.\n",
|
|
33
|
+
"excel/references/read.md": "# Reading a Workbook to Answer in Chat (openpyxl)\n\nWhen the user asks what an attached workbook *holds* — a summary, a question\nanswered, specific values pulled out — the deliverable is your reply in the\nchat, not a file. This is a **read request**: exactly one `exec` call, staging\nthe workbook in `inputs` and declaring **no `outputs`**, whose whole job is to\nprint the sheets so you can read them in the result.\n\n## The exec call\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"openpyxl==3.1.5\"],\n \"inputs\": [{ \"attachmentId\": \"<id from the [Attached file …] line>\", \"path\": \"existing.xlsx\" }],\n \"maxOutputChars\": 24000,\n \"command\": \"...\"\n}\n```\n\nThe id rules: copy it verbatim from the `[Attached file …]` line on the user's\nmessage or from the earlier `exec` result that produced the file, never invent\none, never stage a workbook id-less (an id-less entry resolves to an uploaded\n*image*). If no id appears anywhere in the chat, ask the user to attach the\nfile again. `maxOutputChars` raises the stdout cap so a full workbook comes\nback in one result; keep the sample's 24000. Declare no `outputs` — a read\nbuilds nothing.\n\n## The Read Program\n\nPrints every sheet under a `[Sheet]` marker, then its rows — one printed line\nper row:\n\n```python\nfrom openpyxl import load_workbook\n\nwb = load_workbook(\"existing.xlsx\", data_only=True)\nfor name in wb.sheetnames:\n ws = wb[name]\n print(f\"[Sheet] {name} ({ws.max_row} rows)\")\n for row in ws.iter_rows(values_only=True):\n print(\" | \".join(\"\" if v is None else str(v).replace(\"\\n\", \" \") for v in row))\n```\n\nThe `replace` is load-bearing: a multi-line cell (Alt+Enter in Excel) embeds\n`\"\\n\"` in its value, and an embedded newline would split one row across two\nprinted lines. Flattened, every printed line is exactly one sheet row.\n\n`data_only=True` is the right mode here: a formula cell prints the value the\nlast spreadsheet app cached, or an empty field when the file was never\nrecalculated (`None` prints as nothing). An empty field under a `Total` header\nis that, not missing data — say so, and when the answer needs the number, work\nit out from the data rows that did print.\n\n**You cannot summarize in the call that reads.** The words in `command` are\nfixed before the program runs, so one call cannot inform itself: any summary\nwritten into it was written blind — recalled or invented, not read. Python only\n*transports* the values; the summarizing happens in your reply, after the\nresult comes back.\n\n## A Successful Read Ends Tool Use\n\nWhen the result prints the sheets, reply with the summary or the answer as chat\ntext — when the user asked for the table in the chat, that reply is a markdown\ntable built from the rows you read, never from memory. **Scale the reply to the\nworkbook**: a summary is much shorter than what it summarizes — a small sheet\nearns a few sentences, and only a many-sheet workbook earns sections. Restating\nevery row is not a summary. Do **not**:\n\n- call `exec` again to \"re-check\", \"read more\", or read the same workbook a\n second time;\n- build a summary `.xlsx` the user never asked for — an unrequested file is a\n failed turn, not a bonus.\n\nIf stdout ends with `… [truncated]`, the workbook is longer than the cap:\nanswer from what came back and say the answer covers the sheets up to that\npoint. Do not rerun the read — it prints the same beginning again.\n\nIf the user asks for the summary **as a file**, that is a read followed by a\nbuild: the read call above first, then one build call that writes the new\nworkbook from the values you actually read (load `references/create.md` for the\nbuild). The read still declares no `outputs`.\n\n## Errors\n\n- `attachment … not found in this chat` — the id was invented or the file is\n not attached. Re-copy the exact id; if none exists, ask the user to attach\n the file again instead of retrying.\n- `an id-less input stages an uploaded image, and this path names a document` —\n the workbook was staged with no `attachmentId`. Spreadsheets (`.xlsx`,\n `.csv`, `.xlsm`) are always staged by id.\n- `FileNotFoundError: [Errno 44] No such file or directory` — the file was\n never staged; the working directory is fresh every call. Add it to `inputs`\n with its `attachmentId`.\n- `ModuleNotFoundError: No module named 'openpyxl'` — add\n `[\"openpyxl==3.1.5\"]` to `packages` and rerun. Never try to install it.\n- A formula cell that prints nothing is not a bug — the file was never\n recalculated by a spreadsheet app. Compute the number from the data rows\n instead of rerunning.\n",
|
|
34
|
+
"github/SKILL.md": "---\nname: github\ndescription: Search, read, and write GitHub repos, issues, and pull requests via the REST API.\ntools: [http_request]\nplatform: [darwin, linux, win32, ios, android]\ncredentials: [github_access_token]\nallow_list: [https://api.github.com/]\n---\n\n# GitHub\n\nUse `http_request` against `https://api.github.com` on every call, with `headers: {\"Accept\": \"application/vnd.github+json\"}`. The GitHub PAT credential is attached automatically to every `api.github.com` request — **never include an `auth` block**. Never fetch `github.com` web pages — they return HTML, not data; translate a pasted link to its API path instead (e.g. `github.com/{owner}/{repo}/pull/{n}` → `/repos/{owner}/{repo}/pulls/{n}`).\n\n```json\n{\n \"url\": \"https://api.github.com/search/issues?q=is:pr+is:open+repo:owner/repo&per_page=5\",\n \"method\": \"GET\",\n \"headers\": { \"Accept\": \"application/vnd.github+json\" }\n}\n```\n\n## Reads\n\n- Search repos → `GET /search/repositories?q=...`\n- Search issues/PRs → `GET /search/issues?q=...` (always qualify with `is:pr` or `is:issue` — it returns both)\n- List issues → `GET /repos/{owner}/{repo}/issues?state=open` (items with a `pull_request` key are PRs, not issues)\n- List PRs → `GET /repos/{owner}/{repo}/pulls?state=open`\n- Read a file → `GET /repos/{owner}/{repo}/contents/{path}` (`content` is base64-encoded)\n- List a user's repos → `GET /user/repos?sort=updated`\n\n## Writes\n\n- Create an issue → `POST /repos/{owner}/{repo}/issues` with `body: {\"title\": \"...\", \"body\": \"...\"}`\n- Comment on an issue or PR → `POST /repos/{owner}/{repo}/issues/{n}/comments` with `body: {\"body\": \"...\"}` (PRs are issues for commenting — use the PR number on the issues endpoint)\n- Close or edit an issue → `PATCH /repos/{owner}/{repo}/issues/{n}` with `body: {\"state\": \"closed\"}`\n\n## Notes\n\n- Paginate with `per_page` (default small — 5, rarely above 30) and `page`; never fetch more than the request needs.\n- Ask for specific fields where the endpoint supports it, and summarize in plain language rather than echoing raw JSON — responses get truncated past 8KB.\n- **401** — token missing or revoked: tell the user to connect GitHub, don't retry. **404** on a resource the user linked directly usually means it's private, not nonexistent — search can't see private repos either. **403/429** mentioning rate limits — say so and stop.\n- Never print the token or the `Authorization` header, and don't claim a write succeeded without a successful response in this turn.\n",
|
|
35
|
+
"gmail/SKILL.md": "---\nname: gmail\ndescription: Read, search, send, and manage Gmail messages and labels via the Gmail REST API.\naliases: [inbox, email+send, email+draft, email+reply, email+forward]\ntools: [http_request, gmail_send, gmail_draft]\nplatform: [darwin, linux, win32]\ncredentials: [gmail_access_token]\nallow_list: [https://gmail.googleapis.com/gmail/v1/users/me/]\n---\n\n# Gmail\n\nUse `gmail_send` to send, `gmail_draft` to draft, and `http_request` for everything else (list, search, get, labels, trash). The Gmail credential is attached automatically to every `gmail.googleapis.com` request — **never include an `auth` block**.\n\n## Prerequisites\n\nGmail must be connected. Each Google skill is connected separately, with its\nown app and its own approval — connecting one grants nothing to the others. If\ncredentials are missing, tell the user to connect Gmail from Settings, or to\nset the `gmail_access_token` credential.\n\n## Base URL\n\n`https://gmail.googleapis.com/gmail/v1/users/me`\n\nThe host is `gmail.googleapis.com` — not `www.googleapis.com`. Send is at `/messages/send`, never `/send`.\n\n## Common Operations\n\n### List or search messages\n\n`messages.list` returns `{id, threadId}` pairs only — no subjects, no snippets. To summarize you need a follow-up `messages.get` per id. Use `q` for Gmail search syntax (`from:`, `subject:`, `is:unread`, `newer_than:7d`, `has:attachment`, `label:work`). Keep `maxResults` ≤ 10.\n\n```json\n{\n \"url\": \"https://gmail.googleapis.com/gmail/v1/users/me/messages\",\n \"method\": \"GET\",\n \"query\": { \"q\": \"is:unread newer_than:7d\", \"labelIds\": \"INBOX\", \"maxResults\": 10 }\n}\n```\n\n### Get a message (metadata)\n\nFor lists and summaries always use `format=metadata` — it skips the body and is far cheaper than `full`.\n\n```json\n{\n \"url\": \"https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}\",\n \"method\": \"GET\",\n \"query\": {\n \"format\": \"metadata\",\n \"metadataHeaders\": \"Subject,From,To,Date,Message-ID,References\"\n }\n}\n```\n\n### Get a message (full body)\n\nOnly when the user needs the content. The body is base64url-encoded in `payload.parts[].body.data` (or `payload.body.data`); decode it before presenting. Prefer the `text/plain` part over `text/html`.\n\n```json\n{\n \"url\": \"https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}\",\n \"method\": \"GET\",\n \"query\": { \"format\": \"full\" }\n}\n```\n\n### Send a message\n\nUse `gmail_send` with semantic args: `to` (array), `subject`, and `text` (or `html`). Optional: `cc`, `bcc`, `replyTo`. The tool builds the RFC 2822 message and base64url-encodes it — never construct `raw` yourself.\n\n```json\n{ \"to\": [\"<RECIPIENT_EMAIL>\"], \"subject\": \"Subject line\", \"text\": \"Message body\" }\n```\n\nIf you supply both `text` and `html`, only `text` is sent — pick one.\n\nConfirm recipient, subject, and body with the user before sending. Report success only when the response contains a message `id`.\n\n### Draft a message\n\nUse `gmail_draft` with the same envelope as `gmail_send`.\n\n```json\n{ \"to\": [\"<RECIPIENT_EMAIL>\"], \"subject\": \"Subject line\", \"text\": \"Draft body\" }\n```\n\n### Reply to a message (preserves threading)\n\nA reply is `gmail_send` with the original's `threadId`, `inReplyTo`, and `references`. Without these Gmail starts a new thread.\n\n1. Get the original with `format=metadata` and headers `Message-ID,References,Subject,From,Reply-To`; capture its `threadId`.\n2. Send with `to` <- original From (or Reply-To), `subject` <- `Re: ` + original (don't double-prefix), `inReplyTo` <- original Message-ID, `references` <- original References then that Message-ID. Keep angle brackets.\n\n```json\n{\n \"to\": [\"<RECIPIENT_EMAIL>\"],\n \"subject\": \"Re: Original subject\",\n \"text\": \"Reply body\",\n \"threadId\": \"{originalThreadId}\",\n \"inReplyTo\": \"<msg-id@mail.gmail.com>\",\n \"references\": \"<msg-id@mail.gmail.com>\"\n}\n```\n\n### Modify labels (mark read, archive, star)\n\nSystem labels: `INBOX`, `UNREAD`, `STARRED`, `IMPORTANT`, `SPAM`, `TRASH`. Mark read = remove `UNREAD`; archive = remove `INBOX`; star = add `STARRED`.\n\n```json\n{\n \"url\": \"https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}/modify\",\n \"method\": \"POST\",\n \"body\": { \"removeLabelIds\": [\"UNREAD\", \"INBOX\"] }\n}\n```\n\n### Trash a message\n\n```json\n{\n \"url\": \"https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}/trash\",\n \"method\": \"POST\"\n}\n```\n\n## Output Policy\n\n- Lists: up to 5 entries with subject, sender, and a human-readable date. Fetch full bodies only when asked.\n- Decode base64 message bodies before presenting; never include raw base64 blobs.\n- Modify/trash: state the user-facing effect (\"marked 3 messages as read\"), not the label diff.\n- Confirm destructive or outgoing actions (send, reply, trash) with the user first.\n\n## Common Mistakes\n\n- Using `www.googleapis.com` for Gmail, or building send/draft/reply through `http_request` instead of `gmail_send`/`gmail_draft`.\n- Treating `messages.list` results as if they had subjects — they need a follow-up `messages.get`.\n- Replying without `threadId` + `inReplyTo`/`references` — Gmail starts a new thread.\n- Sending a placeholder (`<RECIPIENT_EMAIL>`, `recipient@example.com`) — the runtime refuses these.\n- Inventing an address. The runtime refuses any recipient absent from the conversation and from earlier tool results; search Gmail or ask the user.\n- Including an `auth` block by hand — credentials attach automatically; a mistyped key breaks the request.\n",
|
|
36
|
+
"gmail/operations.json": "{\n \"operations\": [\n {\n \"tool\": \"gmail_send\",\n \"description\": \"Send an email through Gmail, or reply in a thread when threadId and inReplyTo are given. Pass the message as plain fields — never build the RFC 2822 message or base64 yourself. Confirm the recipient and body with the user before sending.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"to\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"Recipient email addresses\"\n },\n \"subject\": { \"type\": \"string\", \"description\": \"Subject line\" },\n \"text\": { \"type\": \"string\", \"description\": \"Plain-text body\" },\n \"html\": { \"type\": \"string\", \"description\": \"HTML body, used when text is absent\" },\n \"cc\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"bcc\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"replyTo\": { \"type\": \"string\" },\n \"threadId\": {\n \"type\": \"string\",\n \"description\": \"Thread to reply in, from a previous messages.list or messages.get\"\n },\n \"inReplyTo\": {\n \"type\": \"string\",\n \"description\": \"Message-ID header of the message being replied to\"\n },\n \"references\": { \"type\": \"string\", \"description\": \"References header of the thread\" }\n },\n \"required\": [\"to\", \"subject\"]\n },\n \"request\": {\n \"method\": \"POST\",\n \"url\": \"https://gmail.googleapis.com/gmail/v1/users/me/messages/send\",\n \"builder\": \"gmail-send\"\n }\n },\n {\n \"tool\": \"gmail_draft\",\n \"description\": \"Create a Gmail draft without sending it. Pass the message as plain fields — never build the RFC 2822 message or base64 yourself.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"to\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"Recipient email addresses\"\n },\n \"subject\": { \"type\": \"string\", \"description\": \"Subject line\" },\n \"text\": { \"type\": \"string\", \"description\": \"Plain-text body\" },\n \"html\": { \"type\": \"string\", \"description\": \"HTML body, used when text is absent\" },\n \"cc\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"bcc\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n \"replyTo\": { \"type\": \"string\" },\n \"threadId\": { \"type\": \"string\", \"description\": \"Thread the draft replies in\" },\n \"inReplyTo\": {\n \"type\": \"string\",\n \"description\": \"Message-ID header of the message being replied to\"\n },\n \"references\": { \"type\": \"string\", \"description\": \"References header of the thread\" }\n },\n \"required\": [\"to\", \"subject\"]\n },\n \"request\": {\n \"method\": \"POST\",\n \"url\": \"https://gmail.googleapis.com/gmail/v1/users/me/drafts\",\n \"builder\": \"gmail-draft\"\n }\n }\n ]\n}\n",
|
|
37
|
+
"google-calendar/SKILL.md": "---\nname: google-calendar\ndescription: List, create, update, and delete Google Calendar events via the Google Calendar REST API.\naliases: [calendar, meeting+create, meeting+schedule, meeting+book, meeting+move, meeting+cancel, event+create]\ntools: [http_request, calendar_create_meet_event, calendar_add_meet]\nplatform: [darwin, linux, win32]\ncredentials: [google_calendar_access_token]\nallow_list: [https://www.googleapis.com/calendar/v3/]\n---\n\n# Google Calendar\n\nUse `calendar_create_meet_event` and `calendar_add_meet` for Google Meet links, and `http_request` for everything else (list, plain create/update/delete, free/busy). The Google Calendar credential is attached automatically to every `www.googleapis.com/calendar/v3/` request — **never include an `auth` block**.\n\n## Prerequisites\n\nGoogle Calendar must be connected. Each Google skill is connected separately, with its\nown app and its own approval — connecting one grants nothing to the others. If\ncredentials are missing, tell the user to connect Google Calendar from Settings, or to\nset the `google_calendar_access_token` credential.\n\n## Base URL\n\n`https://www.googleapis.com/calendar/v3` — default `calendarId` is `primary` unless the user names another calendar.\n\n## Temporal Accuracy (Critical)\n\n- Resolve relative dates (\"today\", \"tomorrow\", \"next week\", \"in 2 hours\") to absolute ISO 8601 timestamps **with timezone offset** before calling the API.\n- Never infer \"now\" from memory when building `timeMin`, `timeMax`, or event start/end values.\n- If the date or time is ambiguous, ask the user instead of guessing.\n\nExample: annotation `[Current local time: 2026-08-12 14:00:00 (UTC-03:00)]`, user asks \"in 30 minutes for 1 hour\" -> `start.dateTime` = `2026-08-12T14:30:00-03:00`, `end.dateTime` = `2026-08-12T15:30:00-03:00`.\n\n## Common Operations\n\n### List upcoming events\n\nAlways use `singleEvents=true` and `orderBy=startTime` so recurring events expand and sort correctly.\n\n```json\n{\n \"url\": \"https://www.googleapis.com/calendar/v3/calendars/primary/events\",\n \"method\": \"GET\",\n \"query\": {\n \"timeMin\": \"<resolved ISO 8601 with offset>\",\n \"maxResults\": 10,\n \"singleEvents\": true,\n \"orderBy\": \"startTime\"\n }\n}\n```\n\n### Create an event\n\nAsk for missing required fields (summary, start, end) before creating.\n\n```json\n{\n \"url\": \"https://www.googleapis.com/calendar/v3/calendars/primary/events\",\n \"method\": \"POST\",\n \"body\": {\n \"summary\": \"Design review\",\n \"start\": { \"dateTime\": \"2026-06-10T14:00:00-03:00\" },\n \"end\": { \"dateTime\": \"2026-06-10T15:00:00-03:00\" },\n \"attendees\": [{ \"email\": \"colleague@example.com\" }]\n }\n}\n```\n\n### Create an event with a Google Meet link\n\nUse `calendar_create_meet_event` with `summary`, `start`, `end` (ISO 8601 with offset), and optionally `timeZone`, `description`, `location`, `attendees`, `calendarId`.\n\n```json\n{\n \"summary\": \"Sync call\",\n \"start\": \"2026-06-10T14:00:00-03:00\",\n \"end\": \"2026-06-10T14:30:00-03:00\"\n}\n```\n\nTo add a Meet link to an event that already exists, use `calendar_add_meet` with `eventId` (and `calendarId` if not `primary`).\n\n### Update an event\n\n`PATCH` with only the fields to change.\n\n```json\n{\n \"url\": \"https://www.googleapis.com/calendar/v3/calendars/primary/events/{eventId}\",\n \"method\": \"PATCH\",\n \"body\": { \"summary\": \"Updated title\" }\n}\n```\n\n### Delete an event\n\n```json\n{\n \"url\": \"https://www.googleapis.com/calendar/v3/calendars/primary/events/{eventId}\",\n \"method\": \"DELETE\"\n}\n```\n\n### Free/busy query\n\n```json\n{\n \"url\": \"https://www.googleapis.com/calendar/v3/freeBusy\",\n \"method\": \"POST\",\n \"body\": {\n \"timeMin\": \"2026-06-10T00:00:00Z\",\n \"timeMax\": \"2026-06-11T00:00:00Z\",\n \"items\": [{ \"id\": \"primary\" }]\n }\n}\n```\n\n### List calendars\n\n```json\n{\n \"url\": \"https://www.googleapis.com/calendar/v3/users/me/calendarList\",\n \"method\": \"GET\"\n}\n```\n\n## Output Policy\n\n- Present events with title, date/time (with timezone), location, and attendees when available.\n- For create/update, report the event `id`, start/end time, and `htmlLink` rendered as a Markdown link — e.g. `[event title](htmlLink)`. Never present the URL bare or wrapped in backticks, or it won't be clickable.\n- Confirm before deleting events; show the event title and time so the user can verify.\n\n## Common Mistakes\n\n- Sending natural-language dates instead of absolute ISO 8601 with timezone offset.\n- Listing without `singleEvents=true` — recurring events come back collapsed and unsorted.\n- Calling get/patch/delete without a real `eventId` — list/search first.\n- Including an `auth` block by hand — credentials attach automatically; a mistyped key breaks the request.\n",
|
|
38
|
+
"google-calendar/operations.json": "{\n \"operations\": [\n {\n \"tool\": \"calendar_create_meet_event\",\n \"description\": \"Create a Google Calendar event that has a Google Meet link. Use this whenever the user asks for a meeting with a video call; a plain event without a call stays on http_request.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"summary\": { \"type\": \"string\", \"description\": \"Event title\" },\n \"start\": {\n \"type\": \"string\",\n \"description\": \"Start time, ISO 8601 with a UTC offset, e.g. 2026-03-04T15:00:00-03:00\"\n },\n \"end\": {\n \"type\": \"string\",\n \"description\": \"End time, ISO 8601 with a UTC offset\"\n },\n \"timeZone\": {\n \"type\": \"string\",\n \"description\": \"IANA time zone, e.g. America/Sao_Paulo\"\n },\n \"description\": { \"type\": \"string\" },\n \"location\": { \"type\": \"string\" },\n \"attendees\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"Attendee email addresses\"\n },\n \"calendarId\": { \"type\": \"string\", \"description\": \"Defaults to primary\" }\n },\n \"required\": [\"summary\", \"start\", \"end\"]\n },\n \"request\": {\n \"method\": \"POST\",\n \"url\": \"https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events\",\n \"urlDefaults\": { \"calendarId\": \"primary\" },\n \"builder\": \"calendar-meet-event\"\n }\n },\n {\n \"tool\": \"calendar_add_meet\",\n \"description\": \"Add a Google Meet link to an existing Google Calendar event.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"eventId\": {\n \"type\": \"string\",\n \"description\": \"Event id, from a previous events.list or events.insert\"\n },\n \"calendarId\": { \"type\": \"string\", \"description\": \"Defaults to primary\" }\n },\n \"required\": [\"eventId\"]\n },\n \"request\": {\n \"method\": \"PATCH\",\n \"url\": \"https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events/{eventId}\",\n \"urlDefaults\": { \"calendarId\": \"primary\" },\n \"builder\": \"calendar-add-meet\"\n }\n }\n ]\n}\n",
|
|
39
|
+
"google-docs/SKILL.md": "---\nname: google-docs\ndescription: Create, read, and edit Google Docs documents via the Google Docs REST API.\ntools: [http_request, docs_create, docs_append_text]\nplatform: [darwin, linux, win32]\ncredentials: [google_docs_access_token]\nallow_list: [https://docs.googleapis.com/v1/documents, https://www.googleapis.com/drive/v3/files]\n---\n\n# Google Docs\n\nUse `docs_create` to create a document and `docs_append_text` to add text or\npages to it; use `http_request` for everything else (get, replace, insert at a\nposition, delete ranges, Drive search, trash). For each `http_request`, set\n`auth.tokenCredentialKey` to `google_docs_access_token`.\n\nNever use `exec`, Python, curl, shell flags such as `-H`/`-d`, or a JSON string\nto make a Google Docs request. Call one tool per operation with one complete\nstructured object. In particular, `body` must be an object, not a serialized\nJSON string; the tool JSON-encodes it and sets `Content-Type: application/json`\nautomatically.\n\n## Load the Recipe File First\n\nThis file carries no requests. The working request shapes live in three\nreference files — load the one for the job with the `skill` tool BEFORE calling\n`http_request`, then copy its request and change only the values:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Reading or finding documents** — \"what does document X say\", \"summarize\n my doc\", \"find / list my documents\": call the `skill` tool with\n `name: \"google-docs\"` and `file: \"references/read.md\"`.\n- **Creating a new document** (no existing document involved; one page or\n many): call the `skill` tool with `name: \"google-docs\"` and\n `file: \"references/create.md\"` — it covers `docs_create` and filling the\n document with `docs_append_text`.\n- **Changing an existing document** — append or insert text, find and\n replace, delete a passage, move it to the trash: call the `skill` tool with\n `name: \"google-docs\"` and `file: \"references/edit.md\"`.\n\nNever write a request from memory. The recipes carry the exact request shapes\n(`batchUpdate` request names, index rules, page limits) that fail in\nnon-obvious ways when improvised; loading the file is one cheap read-only call.\n\n## Prerequisites\n\nGoogle Docs must be connected. Each Google skill is connected separately, with its\nown app and its own approval — connecting one grants nothing to the others. If\ncredentials are missing, tell the user to connect Google Docs from Settings, or to\nset the `google_docs_access_token` credential.\n\nIf Google returns `403 PERMISSION_DENIED` with reason `SERVICE_DISABLED`, the\ncredential is working but the OAuth client's Google Cloud project has not\nenabled the Google Docs API. Do not ask the user to reconnect. Tell the project\nowner to open the response's `activationUrl`, enable the API, and retry after\npropagation.\n\n## Identifiers\n\nDocuments are identified by `documentId` — the alphanumeric string in the URL\n`docs.google.com/document/d/{documentId}/...`, and the `documentId` field of a\ncreate response. Listing and searching documents go through the Google Drive\nAPI (`references/read.md`), never the Docs API.\n\n## Output Policy\n\n- For document reads, extract and present the text content clearly — do not\n echo the raw JSON structure.\n- For edits, confirm the change with the user first (what text is being\n inserted, replaced, or deleted) unless they already spelled it out.\n- Report success only when the `batchUpdate` response contains `replies` with\n no errors, and name the document (title or link) in the answer.\n",
|
|
40
|
+
"google-docs/operations.json": "{\n \"operations\": [\n {\n \"tool\": \"docs_create\",\n \"description\": \"Create an empty Google Doc with a title. Call it exactly once per requested document, then append content with docs_append_text using the returned documentId.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"description\": \"Document title\"\n }\n },\n \"required\": [\n \"title\"\n ]\n },\n \"request\": {\n \"method\": \"POST\",\n \"url\": \"https://docs.googleapis.com/v1/documents\",\n \"builder\": \"docs-create\"\n },\n \"response\": {\n \"pick\": [\n \"documentId\",\n \"title\"\n ]\n }\n },\n {\n \"tool\": \"docs_append_text\",\n \"description\": \"Append text to the end of a Google Doc, optionally followed by a page break. One page (80 words or fewer) per call; set pageBreak on every page except the last. Never compute indexes yourself.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"documentId\": {\n \"type\": \"string\",\n \"description\": \"Document id, from docs_create or the docs.google.com/document/d/{id} URL\"\n },\n \"text\": {\n \"type\": \"string\",\n \"description\": \"Text to append; end it with a newline\"\n },\n \"pageBreak\": {\n \"type\": \"boolean\",\n \"description\": \"Insert a page break after the text\"\n }\n },\n \"required\": [\n \"documentId\",\n \"text\"\n ]\n },\n \"request\": {\n \"method\": \"POST\",\n \"url\": \"https://docs.googleapis.com/v1/documents/{documentId}:batchUpdate\",\n \"builder\": \"docs-append-text\"\n }\n }\n ]\n}\n",
|
|
41
|
+
"google-docs/references/create.md": "# Creating a Google Doc\n\nTwo typed tools do the whole job: `docs_create` makes the document,\n`docs_append_text` fills it. Typed tools select their own credential. For each fallback `http_request`,\nset `auth.tokenCredentialKey` to `google_docs_access_token`.\n\n## Create the document\n\nCall `docs_create` with the title:\n\n```json\n{ \"title\": \"My Document\" }\n```\n\nThe result contains the `documentId`. Capture it and keep using it.\nCreate the document exactly once. Do not follow a successful create with a\nDrive search, a GET, or another create just to rediscover the ID. If the result has no\n`documentId`, stop and report that response problem; never append without the\nID.\n\n## Add the content\n\nCall `docs_append_text` once per page. Put the document title as the first\nline of the first page instead of spending a call on a heading, end the text\nwith a newline, and keep each call at 80 words or fewer. The 80-word limit\napplies even when the user asks for only one page. Never expand a one-page\nrequest into a long list in one call.\n\n```json\n{\n \"documentId\": \"<id from docs_create>\",\n \"text\": \"My Document\\n\\nConcise content.\\n\",\n \"pageBreak\": false\n}\n```\n\n## Multi-page documents\n\nOne requested page per `docs_append_text` call, in order, each 80 words or\nfewer, with `\"pageBreak\": true` on every page except the last — five requested\npages therefore take five append calls. Wait for a successful result before\nappending the next page. Do not place several pages of text in one call: if\nthe tool reports the arguments are not valid JSON, the call was too large or\nincomplete, so retry only that page with shorter text.\n\n## Finishing\n\nReport success only when every append result came back without an error.\nAnswer with the document title and the link\n`https://docs.google.com/document/d/{documentId}/edit`; do not read the\ndocument back to \"confirm\".\n\n## Without the typed tools\n\nThe typed tools wrap these two Docs API requests; use them only if the typed\ntools are missing from this chat:\n\n```json\n{\n \"url\": \"https://docs.googleapis.com/v1/documents\",\n \"auth\": { \"tokenCredentialKey\": \"google_docs_access_token\" },\n \"method\": \"POST\",\n \"body\": { \"title\": \"My Document\" }\n}\n```\n\n```json\n{\n \"url\": \"https://docs.googleapis.com/v1/documents/{documentId}:batchUpdate\",\n \"auth\": { \"tokenCredentialKey\": \"google_docs_access_token\" },\n \"method\": \"POST\",\n \"body\": {\n \"requests\": [\n {\n \"insertText\": {\n \"endOfSegmentLocation\": {},\n \"text\": \"Page heading\\n\\nConcise page content.\\n\"\n }\n },\n { \"insertPageBreak\": { \"endOfSegmentLocation\": {} } }\n ]\n }\n}\n```\n\nThen it is one requested page per `batchUpdate` call, the same 80-word limit,\nand the `insertPageBreak` request omitted on the last page;\nnever call `/v1/documents:batchUpdate` without the ID.\n\n## Common Mistakes\n\n- Recreating or searching for a document after a successful create instead of\n using the returned `documentId` — this produces duplicates and wastes rounds.\n- Sending a whole multi-page document in one call — one page of at most 80\n words per call, `pageBreak` on every page except the last.\n- Computing indexes yourself — `docs_append_text` and `endOfSegmentLocation`\n never need one.\n",
|
|
42
|
+
"google-docs/references/edit.md": "# Editing an Existing Google Doc\n\nEvery request is one `http_request` call with a structured object. For each request, set `auth.tokenCredentialKey` to `google_docs_access_token`. All content edits go through\n`batchUpdate` — there is no PATCH endpoint for document body changes.\n\nGet the `documentId` from the URL the user gave, from an earlier create\nresponse, or from a Drive search (`references/read.md`).\n\nEvery `http_request` edit is ONE `POST …:batchUpdate` whose body is `{ \"requests\": [ … ] }` —\na bare request object without the `requests` array is rejected with 400. Copy\nthe request shapes below exactly: the field names are fixed by Google, and any\nother name (`searchText`, `replacementText`, `find`, `replace`, `newText`) fails\nwith `Unknown name`. One edit per user request — an edit never needs a\npreceding insert of text that is already in the document.\n\n## Append text\n\nCall `docs_append_text` — it appends at the end of the document with no index\narithmetic and no preliminary GET. End the text with a newline; keep it at 80\nwords or fewer per call.\n\n```json\n{\n \"documentId\": \"<documentId>\",\n \"text\": \"\\nNew paragraph.\\n\",\n \"pageBreak\": false\n}\n```\n\nThe raw equivalent, for when the typed tool is missing from this chat, is a\n`batchUpdate` with `\"insertText\": { \"endOfSegmentLocation\": {}, \"text\": \"…\" }`.\n\n## Insert text at a position\n\nUse an explicit `location.index` only when editing at a known position. A new\nblank document's first valid insertion point is index 1; index 2 is outside its\nempty paragraph. Indexes count UTF-16 code units and change with every edit, so\n`GET` the document first when you need one.\n\n```json\n{\n \"url\": \"https://docs.googleapis.com/v1/documents/{documentId}:batchUpdate\",\n \"auth\": { \"tokenCredentialKey\": \"google_docs_access_token\" },\n \"method\": \"POST\",\n \"body\": {\n \"requests\": [\n {\n \"insertText\": {\n \"location\": { \"index\": 1 },\n \"text\": \"Hello, World!\"\n }\n }\n ]\n }\n}\n```\n\n## Replace all text matching a pattern\n\nUse `replaceAllText` to find and replace — exactly these keys:\n`containsText` (with `text` and optional `matchCase`) and `replaceText`.\n`containsText.text` is a regex — escape special characters (`.`, `*`, `+`,\n`?`, `[`, `]`, `(`, `)`, `{`, `}`, `^`, `$`, `|`, `\\`). No `GET` is needed\nfirst, and no `insertText` belongs in the same request.\n\n```json\n{\n \"url\": \"https://docs.googleapis.com/v1/documents/{documentId}:batchUpdate\",\n \"auth\": { \"tokenCredentialKey\": \"google_docs_access_token\" },\n \"method\": \"POST\",\n \"body\": {\n \"requests\": [\n {\n \"replaceAllText\": {\n \"containsText\": { \"text\": \"old text\", \"matchCase\": true },\n \"replaceText\": \"new text\"\n }\n }\n ]\n }\n}\n```\n\nThe reply's `occurrencesChanged` says how many matches were replaced; `0` means\nthe text was not found — tell the user instead of retrying with guesses.\n\n## Delete a range of text\n\nFetch the document first, then specify the start and exclusive end index.\n\n```json\n{\n \"url\": \"https://docs.googleapis.com/v1/documents/{documentId}:batchUpdate\",\n \"auth\": { \"tokenCredentialKey\": \"google_docs_access_token\" },\n \"method\": \"POST\",\n \"body\": {\n \"requests\": [\n {\n \"deleteContentRange\": {\n \"range\": { \"startIndex\": 10, \"endIndex\": 20 }\n }\n }\n ]\n }\n}\n```\n\n## Move a document to the trash\n\nTrashing goes through the Drive API; the user can restore it from Drive's\ntrash. Only do this when the user explicitly asks to delete or remove the\ndocument.\n\n```json\n{\n \"url\": \"https://www.googleapis.com/drive/v3/files/{documentId}\",\n \"auth\": { \"tokenCredentialKey\": \"google_docs_access_token\" },\n \"method\": \"PATCH\",\n \"body\": { \"trashed\": true }\n}\n```\n\n## Finishing\n\nReport success only when the `batchUpdate` response contains `replies` with no\nerrors. Do not read the document back to confirm an edit.\n\n## Common Mistakes\n\n- Using PATCH on the document body instead of `batchUpdate` — PATCH only\n updates metadata like title, not content.\n- Treating indexes like array offsets — they count UTF-16 code units, range\n ends are exclusive, and a blank document's first usable body index is 1.\n- Using `insertionIndex` or placing `location.index` directly under\n `insertText` — the supported shape is `insertText.location.index`; for\n appends, prefer `insertText.endOfSegmentLocation`.\n- Attempting a numeric-position edit without fetching the document first — you\n won't know the current indexes.\n- Not escaping regex special characters in `containsText` patterns.\n- Renaming the fields — `searchText`, `replacementText`, `replacement`, `find`\n all return 400 `Unknown name`; only `containsText` + `replaceText` exist.\n- Sending a request object without the `{ \"requests\": [ … ] }` wrapper.\n- Inserting text that is already in the document before an edit — a replace or\n delete works on the existing content directly.\n- Passing curl/Python text or serialized JSON to `http_request` instead of one\n structured tool-call object.\n",
|
|
43
|
+
"google-docs/references/read.md": "# Reading and Finding Google Docs\n\nEvery request is one `http_request` call with a structured object. For each request, set `auth.tokenCredentialKey` to `google_docs_access_token`.\n\n## Get a document\n\n```json\n{\n \"url\": \"https://docs.googleapis.com/v1/documents/{documentId}\",\n \"auth\": { \"tokenCredentialKey\": \"google_docs_access_token\" },\n \"method\": \"GET\"\n}\n```\n\nReturns the full document structure with all content, styling, and revisions.\nThe text lives in `body.content[]` → `paragraph.elements[]` → `textRun.content`;\nwalk that array and join the `content` strings to reconstruct the text. Present\nthe text, not the JSON. A read-only question (\"what does the doc say\", \"summarize\nit\") ends here — do not follow a read with an edit.\n\n## List or search for documents\n\nUse the Google Drive API with a MIME type filter to find Google Docs by name.\nThe `documentId` you need for a follow-up read or edit is the file `id`.\n\n```json\n{\n \"url\": \"https://www.googleapis.com/drive/v3/files\",\n \"auth\": { \"tokenCredentialKey\": \"google_docs_access_token\" },\n \"method\": \"GET\",\n \"query\": {\n \"q\": \"mimeType='application/vnd.google-apps.document' and name contains 'report'\",\n \"pageSize\": 10,\n \"fields\": \"files(id,name,modifiedTime,webViewLink)\"\n }\n}\n```\n\nTo list recent documents without a name filter, drop the `and name contains …`\nclause and add `\"orderBy\": \"modifiedTime desc\"` to the query. Run the search\nONCE; if it returns nothing, say so rather than retrying with variations.\n\n## Common Mistakes\n\n- Searching with the wrong MIME type — it must be\n `application/vnd.google-apps.document`.\n- Echoing the raw JSON to the user instead of the extracted text.\n- Fetching the document through the Drive API — content comes only from the\n Docs API `GET`.\n",
|
|
44
|
+
"google-drive/SKILL.md": "---\nname: google-drive\ndescription: Search, list, download metadata, and manage Google Drive files and folders via the Google Drive REST API.\ntools: [http_request, drive_create_folder, drive_trash]\nplatform: [darwin, linux, win32]\ncredentials: [google_drive_access_token]\nallow_list: [https://www.googleapis.com/drive/v3/]\n---\n\n# Google Drive\n\nUse `drive_create_folder` to create folders and `drive_trash` to trash files; use `http_request` for everything else (list, search, get, export, rename). For each `http_request`, set `auth.tokenCredentialKey` to `google_drive_access_token`.\n\n## Prerequisites\n\nGoogle Drive must be connected. Each Google skill is connected separately, with its\nown app and its own approval — connecting one grants nothing to the others. If\ncredentials are missing, tell the user to connect Google Drive from Settings, or to\nset the `google_drive_access_token` credential.\n\n## Base URL\n\n`https://www.googleapis.com/drive/v3`\n\nAlways pass a `fields` mask on list/get calls — default payloads are huge. Keep `pageSize` small (5–10) unless the user asks for more.\n\n## Common Operations\n\n### List recent files\n\n```json\n{\n \"url\": \"https://www.googleapis.com/drive/v3/files\",\n \"auth\": { \"tokenCredentialKey\": \"google_drive_access_token\" },\n \"method\": \"GET\",\n \"query\": {\n \"pageSize\": 5,\n \"orderBy\": \"modifiedTime desc\",\n \"fields\": \"files(id,name,mimeType,modifiedTime,webViewLink),nextPageToken\"\n }\n}\n```\n\n### Search by name\n\n```json\n{\n \"url\": \"https://www.googleapis.com/drive/v3/files\",\n \"auth\": { \"tokenCredentialKey\": \"google_drive_access_token\" },\n \"method\": \"GET\",\n \"query\": {\n \"q\": \"name contains 'roadmap' and trashed = false\",\n \"pageSize\": 5,\n \"fields\": \"files(id,name,mimeType,modifiedTime,webViewLink),nextPageToken\"\n }\n}\n```\n\nOther useful `q` recipes: folders only → `mimeType = 'application/vnd.google-apps.folder' and trashed = false`; inside a folder → `'{folderId}' in parents and trashed = false`.\n\n### Get file metadata\n\nOnly when the `fileId` is known — list/search first instead of guessing ids.\n\n```json\n{\n \"url\": \"https://www.googleapis.com/drive/v3/files/{fileId}\",\n \"auth\": { \"tokenCredentialKey\": \"google_drive_access_token\" },\n \"method\": \"GET\",\n \"query\": {\n \"fields\": \"id,name,mimeType,size,modifiedTime,owners(displayName,emailAddress),webViewLink\"\n }\n}\n```\n\n### Export Google Doc as plain text\n\n```json\n{\n \"url\": \"https://www.googleapis.com/drive/v3/files/{fileId}/export\",\n \"auth\": { \"tokenCredentialKey\": \"google_drive_access_token\" },\n \"method\": \"GET\",\n \"query\": { \"mimeType\": \"text/plain\" }\n}\n```\n\n### Rename or move a file to trash\n\n`PATCH` with only the fields to change.\n\n```json\n{\n \"url\": \"https://www.googleapis.com/drive/v3/files/{fileId}\",\n \"auth\": { \"tokenCredentialKey\": \"google_drive_access_token\" },\n \"method\": \"PATCH\",\n \"body\": { \"trashed\": true }\n}\n```\n\n## MIME Types Reference\n\n- Google Docs: `application/vnd.google-apps.document` — export as `text/plain`\n- Google Sheets: `application/vnd.google-apps.spreadsheet` — export as `text/csv`\n- Google Slides: `application/vnd.google-apps.presentation` — export as `text/plain`\n- Folder: `application/vnd.google-apps.folder`\n\n## Output Policy\n\n- For lists, show name, type, and last modified; limit to 5 files unless asked for more.\n- When `webViewLink` is available, render the file name as a Markdown link to it — `[name](webViewLink)`. Never present the URL bare or wrapped in backticks, or it won't be clickable.\n- Never attempt to download large binary files; export text content instead.\n- Confirm before trashing files; show the file name so the user can verify.\n\n## Common Mistakes\n\n- Calling `files.list` without a `fields` mask — payloads are huge by default.\n- Calling `files.get` with a guessed file id — list/search first.\n- Downloading binaries instead of exporting text.\n",
|
|
45
|
+
"google-drive/operations.json": "{\n \"operations\": [\n {\n \"tool\": \"drive_create_folder\",\n \"description\": \"Create a Google Drive folder, optionally inside a parent folder.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": { \"type\": \"string\", \"description\": \"Folder name\" },\n \"parentId\": {\n \"type\": \"string\",\n \"description\": \"Parent folder id; omit for the Drive root\"\n }\n },\n \"required\": [\"name\"]\n },\n \"request\": {\n \"method\": \"POST\",\n \"url\": \"https://www.googleapis.com/drive/v3/files\",\n \"builder\": \"drive-create-folder\"\n }\n },\n {\n \"tool\": \"drive_trash\",\n \"description\": \"Move a Google Drive file or folder to the trash. Confirm the file name with the user first; list or search for the id, never guess it.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"fileId\": { \"type\": \"string\", \"description\": \"File id from a previous files.list\" }\n },\n \"required\": [\"fileId\"]\n },\n \"request\": {\n \"method\": \"PATCH\",\n \"url\": \"https://www.googleapis.com/drive/v3/files/{fileId}\",\n \"builder\": \"drive-trash\"\n }\n }\n ]\n}\n",
|
|
46
|
+
"google-sheets/SKILL.md": "---\nname: google-sheets\ndescription: Create, read, and update Google Sheets spreadsheets via the Google Sheets REST API.\ntools: [http_request, sheets_create, sheets_write_values, sheets_append_values]\nplatform: [darwin, linux, win32]\ncredentials: [google_sheets_access_token]\nallow_list:\n [https://sheets.googleapis.com/v4/spreadsheets, https://www.googleapis.com/drive/v3/files]\n---\n\n# Google Sheets\n\nUse `sheets_create`, `sheets_write_values`, and `sheets_append_values` for\ncreating and writing cells — they set `valueInputOption` for you. Use\n`http_request` for everything else (reads, `batchUpdate`, Drive search, trash).\nFor each `http_request`, set `auth.tokenCredentialKey` to\n`google_sheets_access_token`. Call one tool per operation with one complete structured\nobject; `body` and `values` are JSON values, never serialized strings.\n\n## Load the Recipe File First\n\nThis file carries no requests. The working calls live in three reference\nfiles — load the one for the job with the `skill` tool BEFORE calling any tool,\nthen copy its call and change only the values:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Reading or finding spreadsheets** — \"what's in the sheet\", \"show me the\n values\", \"find / list my spreadsheets\": call the `skill` tool with\n `name: \"google-sheets\"` and `file: \"references/read.md\"`.\n- **Creating a new spreadsheet** (no existing spreadsheet involved; may fill\n it with a first table): call the `skill` tool with `name: \"google-sheets\"`\n and `file: \"references/create.md\"`.\n- **Changing an existing spreadsheet** — write or overwrite cells, append rows,\n clear a range, add a sheet tab, other structural changes, move it to the\n trash: call the `skill` tool with `name: \"google-sheets\"` and\n `file: \"references/edit.md\"`.\n\nNever write a call from memory. The recipes carry the range rules and the\nexact request shapes that fail in non-obvious ways when improvised; loading\nthe file is one cheap read-only call.\n\n## Prerequisites\n\nGoogle Sheets must be connected. Each Google skill is connected separately, with its\nown app and its own approval — connecting one grants nothing to the others. If\ncredentials are missing, tell the user to connect Google Sheets from Settings, or to\nset the `google_sheets_access_token` credential.\n\n## Identifiers and Ranges\n\nSpreadsheets are identified by `spreadsheetId` — the alphanumeric string in the\nURL `docs.google.com/spreadsheets/d/{spreadsheetId}/...`, or the `id` of a\n`sheets_create` result or Drive search hit. Listing and searching use the\nGoogle Drive API, not the Sheets API.\n\nRanges use A1 notation: `Sheet1!A1`, `Sheet1!A1:B10`, `Sheet1!A:A` (entire\ncolumn), `Sheet1!1:1` (entire row); quote sheet names with spaces:\n`'My Sheet'!A1`. The first sheet of a spreadsheet is NOT always called\n`Sheet1` — Google names it in the account's language (`Página1`, `Feuille1`,\n`Tabellenblatt1`, …). A range with no sheet name (`A1:C3`) always targets the\nfirst sheet; the recipe files say when to use it.\n\n## Output Policy\n\n- For reads, present values in a table format with headers when available.\n- For writes, confirm the range and values with the user first unless they\n already spelled them out.\n- Report success only when the response contains the `spreadsheetId` and the\n updated range, and name the spreadsheet (title or link) in the answer.\n",
|
|
47
|
+
"google-sheets/operations.json": "{\n \"operations\": [\n {\n \"tool\": \"sheets_create\",\n \"description\": \"Create an empty Google Sheets spreadsheet. The response id is the spreadsheetId for later writes.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": { \"type\": \"string\", \"description\": \"Spreadsheet name\" }\n },\n \"required\": [\"name\"]\n },\n \"request\": {\n \"method\": \"POST\",\n \"url\": \"https://www.googleapis.com/drive/v3/files\",\n \"builder\": \"sheets-create\"\n }\n },\n {\n \"tool\": \"sheets_write_values\",\n \"description\": \"Overwrite cells in a range with a 2D array of rows. The range must match the data dimensions or be a single top-left cell. valueInputOption defaults to USER_ENTERED (formulas and dates are parsed).\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"spreadsheetId\": { \"type\": \"string\", \"description\": \"Spreadsheet id\" },\n \"range\": {\n \"type\": \"string\",\n \"description\": \"A1 notation. Omit the sheet name to target the first sheet whatever its language (e.g. A1); name a sheet only for another tab, quoted if it has spaces (e.g. 'My Sheet'!A1)\"\n },\n \"values\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": { \"type\": [\"string\", \"number\", \"boolean\", \"null\"] }\n },\n \"description\": \"Rows of cell values\"\n },\n \"valueInputOption\": {\n \"type\": \"string\",\n \"enum\": [\"RAW\", \"USER_ENTERED\"],\n \"description\": \"RAW inserts values as-is; USER_ENTERED parses formulas and dates\"\n }\n },\n \"required\": [\"spreadsheetId\", \"range\", \"values\"]\n },\n \"request\": {\n \"method\": \"PUT\",\n \"url\": \"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheetId}/values/{range}\",\n \"builder\": \"sheets-write-values\"\n }\n },\n {\n \"tool\": \"sheets_append_values\",\n \"description\": \"Append rows after the last row of data in a range. valueInputOption defaults to USER_ENTERED.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"spreadsheetId\": { \"type\": \"string\", \"description\": \"Spreadsheet id\" },\n \"range\": {\n \"type\": \"string\",\n \"description\": \"A1 notation of the table's columns to append to. Omit the sheet name for the first sheet (e.g. A:C); name a sheet only for another tab (e.g. 'My Sheet'!A:C)\"\n },\n \"values\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": { \"type\": [\"string\", \"number\", \"boolean\", \"null\"] }\n },\n \"description\": \"Rows of cell values\"\n },\n \"valueInputOption\": {\n \"type\": \"string\",\n \"enum\": [\"RAW\", \"USER_ENTERED\"]\n }\n },\n \"required\": [\"spreadsheetId\", \"range\", \"values\"]\n },\n \"request\": {\n \"method\": \"POST\",\n \"url\": \"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheetId}/values/{range}:append\",\n \"builder\": \"sheets-append-values\"\n }\n }\n ]\n}\n",
|
|
48
|
+
"google-sheets/references/create.md": "# Creating a Spreadsheet\n\nTwo tool calls: `sheets_create` makes the file, `sheets_write_values` fills\nit. Typed tools select their own credential.\n\n## Create the spreadsheet\n\n```json\n{ \"name\": \"My Spreadsheet\" }\n```\n\nCall `sheets_create` with that object. The result's `id` is the\n`spreadsheetId`. Capture it and keep using it. Create exactly once — never\nfollow a successful create with a Drive search or another create to rediscover\nthe id.\n\n## Fill it with a first table\n\nCall `sheets_write_values`. `values` is an array of rows; every row is an array\nof cells. Use the range `A1` with NO sheet name: the new file's only sheet is\nnamed in the account's language (`Sheet1`, `Página1`, `Feuille1`, …), and a\nrange without a sheet name always targets the first sheet, so the write cannot\nfail on the name.\n\n```json\n{\n \"spreadsheetId\": \"<id from sheets_create>\",\n \"range\": \"A1\",\n \"values\": [\n [\"Name\", \"Age\"],\n [\"Alice\", 30],\n [\"Bob\", 25]\n ]\n}\n```\n\n`valueInputOption` defaults to `USER_ENTERED` (formulas, numbers and dates are\nparsed like typing them in); pass `\"valueInputOption\": \"RAW\"` only to store\nevery value exactly as given. The response reports `updatedRange` and\n`updatedCells` — that is the confirmation.\n\n## Finishing\n\nAnswer with the spreadsheet name and the link\n`https://docs.google.com/spreadsheets/d/{spreadsheetId}/edit`. Do not read the\nvalues back to \"confirm\" a write that already reported `updatedCells`.\n\n## Common Mistakes\n\n- Writing to `Sheet1!A1` on a freshly created file — if the account's language\n is not English the sheet is not called `Sheet1` and the write fails with\n `Unable to parse range`. Use `A1` without a sheet name.\n- Creating through `http_request` — `sheets_create` is the supported path.\n- Passing `values` as a string, or rows that are not arrays.\n",
|
|
49
|
+
"google-sheets/references/edit.md": "# Editing an Existing Spreadsheet\n\nCell writes use the typed tools `sheets_write_values` (overwrite) and\n`sheets_append_values` (add rows) — they set `valueInputOption` for you.\nEverything else (clear, structural changes, trash) is one `http_request` with\na structured object. For each `http_request`, set `auth.tokenCredentialKey` to `google_sheets_access_token`. Get the `spreadsheetId` from the URL the user gave, an earlier\n`sheets_create` result, or a Drive search (`references/read.md`).\n\nRanges: a range with no sheet name (`A1`, `A2:B10`) targets the first sheet\nwhatever its language. Name a sheet only for another tab, quoted if it has\nspaces (`'My Sheet'!A1`).\n\n## Overwrite cells\n\nCall `sheets_write_values`. The range is the top-left cell; the values block\ngrows from there. `values` is an array of rows, each an array of cells.\n\n```json\n{\n \"spreadsheetId\": \"<id>\",\n \"range\": \"A1\",\n \"values\": [\n [\"Name\", \"Age\"],\n [\"Alice\", 30],\n [\"Bob\", 25]\n ]\n}\n```\n\nTo change one cell, use that cell as the range with a single-row, single-cell\n`values` array (`\"range\": \"B2\", \"values\": [[\"100\"]]`). Pass\n`\"valueInputOption\": \"RAW\"` only to store values exactly as given; the default\n`USER_ENTERED` parses formulas, numbers and dates.\n\n## Append rows\n\nCall `sheets_append_values` with the range of the table the rows belong to\n(its columns, not the empty row you expect); the API finds the first empty row\nafter that table.\n\n```json\n{\n \"spreadsheetId\": \"<id>\",\n \"range\": \"A:B\",\n \"values\": [\n [\"Charlie\", 28],\n [\"Diana\", 32]\n ]\n}\n```\n\n## Clear a range\n\n```json\n{\n \"url\": \"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheetId}/values/A2:B10:clear\",\n \"auth\": { \"tokenCredentialKey\": \"google_sheets_access_token\" },\n \"method\": \"POST\",\n \"body\": {}\n}\n```\n\n## Structural changes (batchUpdate)\n\nAdding or removing sheets, resizing, formatting, and other structural\noperations use `batchUpdate`; its items are always wrapped in\n`{ \"requests\": [ … ] }`, and cell values never go through it:\n\n```json\n{\n \"url\": \"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheetId}:batchUpdate\",\n \"auth\": { \"tokenCredentialKey\": \"google_sheets_access_token\" },\n \"method\": \"POST\",\n \"body\": {\n \"requests\": [\n {\n \"addSheet\": {\n \"properties\": { \"title\": \"New Sheet\", \"sheetType\": \"GRID\" }\n }\n }\n ]\n }\n}\n```\n\nRequests that target a sheet by `sheetId` (delete, resize, format) need the\nnumeric id from the metadata call in `references/read.md`, not the tab name.\n\n## Move a spreadsheet to the trash\n\nTrashing goes through the Drive API; the user can restore it from Drive's\ntrash. Only do this when the user explicitly asks to delete or remove the\nspreadsheet.\n\n```json\n{\n \"url\": \"https://www.googleapis.com/drive/v3/files/{spreadsheetId}\",\n \"auth\": { \"tokenCredentialKey\": \"google_sheets_access_token\" },\n \"method\": \"PATCH\",\n \"body\": { \"trashed\": true }\n}\n```\n\n## If a range is rejected\n\n`400 Unable to parse range: Sheet1!A1` means the spreadsheet has no sheet\ncalled `Sheet1` — Google names the first sheet in the account's language. Do\nNOT retry the same range: drop the sheet name, or GET the metadata\n(`references/read.md`) for the real title.\n\n## Finishing\n\nA write is confirmed by `updatedRange`/`updatedCells` (or `updates` for\nappend, `replies` for batchUpdate) in the response — do not read the values\nback to check. Name the spreadsheet in the answer.\n\n## Common Mistakes\n\n- Writing cell values with `http_request` PUT or with `batchUpdate` — use the\n typed tools.\n- Naming a sheet that does not exist (`Sheet1!…` on a non-English account) —\n leave the sheet name out for the first sheet.\n- Appending to an empty row range instead of the table's columns.\n- Passing `values` as a string, or rows that are not arrays.\n",
|
|
50
|
+
"google-sheets/references/read.md": "# Reading and Finding Spreadsheets\n\nReads go through `http_request` with a structured object. For each request, set `auth.tokenCredentialKey` to `google_sheets_access_token`.\n\n## Get values from a range\n\nAlways specify `valueRenderOption`: `FORMATTED_VALUE` for displayed values\n(formulas evaluated) or `UNFORMATTED_VALUE` for raw cell content.\n\n```json\n{\n \"url\": \"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheetId}/values/A1:C10\",\n \"auth\": { \"tokenCredentialKey\": \"google_sheets_access_token\" },\n \"method\": \"GET\",\n \"query\": { \"valueRenderOption\": \"FORMATTED_VALUE\" }\n}\n```\n\nA range with no sheet name (`A1:C10`) reads the first sheet whatever its\nlanguage; add a sheet name only for another tab, quoted if it has spaces\n(`'My Sheet'!A1:C10`). The response contains a `values` array of rows; each row\nis an array of cell strings. Trailing empty cells are omitted, so rows can\ndiffer in length. To read everything on the first sheet, GET the spreadsheet\nmetadata below for the tab title and use it alone as the range. Present the\nrows as a table with the first row as headers when it looks like one.\n\n## Get spreadsheet metadata (sheet names)\n\nWhen you need the tab names or their numeric ids before a follow-up call:\n\n```json\n{\n \"url\": \"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheetId}\",\n \"auth\": { \"tokenCredentialKey\": \"google_sheets_access_token\" },\n \"method\": \"GET\",\n \"query\": { \"fields\": \"spreadsheetId,properties.title,sheets.properties\" }\n}\n```\n\n## List or search for spreadsheets\n\nUse the Google Drive API with a MIME type filter to find spreadsheets by name.\nThe `spreadsheetId` you need for a follow-up call is the file `id`.\n\n```json\n{\n \"url\": \"https://www.googleapis.com/drive/v3/files\",\n \"auth\": { \"tokenCredentialKey\": \"google_sheets_access_token\" },\n \"method\": \"GET\",\n \"query\": {\n \"q\": \"mimeType='application/vnd.google-apps.spreadsheet' and name contains 'budget' and trashed = false\",\n \"pageSize\": 10,\n \"fields\": \"files(id,name,modifiedTime,webViewLink)\"\n }\n}\n```\n\nTo list recent spreadsheets without a name filter, drop the `and name contains …`\nclause and add `\"orderBy\": \"modifiedTime desc\"`. Run the search ONCE; if it\nreturns nothing, say so rather than retrying with variations.\n\n## If a range is rejected\n\n`400 Unable to parse range: Sheet1!A1` means the spreadsheet has no sheet\ncalled `Sheet1` — Google names the first sheet in the account's language. Do\nNOT retry the same range: drop the sheet name (`A1:C10`) or take the real title\nfrom the metadata call above.\n\n## Common Mistakes\n\n- Using the wrong MIME type when searching (should be\n `application/vnd.google-apps.spreadsheet`).\n- Reading values through the Drive API — cell values come only from the Sheets\n API `values` endpoint.\n",
|
|
51
|
+
"image-generation/SKILL.md": "---\nname: image-generation\ndescription: Generate images from text prompts with the local diffusion model.\ntools: [generate_image, edit_image]\nplatform: [darwin, linux, win32]\n---\n\n# Image generation\n\nCall `generate_image` when the user asks for an image, picture, illustration,\ndrawing, or logo. The generated image is saved as a chat attachment and shown\nto the user automatically — never describe pixels, paste data, or apologize\nabout being text-only; just confirm what you generated.\n\n```json\n{ \"prompt\": \"a watercolor cat on a windowsill, soft morning light\" }\n```\n\n## Parameters\n\n- `prompt` (required) — describe subject, style, and mood in plain language.\n- `width` / `height` — pixels, multiples of 64, max 1024. Default 512×512;\n only change them when the user asks for a specific shape (e.g. wide banner\n → 1024×512).\n- `negative_prompt` — what to avoid (e.g. `blurry, low quality, watermark`).\n- `seed` — set only when the user wants a reproducible or slightly varied\n retry of a previous result.\n- `steps` — leave unset unless the user asks for a faster draft (lower) or\n higher quality (higher, max 100).\n\n## Editing an existing image\n\nCall `edit_image` when the user wants an image from this chat modified,\nrestyled, or varied (\"make it a watercolor\", \"same but at night\"). It edits\nthe most recent image by default; pass `attachment_id` (from an earlier\nresult) to target another. `strength` 0..1 sets how far to move from the\nsource: 0.3-0.5 for subtle changes, 0.7 (default) for restyling, 0.9 for\nloose reinterpretation. Output keeps the source dimensions.\n\n```json\n{ \"prompt\": \"turn it into a watercolor painting\", \"strength\": 0.7 }\n```\n\n## Notes\n\n- Generation takes a while; the result arrives as an attachment in this turn.\n- One generation or edit at a time — if the tool reports it is busy, wait and\n retry instead of stacking calls.\n",
|
|
52
|
+
"music-generation/SKILL.md": "---\nname: music-generation\ndescription: Create original music from an idea, mood, scene, or lyrics. Make complete tracks, with instrumentals, vocals and variations in any music style.\ntools: [generate_music]\nplatform: [darwin, linux, win32]\n---\n\n# Music generation\n\nCall `generate_music` when the user asks for a song, track, beat, melody,\njingle, background music, or any generated audio. The result is saved as a chat\nattachment and played to the user automatically — never describe waveforms,\npaste data, or apologize about being text-only; just confirm what you generated.\nMatch your description to the tool result's `instrumental` flag: never tell the\nuser a track has vocals when it came back instrumental.\n\n```json\n{\n \"prompt\": \"lo-fi hip hop, mellow piano, soft drums, warm bass\",\n \"title\": \"midnight study session\"\n}\n```\n\n## Instrumental or sung\n\nVocals come from the `lyrics` argument. It is the only switch that makes the\nmodel sing: a voice cue in the `prompt` (e.g. \"male vocals\") only sets the voice\ntimbre, and with no `lyrics` the track is always instrumental, whatever the\nprompt says.\n\n- **Instrumental, background music, or a beat with no singing**: omit `lyrics`\n entirely. That produces an instrumental (the model's default).\n- **A song with singing, vocals, or words**: you must pass `lyrics`, structured\n with `[verse]` / `[chorus]` tags. Name who sings with a tag in the `prompt`\n (`male vocal`, `female vocal`, or for a duet `male and female duet, harmonized\n vocals`) to set the voice, and set `vocalLanguage` when the user names a\n language.\n- **The user wants singing but gave no words**: write short `[verse]` /\n `[chorus]` lyrics yourself from their topic and pass them as `lyrics`. Never\n leave `lyrics` empty for a sung request, or the track comes out instrumental.\n\nSet `duration` when the user asks for a specific length (\"30 seconds\", \"a\ntwo-minute track\"); otherwise leave it unset and let the model choose.\n\n## Parameters\n\n- `prompt` (required) - describe the genre, instruments, mood, and vocal\n arrangement in plain language. It is the caption ACE-Step reads for the sound\n and the voice timbre, so name who sings here; the singing itself still needs\n `lyrics` (see \"Instrumental or sung\").\n- `title` — a short, descriptive song title (2 to 5 words) used to name the\n saved audio file. Set it whenever you generate a track so the download has a\n human-readable name; keep it plain and omit any file extension. Without it the\n file falls back to a slug of the `prompt`.\n- `lyrics` - the sung words, and the switch that turns vocals on. Omit for an\n instrumental (the default). Structure them with `[verse]` / `[chorus]` tags. A\n voice cue in the `prompt` alone does not sing; vocals need `lyrics` here.\n- `vocalLanguage` — the language the vocals are sung in, as a short code\n (`en`, `es`, `de`, `it`, …). Set it when the user names a language; it only\n applies with lyrics and defaults to English.\n- `duration` — approximate length in seconds. Leave unset to let the model\n choose; set it only when the user asks for a specific length. The model rounds\n to its own frame grid, so the clip may be slightly shorter or longer.\n- `seed` — set only when the user wants a reproducible or slightly varied retry\n of a previous result; `-1` or omitted is random.\n- `bpm` — tempo in beats per minute; set it when the user names a tempo.\n- `keyscale` — musical key and scale (e.g. `C minor`, `A major`), when named.\n- `timesignature` — meter (e.g. `4/4`, `3/4`), when the user names one.\n\n## Notes\n\n- Generation takes a while; the result arrives as an attachment in this turn.\n- One generation at a time — if the tool reports it is busy, wait and retry\n instead of stacking calls.\n- The first use downloads several gigabytes of model weights; that happens once,\n before the first track is produced.\n",
|
|
53
|
+
"notion/SKILL.md": "---\nname: notion\ndescription: Notion pages, databases, and blocks — search, read, create, update, and comment via the official Notion MCP server.\ntools: [mcp_call, notion_create_page, notion_insert_content]\nplatform: [darwin, linux, win32, ios, android]\ncredentials: [notion_mcp_access_token]\nallow_list: [https://mcp.notion.com/mcp]\nmcp_reads:\n [\n notion-search,\n notion-fetch,\n notion-get-comments,\n notion-get-teams,\n notion-get-users,\n notion-get-async-task,\n notion-query-data-sources\n ]\n---\n\n# Notion\n\nTwo typed tools cover the most common writes and are called directly, like `skill`: `notion_create_page` creates a page and `notion_insert_content` adds text to one. Everything else is one `mcp_call` against `https://mcp.notion.com/mcp` with the **tool name as `method`** and its **args as `params`** — the JSON-RPC envelope, session handshake, and bearer token are handled for you. Do **not** build a `{name, arguments}` envelope, pass a `sessionId`, or call `initialize`/`notifications/initialized`/`tools/list`.\n\n```json\n{\n \"url\": \"https://mcp.notion.com/mcp\",\n \"method\": \"notion-search\",\n \"params\": { \"query\": \"Q4 roadmap\", \"page_size\": 5 }\n}\n```\n\nRules that hold for every call:\n\n- `params` is a JSON object, never a string.\n- Page, database, and view ids come from a URL the user gave or from a previous result — never invented.\n- A page title is always a plain string: `\"properties\": { \"title\": \"The title\" }` when creating several pages or renaming one.\n- Deleting or archiving a page is not possible through this connection — say so instead of improvising (details in `references/pages.md`).\n- A `401` status means Notion isn't connected — tell the user to connect \"Notion\" from the skill's setup; do not drive OAuth yourself. An \"object not found\" / no-access error means the connection can't see that object — tell the user to share the page or database with the Notion connection.\n\n## Finding pages\n\nThere is no \"list all\" — `notion-search` is the entry point and `query` is required (min length 1). For a broad overview pass a keyword from the request, and always bound results with a small `page_size`. Each result carries `title`, `url`, and `id`. Results are ranked by semantic similarity, not by title — when one result's title matches the requested name (case-insensitive), take that one even if it isn't first. When the user named a specific page and no title matches, list the candidates as `[title](url)` and ask instead of guessing. Search once per request; do not re-run it with variations.\n\n`notion-search` knows nothing about the connected identity — for \"who am I\" / \"which workspace\" call `notion-fetch` with `id: \"self\"` (see `references/pages.md`).\n\n## Load the Recipe File First\n\nThis file carries no other calls. The tool tables and working calls live in four reference files — load the one for the job with the `skill` tool BEFORE calling a Notion tool, then copy its call and change only the values. Each load is a real `skill` tool call — printing the call as JSON or text in your reply loads nothing.\n\n- **Pages** — read or summarize a page, \"who am I / which workspace\", create a page, add / change / replace text, rename or set a property, delete / archive (and why it cannot), move, duplicate: call the `skill` tool with `name: \"notion\"` and `file: \"references/pages.md\"`.\n- **Databases, data sources and views** — create a database, add a row, read a database's schema, query or filter rows, create or change a view: call the `skill` tool with `name: \"notion\"` and `file: \"references/databases.md\"`.\n- **Comments** (also people and teamspaces) — read or add a comment, list workspace members, look up a user, list teamspaces: call the `skill` tool with `name: \"notion\"` and `file: \"references/comments.md\"`.\n- **Async tasks** — a result carried a `task_id`, or the user asks whether a duplicate / large write finished: call the `skill` tool with `name: \"notion\"` and `file: \"references/tasks.md\"`.\n\nNever write a call from memory. Each tool's arguments have one exact shape (`command` + its single companion field, `data.mode` + its fields) and the server rejects anything else; loading the file is one cheap read-only call. After the file is loaded, your next output is the tool call — no further loads.\n\n## Output\n\n- Show page titles and IDs together. When a result carries a `url`, render the title as a Markdown link — `[title](url)` — never bare or in backticks, or it won't be clickable.\n- For query results, lead with counts/rollups if present, then rows — don't dump the raw structured response. Surface user-visible property names, not IDs.\n- If a query tool responds with an upgrade prompt, tell the user their plan doesn't support it (single-source needs Business+Notion AI, multi-source needs Enterprise+Notion AI) — don't retry.\n",
|
|
54
|
+
"notion/operations.json": "{\n \"operations\": [\n {\n \"tool\": \"notion_create_page\",\n \"description\": \"Create one Notion page with a title and optional Markdown body, under a parent page or database. One user request means one call; never recreate a page to make sure it landed.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": { \"type\": \"string\", \"description\": \"Exact title from the user\" },\n \"content\": {\n \"type\": \"string\",\n \"description\": \"Page body in Notion-flavored Markdown; do not repeat the title\"\n },\n \"parentPageId\": { \"type\": \"string\", \"description\": \"Parent page id or URL\" },\n \"parentDatabaseId\": {\n \"type\": \"string\",\n \"description\": \"Parent database id or URL, for a database row\"\n }\n },\n \"required\": [\"title\"]\n },\n \"request\": {\n \"transport\": \"mcp\",\n \"method\": \"notion-create-pages\",\n \"url\": \"https://mcp.notion.com/mcp\",\n \"builder\": \"notion-create-page\"\n }\n },\n {\n \"tool\": \"notion_insert_content\",\n \"description\": \"Add Markdown text to an existing Notion page, at the end by default or at the start. For changing text that is already there use notion-update-page with update_content via mcp_call.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"pageId\": { \"type\": \"string\", \"description\": \"Page id or URL\" },\n \"content\": { \"type\": \"string\", \"description\": \"Markdown to add\" },\n \"position\": {\n \"type\": \"string\",\n \"enum\": [\"start\", \"end\"],\n \"description\": \"Defaults to end\"\n }\n },\n \"required\": [\"pageId\", \"content\"]\n },\n \"request\": {\n \"transport\": \"mcp\",\n \"method\": \"notion-update-page\",\n \"url\": \"https://mcp.notion.com/mcp\",\n \"builder\": \"notion-insert-content\"\n }\n }\n ]\n}\n",
|
|
55
|
+
"notion/references/comments.md": "# Notion Comments, People and Teamspaces\n\nEvery call is one `mcp_call` with `url: \"https://mcp.notion.com/mcp\"`, the tool name as `method`, and its args as `params` (a JSON object, never a string). Call these tools directly — no `tools/list`.\n\n| tool | required args | use for |\n| ----------------------- | ----------------------------- | ------------------------------------------- |\n| `notion-get-comments` | `page_id` | read a page's comments and discussions |\n| `notion-create-comment` | `page_id`, `rich_text` | comment on a page |\n| `notion-get-users` | — (optional `id` or `\"self\"`) | list workspace members, or look up one user |\n| `notion-get-teams` | — | list teamspaces |\n\n## Reading comments\n\n```json\n{\n \"url\": \"https://mcp.notion.com/mcp\",\n \"method\": \"notion-get-comments\",\n \"params\": { \"page_id\": \"<page id or URL>\" }\n}\n```\n\nThe result includes block-level and resolved threads. Report each comment with its author and text; a read-only question ends after the read.\n\n## Adding a comment\n\nThe comment text goes in `rich_text` as one text object:\n\n```json\n{\n \"url\": \"https://mcp.notion.com/mcp\",\n \"method\": \"notion-create-comment\",\n \"params\": {\n \"page_id\": \"<page id or URL>\",\n \"rich_text\": [{ \"text\": { \"content\": \"The comment\" } }]\n }\n}\n```\n\nOne user request → one comment. After the result, say the comment was added and link the page.\n\n## People\n\n`notion-get-users` with `params: {}` lists workspace members and guests (id, name, email, type). Pass `\"id\": \"self\"` for the connected user, or a user id to look one up:\n\n```json\n{ \"url\": \"https://mcp.notion.com/mcp\", \"method\": \"notion-get-users\", \"params\": {} }\n```\n\nFor \"who am I / which workspace\" prefer `notion-fetch` with `\"id\": \"self\"` (see `references/pages.md`) — it also names the workspace.\n\n## Teamspaces\n\n```json\n{ \"url\": \"https://mcp.notion.com/mcp\", \"method\": \"notion-get-teams\", \"params\": {} }\n```\n\nLists the teamspaces and whether the connected user is a member of each.\n\n## Output\n\nShow people by name (and email when present), pages as `[title](url)` links. Never invent a user id — take it from `notion-get-users`.\n\n## Now act\n\nYour next output is the tool call (or, after the result, the reply) — no further skill loads.\n",
|
|
56
|
+
"notion/references/databases.md": "# Notion Databases, Data Sources and Views\n\nA database holds one or more **data sources** (the typed schema behind it) and **views** over them. Every call is one `mcp_call` with `url: \"https://mcp.notion.com/mcp\"`, the tool name as `method`, and its args as `params` (a JSON object, never a string). Call these tools directly — no `tools/list`.\n\n| tool | required args | use for |\n| --------------------------- | ---------------------------------------- | ------------------------------------------------------------------------ |\n| `notion-fetch` | `id` (database / data-source / view URL) | read the schema, the `collection://` data-source URLs, and the view URLs |\n| `notion-query-data-sources` | `data` (`mode` + its fields) | run a database view, or query rows by SQL |\n| `notion_create_page` | `title`, `parentDatabaseId` | add one row to a database (typed tool — no `params`) |\n| `notion-create-database` | properties for the new database | create a database + its initial data source + view |\n| `notion-update-data-source` | `data_source_id` + fields | rename a data source or edit its properties |\n| `notion-create-view` | `data_source_id`, `name`, `type` | add a table / board / list / calendar / timeline / gallery view |\n| `notion-update-view` | `view_id` + fields | edit a view's name, filters, sorts, or display |\n\n## Reading a database\n\nFetch the database first: the result carries the schema, each data source's `collection://<id>` URL (used as the SQL table name), and each view's URL (the one carrying `?v=<view-id>`).\n\n```json\n{\n \"url\": \"https://mcp.notion.com/mcp\",\n \"method\": \"notion-fetch\",\n \"params\": { \"id\": \"<database id or URL>\" }\n}\n```\n\n## Querying rows\n\n`notion-query-data-sources` nests every argument under `data`, and the two modes take different fields — send the wrong one and the server rejects the call:\n\n| `mode` | carries | for |\n| --------------- | ---------------------------- | ------------------------------------------- |\n| `view` | `view_url` | run a database view's own filters and sorts |\n| `sql` (default) | `data_source_urls` + `query` | filter, group or aggregate rows yourself |\n\nView mode works on every plan, so start there:\n\n```json\n{\n \"url\": \"https://mcp.notion.com/mcp\",\n \"method\": \"notion-query-data-sources\",\n \"params\": { \"data\": { \"mode\": \"view\", \"view_url\": \"<database url including ?v=>\" } }\n}\n```\n\nSQL mode is plan-gated: `data_source_urls` lists the `collection://` URLs and `query` is SQLite using them as table names. If the response is an upgrade prompt, tell the user their plan doesn't support it (single-source needs Business+Notion AI, multi-source needs Enterprise+Notion AI) — don't retry.\n\n## Adding a row\n\nA row is a page whose parent is the database. Use `notion_create_page` with `parentDatabaseId`; the `title` fills the title property and `content` becomes the row's page body:\n\n```json\n{ \"title\": \"Row title\", \"content\": \"Optional body\", \"parentDatabaseId\": \"<database id or URL>\" }\n```\n\nSet other properties afterwards with `notion-update-page` → `update_properties` (see `references/pages.md`).\n\n## Creating and changing structure\n\n`notion-create-database` creates the database with its first data source and view; `notion-update-data-source` renames a data source or changes its properties; `notion-create-view` / `notion-update-view` manage views. Take the `data_source_id` and `view_id` from a `notion-fetch` of the database — never guess them. If the server rejects the call, quote its message and ask the user how to proceed instead of retrying with invented fields.\n\n## Output\n\nLead with counts and rollups when present, then rows; surface user-visible property names, not IDs. Show the database as a `[title](url)` link.\n\n## Now act\n\nYour next output is the tool call (or, after the result, the reply) — no further skill loads.\n",
|
|
57
|
+
"notion/references/pages.md": "# Notion Pages\n\nCreate with `notion_create_page`, add text with `notion_insert_content`; every other call is one `mcp_call` with `url: \"https://mcp.notion.com/mcp\"`, the tool name as `method`, and its args as `params` (a JSON object, never a string). Call these tools directly — no `tools/list`.\n\n| tool | required args | use for |\n| ----------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------- |\n| `notion-fetch` | `id` (page URL or ID, or `\"self\"`) | read a page's properties + content, or the connection identity |\n| `notion_create_page` | `title`; optional `content`, `parentPageId` | create one page (typed tool — no `params`) |\n| `notion-create-pages` | `pages` | create several pages in one request; `allow_async: true` for very large content |\n| `notion_insert_content` | `pageId`, `content`; optional `position` | add text to a page (typed tool — no `params`) |\n| `notion-update-page` | `page_id`, `command`, + its one companion field | change text, rename, set a property, icon, cover |\n| `notion-move-pages` | `page_or_database_ids`, `new_parent` | reparent pages or databases |\n| `notion-duplicate-page` | `page_id` | duplicate a page — always async, poll the returned task (`references/tasks.md`) |\n\nComments are not a page command: to read or add a comment, load `references/comments.md` and use `notion-create-comment` / `notion-get-comments`.\n\n## Reading a page\n\n`notion-fetch` returns the page's properties and its content as Markdown. Always name the page you read (title + link) when reporting its content. A read-only question ends after the read — never follow it with a write.\n\n```json\n{\n \"url\": \"https://mcp.notion.com/mcp\",\n \"method\": \"notion-fetch\",\n \"params\": { \"id\": \"<page id or URL>\" }\n}\n```\n\nFor \"who am I\" / \"which workspace\", the same call with `\"id\": \"self\"` is the fixed one-call answer — never loop on search for it.\n\n## Creating a page\n\nOne user request → one create. `notion_create_page` is a tool of its own, listed beside `mcp_call` — call it directly with these arguments (it is not a `method` for `mcp_call`, and it takes no `url` or `params`):\n\n```json\n{ \"title\": \"Exact title from the user\", \"content\": \"The page body\" }\n```\n\n`title` is the exact title from the user; `content` is optional Markdown (do not repeat the title in the body). For a private standalone page send only those two fields — no parent field at all. Add `\"parentPageId\": \"<page id or URL>\"` only when the user named a parent page, or `\"parentDatabaseId\"` for a database row.\n\nUse `notion-create-pages` through `mcp_call` only when one request asks for several pages. Each entry uses `properties.title` — a plain string — plus optional `content`; leave `parent` out for standalone pages and add `\"parent\": { \"page_id\": \"<id>\" }` beside `pages` only when the user named one:\n\n```json\n{\n \"url\": \"https://mcp.notion.com/mcp\",\n \"method\": \"notion-create-pages\",\n \"params\": {\n \"pages\": [\n { \"properties\": { \"title\": \"First title\" }, \"content\": \"First body\" },\n { \"properties\": { \"title\": \"Second title\" }, \"content\": \"Second body\" }\n ]\n }\n}\n```\n\nOne user request → one `notion-create-pages` write unless they asked for several pages in that request. Never recreate a page to \"make sure\", to fill an empty wrap-up, or because a previous call timed out. After a confirmed create, show the title as a Markdown link from the result `url` and keep the result `id` for follow-up edits. To verify, `notion-search` / `notion-fetch` — do not write again. If create times out or returns an uncertain result: search for the **exact title**, then fetch any title match. Only create again if no exact-title page exists.\n\n## Adding text\n\n`notion_insert_content` is also a tool of its own — call it directly. It appends by default; `\"position\": \"start\"` prepends. It sends `\"command\": \"insert_content\"` to `notion-update-page` for you, so never call that command through `mcp_call`.\n\n```json\n{ \"pageId\": \"<page id or URL>\", \"content\": \"The line to add\" }\n```\n\n## Changing a page\n\n`notion-update-page` always takes `page_id` and `command`. Each command carries **its own one extra field** — send a different one and the server rejects the call, so pick the row before writing:\n\n| `command` | carries | for |\n| --------------------- | --------------------- | ------------------------------------ |\n| `update_content` | `content_updates` | change part of the body |\n| `replace_content` | `new_str` | overwrite the whole body |\n| `update_properties` | `properties` | title, status, any database property |\n| `apply_template` | `template_id` | apply a database template |\n| `update_verification` | `verification_status` | mark a page verified |\n\nRename = `update_properties` with the new title as a plain string:\n\n```json\n{\n \"url\": \"https://mcp.notion.com/mcp\",\n \"method\": \"notion-update-page\",\n \"params\": {\n \"page_id\": \"<page id or URL>\",\n \"command\": \"update_properties\",\n \"properties\": { \"title\": \"New title\" }\n }\n}\n```\n\nReserve `update_content` for changing text that is already there: its `content_updates` is an array of `{ \"old_str\", \"new_str\" }` pairs, `old_str` must match the page exactly, so `notion-fetch` first. `update_content` has no top-level `new_str`. `new_str` is a Markdown string, never an array. Icon and cover can be set alongside any command.\n\n## Archiving (deleting) a page\n\nThis connection cannot move a page to Trash: `notion-update-page` rejects `in_trash`, and there is no delete tool. When the user asks to delete, remove or archive a page, make no tool call — reply that the page has to be deleted in Notion itself (open the page, `•••` menu, **Move to Trash**) and link it. Never empty the page, overwrite its body, rename it or move it as a substitute for deleting it.\n\n## Moving and duplicating\n\n```json\n{\n \"url\": \"https://mcp.notion.com/mcp\",\n \"method\": \"notion-move-pages\",\n \"params\": {\n \"page_or_database_ids\": [\"<page id>\"],\n \"new_parent\": { \"page_id\": \"<new parent id>\" }\n }\n}\n```\n\n`new_parent` is `{ \"page_id\": … }`, `{ \"database_id\": … }`, or `{ \"data_source_id\": … }`. `notion-duplicate-page` takes `page_id` and returns a `task_id`, not the copy — load `references/tasks.md` to poll it before telling the user it is done.\n\n## Output\n\nAfter a write, show the page as a `[title](url)` link and say what changed. After a read, report the content under the page's title and link.\n\n## Now act\n\nYour next output is the tool call (or, after the result, the reply) — no further skill loads.\n",
|
|
58
|
+
"notion/references/tasks.md": "# Notion Async Tasks\n\nSome writes do not finish inside the call. The response then carries a `task_id`, not the final result — poll it, and don't tell the user it's done until the status is `succeeded`. Every call is one `mcp_call` with `url: \"https://mcp.notion.com/mcp\"`, the tool name as `method`, and its args as `params` (a JSON object, never a string).\n\n| tool | required args | use for |\n| ----------------------- | ------------- | ------------------------------- |\n| `notion-get-async-task` | `task_id` | poll an async operation's state |\n\nWhich calls go async:\n\n- `notion-duplicate-page` — always.\n- `notion-create-pages` and `notion-update-page` — when sent with `\"allow_async\": true` (use it only for very large content).\n\n## Polling\n\n```json\n{\n \"url\": \"https://mcp.notion.com/mcp\",\n \"method\": \"notion-get-async-task\",\n \"params\": { \"task_id\": \"<task_id from the prior response>\" }\n}\n```\n\nStatus is `queued`, `running`, `retrying`, `succeeded`, or `failed`. Respect the suggested backoff in the response before polling again; poll at most a few times, then tell the user it is still running and how to check later. On `succeeded`, report the result it carries (for a duplicate, the new page's title as a `[title](url)` link). On `failed`, quote the error and stop — do not retry the original write.\n\n## Now act\n\nYour next output is the tool call (or, after the result, the reply) — no further skill loads.\n",
|
|
59
|
+
"obsidian/SKILL.md": "---\nname: obsidian\ndescription: Manage local Obsidian vault notes through the registered Obsidian CLI. Search, read, create, append, rename, move, inspect backlinks, list tasks, and work with daily notes.\ntools: [exec(obsidian)]\nplatform: [darwin, linux, win32]\nmetadata:\n {\n \"openclaw\":\n {\n \"requires\":\n {\n \"bins\": [\"obsidian\"],\n \"binMinVersions\": { \"obsidian\": { \"min\": \"1.12.7\", \"command\": \"obsidian version\" } }\n },\n \"setup\":\n {\n \"summary\": \"Obsidian works through the official Obsidian CLI connected to the running desktop app. You can instead select a vault folder for direct Markdown file access; app-only actions need the CLI. Workbench opens Obsidian on demand when using the CLI.\",\n \"routes\":\n [\n {\n \"kind\": \"instructions\",\n \"label\": \"Register the Obsidian CLI\",\n \"description\": \"Requires Obsidian 1.12.7 or newer with the command line interface registered. Keep the app installed; Workbench opens it on demand.\",\n \"helpUrl\": \"https://help.obsidian.md/cli\",\n \"steps\":\n [\n \"Open the Obsidian desktop app and update it to 1.12.7 or newer.\",\n \"Go to Settings → General → Advanced and enable Command line interface.\",\n \"Click Register CLI to add it to your PATH.\",\n \"Reopen this Skills page.\"\n ]\n },\n {\n \"kind\": \"picker\",\n \"label\": \"Select vault folder\",\n \"provider\": \"obsidian\",\n \"description\": \"Choose a folder that contains a .obsidian config directory. Switches to vault file mode (no running app required).\",\n \"credentialKey\": \"obsidian_vault_path\"\n }\n ]\n }\n }\n }\n---\n\n# Obsidian\n\nWork with a local Obsidian vault through the `obsidian` CLI. The CLI is a local\ncontroller for the running Obsidian desktop app and can search, read, create,\nappend, rename, move, open, and manage notes inside known vaults.\n\n## Load the Recipe File First\n\nThis file carries no commands. The working commands live in two reference files\n— load the one for the job with the `skill` tool BEFORE calling `exec`, then\ncopy its command and change only the arguments:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Finding and reading notes** — \"what are my notes\", \"list notes in X\",\n \"find / search notes about X\", \"read / open / summarize note X\", \"what's in\n my daily note\", \"list my tags\", backlinks, tasks: call the `skill` tool with\n `name: \"obsidian\"` and `file: \"references/read.md\"`.\n- **Changing notes** — \"create a note\", \"add to note X\", \"prepend\", \"add to\n my daily note\", rename, move, delete: call the `skill` tool with\n `name: \"obsidian\"` and `file: \"references/write.md\"`.\n\nMost requests are ONE command. Run that single command, then answer from its\noutput. Do NOT run `obsidian vault info` first unless the vault is ambiguous.\n\n## Always Use the obsidian CLI — Never Shell Out\n\nThe vault is managed by Obsidian. ALWAYS read, list, search, and edit notes with\n`obsidian` commands via the `exec` tool. NEVER use `cat`, `ls`, `find`, `grep`,\n`head`, `tail`, `echo`, output redirection, or filesystem paths under the vault\ndirectory — those bypass Obsidian and are wrong even when they appear to work.\nIf an `obsidian` command fails, correct its arguments and retry the `obsidian`\ncommand; do not switch to shell or file tools. Use `exec` only, one `obsidian`\ncommand per call. Do not chain with `&&`, `;`, or pipes.\n\n## When to Use\n\n- The user asks to search, read, summarize, create, append, rename, move, or\n delete notes in Obsidian.\n- The user asks about daily notes, tags, backlinks, aliases, properties, tasks,\n templates, bookmarks, or vault structure.\n- The user refers to \"my vault\", \"my notes\", \"daily note\", \"Obsidian\",\n wikilinks, or Markdown notes managed by Obsidian.\n\n## When NOT to Use\n\n- General filesystem work outside Obsidian.\n- Remote note providers such as Notion, Google Drive, Apple Notes, or cloud\n storage.\n- Browser automation, OAuth, MCP, web search, or direct filesystem tools.\n- Plugin installation, theme changes, sync changes, command execution by ID, or\n deletion unless the user explicitly asks for that exact side effect.\n\n## Setup and Availability\n\n- CLI mode: Obsidian 1.12.7+ with Command line interface registered. Workbench\n launches the app on demand when needed.\n- Vault file mode: Skills page → Obsidian → Select vault folder. Commands run\n against that folder's Markdown files without the running app.\n- Confirm the active vault with `obsidian vault info=name` ONLY when the request\n is ambiguous or multiple vaults exist — not before every request. If the\n request names a specific vault, add `vault=\"<Vault Name>\"`.\n- If no vault is active or the CLI cannot reach Obsidian, ask the user to open\n Obsidian or select a vault folder. A missing vault path is a runtime setup\n question, not a reason to use another tool or python.\n\n## Output Policy\n\n- Keep results small: note path, matching line, and the minimal relevant\n excerpt. Cite note paths and headings when answering from vault content.\n- After a successful command, finish with a concise visible answer.\n- If a command fails because Obsidian is not running, a vault is missing, or a\n note path is ambiguous, report that clearly and ask for the next specific\n setup step. Never claim a note changed unless the command succeeded.\n- Do not expose full vault dumps, plugin listings, or large note bodies unless\n the user explicitly requests them.\n",
|
|
60
|
+
"obsidian/cli.schema.json": "{\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"version\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {},\n \"x-effect\": \"read\"\n },\n \"vaults\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {},\n \"x-effect\": \"read\"\n },\n \"vault\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"list\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {},\n \"x-effect\": \"read\"\n },\n \"info\": {\n \"type\": \"string\"\n },\n \"vault\": {\n \"type\": \"string\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"files\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"folder\": {\n \"type\": \"string\"\n },\n \"ext\": {\n \"type\": \"string\"\n },\n \"vault\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"limit\": {\n \"type\": \"string\"\n },\n \"total\": {\n \"type\": \"boolean\"\n },\n \"counts\": {\n \"type\": \"boolean\"\n },\n \"verbose\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"search\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\n \"query\"\n ],\n \"properties\": {\n \"query\": {\n \"type\": \"string\"\n },\n \"limit\": {\n \"type\": \"string\"\n },\n \"vault\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"folder\": {\n \"type\": \"string\"\n },\n \"verbose\": {\n \"type\": \"boolean\"\n },\n \"total\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"read\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"file\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"vault\": {\n \"type\": \"string\"\n },\n \"verbose\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"create\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"file\": {\n \"type\": \"string\"\n },\n \"content\": {\n \"type\": \"string\"\n },\n \"vault\": {\n \"type\": \"string\"\n },\n \"template\": {\n \"type\": \"string\"\n },\n \"silent\": {\n \"type\": \"boolean\"\n }\n }\n },\n \"append\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"file\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"content\": {\n \"type\": \"string\"\n },\n \"vault\": {\n \"type\": \"string\"\n },\n \"silent\": {\n \"type\": \"boolean\"\n }\n }\n },\n \"prepend\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"file\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"content\": {\n \"type\": \"string\"\n },\n \"vault\": {\n \"type\": \"string\"\n },\n \"silent\": {\n \"type\": \"boolean\"\n }\n }\n },\n \"backlinks\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"file\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"vault\": {\n \"type\": \"string\"\n },\n \"format\": {\n \"type\": \"string\"\n },\n \"verbose\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"tags\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"vault\": {\n \"type\": \"string\"\n },\n \"format\": {\n \"type\": \"string\"\n },\n \"counts\": {\n \"type\": \"boolean\"\n },\n \"verbose\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"tasks\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"vault\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"folder\": {\n \"type\": \"string\"\n },\n \"format\": {\n \"type\": \"string\"\n },\n \"todo\": {\n \"type\": \"boolean\"\n },\n \"done\": {\n \"type\": \"boolean\"\n },\n \"verbose\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"daily:read\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"vault\": {\n \"type\": \"string\"\n },\n \"verbose\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"daily:append\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"content\": {\n \"type\": \"string\"\n },\n \"vault\": {\n \"type\": \"string\"\n },\n \"silent\": {\n \"type\": \"boolean\"\n }\n }\n },\n \"daily:prepend\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"content\": {\n \"type\": \"string\"\n },\n \"vault\": {\n \"type\": \"string\"\n },\n \"silent\": {\n \"type\": \"boolean\"\n }\n }\n },\n \"rename\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"file\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"vault\": {\n \"type\": \"string\"\n }\n }\n },\n \"move\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"file\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"to\": {\n \"type\": \"string\"\n },\n \"vault\": {\n \"type\": \"string\"\n }\n }\n },\n \"delete\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"file\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"vault\": {\n \"type\": \"string\"\n },\n \"force\": {\n \"type\": \"boolean\"\n }\n }\n },\n \"open\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"file\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?![/\\\\\\\\~]|[A-Za-z]:).+$\",\n \"description\": \"vault-relative path (not absolute)\"\n },\n \"vault\": {\n \"type\": \"string\"\n }\n }\n }\n }\n}\n",
|
|
61
|
+
"obsidian/references/read.md": "# Finding and Reading Notes\n\nOne `obsidian` command per `exec` call, no chaining. Skip `vault info` unless\nthe vault is ambiguous.\n\n| Request | Command |\n| ----------------------------------------- | ------------------------------------------ |\n| \"what are my notes\" / \"list my notes\" | `obsidian files` |\n| \"list notes in <folder>\" | `obsidian files folder=\"<Folder>\"` |\n| \"find / search notes about X\" | `obsidian search query=\"X\"` |\n| \"read / open / summarize note X\" | `obsidian read file=\"X\"` (name, no `.md`) |\n| \"what's in my daily note\" | `obsidian daily:read` |\n| \"list my tags\" | `obsidian tags` |\n\n## List or search\n\n```bash\nobsidian files\nobsidian files folder=\"Projects\" ext=md\nobsidian search query=\"release handoff\" limit=10\n```\n\n`search` REQUIRES `query=\"...\"`. Add `limit=` to keep results small; if they are\nstill broad, ask a narrowing question or rerun with a tighter query.\n\n## Read\n\nRead by NAME (no `.md`) or by a VAULT-RELATIVE path — never an absolute\nfilesystem path (`/Users/.../Vault/Note.md` fails with \"File not found\").\nPrefer `path=` when two notes may share a name.\n\n```bash\nobsidian read file=\"QVAC\"\nobsidian read path=\"Projects/QVAC.md\"\nobsidian daily:read\n```\n\n## Structured output\n\nFor counts or machine-readable output add `total`, `counts`, or `format=json`:\n\n```bash\nobsidian files folder=\"Projects\" ext=md total\nobsidian tags counts format=json\n```\n\n## Backlinks and tasks (CLI mode only)\n\n`backlinks` and `tasks` need the running Obsidian app; in vault-folder mode\nthey fail with `not supported in vault file mode` — then say so instead of\nretrying or falling back to shell tools.\n\n```bash\nobsidian backlinks path=\"Projects/QVAC.md\" format=json\nobsidian tasks todo verbose\n```\n\n## Vault checks (only when needed)\n\n```bash\nobsidian vaults\nobsidian vault info=name\nobsidian version\n```\n\n## Answering\n\nGive the note path and the exact line or excerpt that supports the answer. Do\nnot dump whole notes or the whole vault unless asked. A read-only request ends\nafter the read — never follow it with a write.\n\n## Common Mistakes\n\n- Shelling out with `cat`, `ls`, `find`, `grep`, or filesystem tools instead of\n `obsidian` commands. Always use `obsidian`, even for a single note.\n- Passing an absolute filesystem path to `read`. Use `file=\"Name\"` or a\n vault-relative `path=\"Folder/Note.md\"`.\n- Calling `search` without `query=`.\n- Running `obsidian vault info` before every request.\n",
|
|
62
|
+
"obsidian/references/write.md": "# Creating and Changing Notes\n\nOne `obsidian` command per `exec` call, no chaining. Writes must go through\n`obsidian` CLI commands — never direct filesystem writes.\n\n| Request | Command |\n| -------------------------------- | --------------------------------------------------------- |\n| \"create a note\" | `obsidian create name=\"Title\" content=\"...\"` |\n| \"create a note in <folder>\" | `obsidian create path=\"Folder/Title.md\" content=\"...\"` |\n| \"add to note X\" | `obsidian append file=\"X\" content=\"...\"` |\n| \"add to the top of note X\" | `obsidian prepend file=\"X\" content=\"...\"` |\n| \"add to my daily note\" | `obsidian daily:append content=\"...\"` |\n| \"rename / move / delete note X\" | only when explicitly asked — see below |\n\n## Create\n\nName new notes with `name=\"Title\"` or an exact `path=\"Folder/Title.md\"`. Never\nuse `file=` with `create`: the official CLI ignores it and silently creates an\n\"Untitled\" note. `content=` is Markdown; write line breaks as `\\n`.\n\n```bash\nobsidian create name=\"QVAC\" content=\"# QVAC\\n\\nInitial note\"\nobsidian create path=\"Projects/QVAC.md\" content=\"# QVAC\\n\\nInitial note\"\n```\n\n## Append and prepend\n\nTarget by NAME (no `.md`) or by a VAULT-RELATIVE path — never an absolute\nfilesystem path. Prefer `path=` when two notes may share a name.\n\n```bash\nobsidian append file=\"QVAC\" content=\"- [ ] Follow up on QVAC notes\"\nobsidian append path=\"Daily/2026-05-29.md\" content=\"- [ ] Follow up on QVAC notes\"\nobsidian prepend path=\"Projects/QVAC.md\" content=\"> Updated 2026-05-29\"\nobsidian daily:append content=\"- [ ] Review local Obsidian skill wiring\"\n```\n\nTo change existing text inside a note, read it first (`references/read.md`),\nthen `append`/`prepend` the corrected content — there is no in-place replace.\n\n## Rename, move, delete\n\nRun these ONLY when the user explicitly asks for that exact side effect, and\nconfirm the target path when the request is ambiguous.\n\n```bash\nobsidian rename path=\"Projects/QVAC.md\" name=\"QVAC v2\"\nobsidian move path=\"Projects/QVAC.md\" to=\"Archive/QVAC.md\"\nobsidian delete path=\"Archive/QVAC.md\" force\n```\n\nAvoid `overwrite`, plugin commands, sync commands, and theme commands unless the\nuser explicitly asks.\n\n## Answering\n\nDo not claim a note changed unless the `obsidian` command completed\nsuccessfully. Confirm with the note path and what was written.\n\n## Common Mistakes\n\n- Using `create file=\"Title\"` — the CLI ignores `file=` on create and makes an\n \"Untitled\" note. Use `name=\"Title\"` or `path=`.\n- Passing an absolute filesystem path to `create` or `append`.\n- Using `file=` when two notes may share the same name. Prefer exact `path=`.\n- Writing through `echo`, redirection, or file tools instead of `obsidian`.\n- Treating a failed command as success.\n",
|
|
63
|
+
"pdf/SKILL.md": "---\nname: pdf\ndescription: Create PDF documents with fpdf2 or transform existing ones with pypdf — merge, split, rotate, watermark, encrypt, decrypt, fill forms, extract text — and deliver them as chat attachments.\ntools: [exec(python)]\nplatform: [darwin, linux, win32]\nmetadata:\n {\n \"openclaw\":\n {\n \"setup\":\n {\n \"summary\": \"Runs pypdf and fpdf2 in the in-process Python runtime, from packages that ship with the app. The first use waits for the runtime to start.\"\n }\n }\n }\n---\n\n# PDF\n\nBuild or transform `.pdf` files by running Python through the `exec` tool with\n`language: \"python\"`. Declare every produced file in `outputs` and it comes back\nas a chat attachment the user can save.\n\nTwo libraries, split by job — pick by whether a PDF already exists:\n\n- **fpdf2** — create a new PDF from scratch: reports, letters, invoices, cheat\n sheets, anything laid out page by page.\n- **pypdf** — transform a PDF that already exists: merge, split, reorder or\n rotate pages, watermark, encrypt or decrypt, fill form fields, extract text.\n\n## Load the Recipe File First\n\nThis file contains no Python. The working recipes live in two reference files —\nload the one for the job with the `skill` tool BEFORE writing any Python, then\ncopy its recipe and change the content:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Creating a new PDF** (no existing PDF involved): call the `skill` tool with\n `name: \"pdf\"` and `file: \"references/create.md\"`.\n- **Anything with an existing PDF** (merge, split, rotate, watermark, encrypt,\n decrypt, fill forms, extract text): call the `skill` tool with\n `name: \"pdf\"` and `file: \"references/transform.md\"`.\n- **Adding or changing words in an existing PDF** (add a paragraph or section,\n reword, restyle): pypdf cannot edit page content — do not try. The job is a\n REBUILD: load `references/create.md` and write the whole document again with\n fpdf2 — every original section, unchanged, plus the requested change. When\n the original text is not already in this chat, load\n `references/transform.md` too and extract it first.\n- **Both in one flow** (build a page with fpdf2, then stamp it onto an existing\n PDF): load both files.\n\nNever write the Python from memory. The recipes carry required arguments\n(`new_x`/`new_y`, exact version pins, attachment staging rules) that fail in\nnon-obvious ways when improvised; loading the file is one cheap read-only call.\n\n## When to Use\n\n- The user asks for a new `.pdf` document.\n- The user attached one or more PDFs and wants them merged, split, rotated,\n watermarked, password-protected, unlocked, filled in, or their text pulled out.\n- A flow needs both: build a page with fpdf2, then stamp it onto an existing\n PDF with pypdf — one `exec` call can use both libraries.\n\n## When NOT to Use\n\n- A `create_pdf` tool is available in this chat and the user only wants prose\n you are writing turned into a downloadable document — call `create_pdf` with\n the markdown instead; it is cheaper and handles layout itself. Reach for this\n skill when that tool is absent on this device, when an existing PDF is\n involved, or when the layout must be controlled page by page.\n- The user asks about an attached PDF — answer from the document text or\n knowledge excerpts already in the chat. Run text extraction only when the\n user wants the text itself delivered as a file.\n- The user wants a slide deck — use the presentations skill; a spreadsheet or\n Word file — this skill cannot read or write Office formats.\n- The user wants a single image — call `generate_image` alone.\n\n## Rules for Every Job\n\n**You build it, not the user.** Deliver the file, never the recipe. Do NOT\nprint the Python source in chat, do NOT tell the user to install pypdf, run a\nscript, or open a terminal — they have no terminal in this chat and the code\nwould not run there. The PDF exists only if an `exec` call with `outputs`\nsucceeds and returns the attachment.\n\n**Success = stop.** When `exitCode` is `0` and the result's `attachments` lists\nthe `.pdf`, the job is done. Do not call `exec` again for the same request —\nnot to \"confirm\", not to \"improve\". Reply with a single line: file name + the\npage count from stdout. If the result has `missingOutputs` instead, the file\nwas never written: check the save name matches the declared output and rerun\nonce.\n\n**Failures are fixed in the code, not around it.** If a run fails, fix the\nPython against the loaded reference file's recipes and Errors table and call\n`exec` again. If two consecutive calls fail with the same error, re-read the\ntraceback line-by-line before a third. An error is never a fault in pypdf,\nfpdf2, or the runtime — do not switch package versions, do not wrap source in\n`python -c` or shell, and do not \"debug\" with `os.listdir` or no-op scripts\nwhile `outputs` still lists the PDF.\n\n**The runtime is sealed.** There is no shell (`ls`, `cat` raise\n`SyntaxError` — the `command` is Python source) and no network — `requests`,\n`urllib`, and `socket` all fail, so a PDF behind a URL cannot be downloaded;\nask the user to attach the file. The working directory starts empty on every\ncall: a file from an earlier call is gone unless staged again, and a file you\nwrite but do not declare in `outputs` is discarded.\n\n**Never overwrite a staged input.** Transforms always write a new output name.\n",
|
|
64
|
+
"pdf/references/create.md": "# Creating a PDF (fpdf2)\n\nCreate a new `.pdf` from scratch by running Python through the `exec` tool.\nA new PDF needs **no** `inputs` — do not invent attachment ids. **Exactly one**\n`exec` call per user request when that call succeeds.\n\nThis file is also the recipe for **changing the content of an existing PDF**\n(add a paragraph, reword, restyle): pypdf cannot edit page content, so the job\nis writing the whole document again with fpdf2 — every original section,\nreproduced unchanged, plus the requested change. A rebuild that condenses,\nsummarizes, or drops original sections is a failed turn; the user must get\ntheir document back with only the asked-for difference. Save the rebuild under\na **new** output name (`updated.pdf`, never the original file's name), and\nstop after the one successful build.\n\n## The exec call\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"fpdf2==2.8.8\"],\n \"outputs\": [\"report.pdf\"],\n \"command\": \"...\"\n}\n```\n\n- `packages` — pin exactly `fpdf2==2.8.8` (imported as `fpdf`). This version\n ships with the app and installs with no network; any other version has to be\n downloaded, which fails on a device that is offline. Never install `fpdf`\n (no `2`) — that is an abandoned, incompatible library.\n- `outputs` — the file to deliver. `pdf.output(\"report.pdf\")` must match the\n declared output name exactly.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a\n semicolon is a `SyntaxError`.\n\n## The Recipe\n\nStart from this. It is a complete, working document — title, body paragraphs,\na bulleted section, a table, automatic page breaks, saved under the declared\noutput name. Copy it and change the content; do not assemble a PDF from memory.\n\n```python\nfrom fpdf import FPDF\n\npdf = FPDF(format=\"A4\")\npdf.set_auto_page_break(auto=True, margin=15)\npdf.add_page()\n\npdf.set_font(\"helvetica\", style=\"B\", size=24)\npdf.multi_cell(0, 12, \"Quarterly Report\", new_x=\"LMARGIN\", new_y=\"NEXT\")\n\npdf.set_font(\"helvetica\", size=12)\npdf.ln(4)\npdf.multi_cell(0, 6, \"Revenue grew 20% quarter over quarter, driven by APAC. \"\n \"EMEA held flat while new logos offset churn.\",\n new_x=\"LMARGIN\", new_y=\"NEXT\")\npdf.ln(2)\n\npdf.set_font(\"helvetica\", style=\"B\", size=14)\npdf.multi_cell(0, 10, \"Highlights\", new_x=\"LMARGIN\", new_y=\"NEXT\")\npdf.set_font(\"helvetica\", size=12)\nfor point in [\n \"APAC bookings grew 34% and drove most of the quarter\",\n \"EMEA held flat; new logos offset churn\",\n \"Gross margin improved 2 points on infra savings\",\n]:\n pdf.multi_cell(0, 6, \"- \" + point, new_x=\"LMARGIN\", new_y=\"NEXT\")\npdf.ln(2)\n\nwith pdf.table() as table:\n for row_data in [(\"Region\", \"Revenue\"), (\"APAC\", \"$1.2M\"), (\"EMEA\", \"$0.9M\")]:\n row = table.row()\n for cell_text in row_data:\n row.cell(cell_text)\n\npdf.output(\"report.pdf\") # must match the declared output exactly\nprint(f\"{pdf.pages_count} pages\")\n```\n\n## The Rules That Keep It Working\n\n- **Sizes are positional — there is no `width=` or `height=` keyword.** The\n first two arguments of `cell` and `multi_cell` are `w` and `h`; passing\n `width=` raises `TypeError`. The fix is renaming the arguments as in the\n sample — never deleting the `new_x`/`new_y` keywords, which \"fixes\" the\n error and prints every later line on top of the previous one.\n- **Every line of text goes through\n `pdf.multi_cell(0, h, text, new_x=\"LMARGIN\", new_y=\"NEXT\")` — headings,\n paragraphs, and bullets alike. Do not use `pdf.cell` at all.** `cell` does\n not wrap, so a long heading is silently clipped at the right margin, and\n without `new_x`/`new_y` it leaves the cursor at the END of the line so the\n next write starts at the right margin — raising\n `FPDFException: Not enough horizontal space` or printing on top of earlier\n text. `multi_cell` wraps everything. Keep `new_x=\"LMARGIN\", new_y=\"NEXT\"`\n on every call, exactly as in the sample. A bullet is one\n `multi_cell(0, 6, \"- \" + point, …)` per point — never several bullets\n packed into one string. `pdf.text(x, y, s)` is not a third option: it\n paints at a fixed point with no wrapping and exists only for the watermark\n stamp in `references/transform.md`.\n- **Write the whole document, not a cover page.** \"A small PDF about X\"\n still means real content: a title, then several short sections, each a\n heading plus a paragraph or bullets, as in the sample. A PDF containing\n only a title and a subtitle is a failed turn — the explanation the user\n asked for belongs inside the PDF, not in your chat reply.\n- **`set_font` before every block, not once.** A heading's bold 14-24 pt\n style stays active until changed — reset to `pdf.set_font(\"helvetica\",\n size=12)` after each heading or the whole body renders huge and bold.\n Core fonts: `helvetica`, `times`, `courier`; body text 10-12 pt.\n- **ASCII punctuation only.** The core fonts cover latin-1 and nothing else,\n and one character outside it fails the whole cell with\n `FPDFUnicodeEncodingException`. Curly quotes, em dashes, arrows, the •\n bullet, CJK, and emoji are all outside. Write straight quotes `\"` `'` and\n hyphens `-` in every string — a bullet is `\"- \"`, never the `•` character.\n Accented latin (`café`, `naïve`) is fine. When user content may carry smart\n punctuation, normalize it first:\n\n ```python\n def latin1(text):\n for bad, good in [(\"‘\", \"'\"), (\"’\", \"'\"), (\"“\", '\"'),\n (\"”\", '\"'), (\"–\", \"-\"), (\"—\", \"-\"),\n (\"…\", \"...\"), (\"→\", \"->\"), (\"•\", \"-\")]:\n text = text.replace(bad, good)\n return text\n ```\n\n Text that genuinely needs CJK or emoji cannot be rendered — there are no\n font files in this runtime and `add_font` has nothing to load, so never call\n it. Say so and offer a latin transliteration instead of shipping `?`.\n- **Units are millimetres**, page format defaults to A4. `FPDF(format=\"letter\")`\n for US letter. An A4 page is 210 x 297 mm with 10 mm margins; a width of\n `0` extends to the right margin.\n- **No markdown syntax in strings.** fpdf2 prints text literally — `##`,\n `**bold**`, and `*italics*` come out as those exact characters. Headings\n and emphasis are made with `set_font(..., style=\"B\", size=…)`, as in the\n sample.\n- **`pdf.output(\"name.pdf\")` writes the deliverable.** Calling `output()`\n with no argument returns the bytes instead — useful only for in-memory\n intermediates; the file the user receives must be written under its\n declared `outputs` name.\n- Never print the PDF's bytes or base64 — stdout is capped and the file\n travels through `outputs`. Print only the page-count line.\n\n## Errors\n\n- `ModuleNotFoundError: No module named 'fpdf'` — `packages` was missing or\n wrong; the pin is `fpdf2==2.8.8` (imported as `fpdf`). Never try to install\n inside the script.\n- `TypeError: FPDF.cell() (or multi_cell) got an unexpected keyword argument\n 'width'` (or `'height'`) — the size parameters are the positional `w` and\n `h`. Rewrite the call as in the sample —\n `multi_cell(0, 6, text, new_x=\"LMARGIN\", new_y=\"NEXT\")`. Renaming is the\n whole fix; deleting the keywords instead produces overlapping text.\n- `FPDFException: Not enough horizontal space to render a single character` —\n an earlier `cell`/`multi_cell` left the cursor at the right margin. Add\n `new_x=\"LMARGIN\", new_y=\"NEXT\"` to every `cell` and `multi_cell` call.\n- `FPDFUnicodeEncodingException: Character \"…\" is outside the range …` — a\n non-latin-1 character reached a core font. Normalize the string (see the\n `latin1` helper) and rerun; for CJK or emoji, tell the user it cannot be\n rendered.\n- On an `AttributeError` or `TypeError` from fpdf2 the API name or arguments\n are wrong — fix against this file's recipe. Do not retry the same call and\n do not switch to a shell.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the `.pdf`, stop tool use and\nanswer with one line: file name + the page count from stdout. Exactly one\nsuccessful `exec` per request.\n",
|
|
65
|
+
"pdf/references/transform.md": "# Transforming an Existing PDF (pypdf)\n\nTransform a PDF that is already in this chat by running Python through the\n`exec` tool: merge, split, rotate, watermark, encrypt, decrypt, fill form\nfields, extract text.\n\n**pypdf transforms pages; it cannot restyle or edit their content.** Merging,\nsplitting, rotating, stamping, and encrypting work on a staged PDF. Adding a\nparagraph, rewording, or changing fonts, sizes, or colors does not — never\npoke at page objects to try (`KeyError`/`IndexError` is that mistake). For a\ncontent change, load `references/create.md` NOW and rebuild the whole document\nwith fpdf2: every original section, unchanged, plus the requested change (no\n`inputs` needed when the original text is already in this chat; otherwise run\nthe extract-text script below first). A rebuilt PDF that condenses or drops\noriginal sections is a failed turn.\n\n## Staging the PDF\n\nAny PDF already in this chat can be staged, whoever put it there. Stage it as\nan input by its `attachmentId` and open it with `PdfReader(\"existing.pdf\")` or\n`PdfWriter(clone_from=\"existing.pdf\")`; then transform and save a **new**\noutput such as `merged.pdf`. Never overwrite the staged input. The working\ndirectory starts empty on every call, so a file you do not stage does not\nexist — opening last turn's PDF by name alone raises `FileNotFoundError`.\n\nThe id comes from wherever the PDF entered the chat:\n\n- **Produced earlier in this chat** (a prior `exec` output, a `create_pdf`\n result) — the `attachmentId` is in that tool result.\n- **Uploaded by the user** — the `[Attached file …]` line on their message\n names it, and every non-image upload gets one:\n\n ```\n [Attached file \"contract.pdf\" (application/pdf) — attachmentId: 4f9c2ab1]\n ```\n\nCopy the id verbatim — never placeholders like `att_pdf` or any id you made\nup. A `.pdf` is never staged id-less: the id-less form resolves to an uploaded\n*image*, so it cannot reach a PDF. Id-less entries (`{ \"path\": \"photo.png\" }`,\nno `attachmentId` key) are only for images the user uploaded — the first\nid-less entry is the first image of the user's latest message, and so on.\n\nIf no `attachmentId` for the PDF is available anywhere in the chat, ask the\nuser to attach it again. Do not invent ids and do not retry.\n\n## The exec call\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"pypdf==6.15.0\"],\n \"inputs\": [\n { \"attachmentId\": \"<id from the tool result that built a.pdf>\", \"path\": \"a.pdf\" },\n { \"attachmentId\": \"<id from the tool result that built b.pdf>\", \"path\": \"b.pdf\" }\n ],\n \"outputs\": [\"merged.pdf\"],\n \"command\": \"...\"\n}\n```\n\n- `packages` — pin exactly `pypdf==6.15.0`; add `fpdf2==2.8.8` when one run\n also builds pages (watermark). Add `\"cryptography\"` (deliberately unpinned —\n the runtime supplies its own build) only for AES encryption or decrypting\n AES-encrypted files. These exact versions ship with the app and install with\n no network; any other version has to be downloaded, which fails on a device\n that is offline.\n- `inputs` — the staged attachments. Paths must be unique bare filenames;\n staged files land in the working directory under those names — reference\n `PdfReader(\"a.pdf\")` by that name only.\n- `outputs` — the files to deliver. A file you write but do not declare here\n is discarded, and an undeclared `.pdf` earns a warning — build intermediate\n pages in memory (`io.BytesIO`, as in the watermark recipe) instead of\n writing extra files.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;`.\n\n`attachment … not found in this chat` means you invented an id or the file is\nnot attached. Re-copy the exact id from the tool result or the\n`[Attached file …]` line that names the PDF; if neither exists, ask the user to\nattach it again instead of retrying.\n\n## Scripted Transforms — Run These, Do Not Write Them\n\nMerge, rotate, extract text, encrypt, and decrypt are bundled scripts: run them\nby naming the script in the `exec` call — no Python source at all. Stage the\nattachment(s) as `inputs`, declare the produced file in `outputs`, and pass the\nfile names again as `scriptArgs` (scripts see the working directory, so the\nsame bare names). Always write a new output name — never the staged input's.\n\n**Merge** several PDFs into one — args: output first, then the inputs in order:\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"pypdf==6.15.0\"],\n \"inputs\": [\n { \"attachmentId\": \"<real id>\", \"path\": \"a.pdf\" },\n { \"attachmentId\": \"<real id>\", \"path\": \"b.pdf\" }\n ],\n \"outputs\": [\"merged.pdf\"],\n \"skill\": \"pdf\",\n \"script\": \"scripts/merge.py\",\n \"scriptArgs\": [\"merged.pdf\", \"a.pdf\", \"b.pdf\"]\n}\n```\n\n**Rotate** every page — args: input, output, degrees (a multiple of 90):\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"pypdf==6.15.0\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.pdf\" }],\n \"outputs\": [\"rotated.pdf\"],\n \"skill\": \"pdf\",\n \"script\": \"scripts/rotate.py\",\n \"scriptArgs\": [\"existing.pdf\", \"rotated.pdf\", \"90\"]\n}\n```\n\n**Extract text** into a delivered `.txt` — args: input, output:\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"pypdf==6.15.0\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.pdf\" }],\n \"outputs\": [\"extracted.txt\"],\n \"skill\": \"pdf\",\n \"script\": \"scripts/extract_text.py\",\n \"scriptArgs\": [\"existing.pdf\", \"extracted.txt\"]\n}\n```\n\nThe script refuses a scanned PDF (no text layer, no OCR in this runtime) with a\nmessage saying so — tell the user the pages are images rather than rerunning.\n\n**Encrypt** with AES-256 — args: input, output, password. Needs the extra\n`\"cryptography\"` package entry (deliberately unpinned):\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"pypdf==6.15.0\", \"cryptography\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.pdf\" }],\n \"outputs\": [\"locked.pdf\"],\n \"skill\": \"pdf\",\n \"script\": \"scripts/encrypt.py\",\n \"scriptArgs\": [\"existing.pdf\", \"locked.pdf\", \"s3cret\"]\n}\n```\n\n**Decrypt** — args: input, output, password (the password must come from the\nuser). Needs `\"cryptography\"` too when the file is AES-encrypted:\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"pypdf==6.15.0\", \"cryptography\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"locked.pdf\" }],\n \"outputs\": [\"unlocked.pdf\"],\n \"skill\": \"pdf\",\n \"script\": \"scripts/decrypt.py\",\n \"scriptArgs\": [\"locked.pdf\", \"unlocked.pdf\", \"s3cret\"]\n}\n```\n\n### If a script fails\n\nRead its stderr first — the scripts print exactly what is wrong (bad argument\norder, wrong password, scanned pages). A failure is almost always fixed by\ncorrecting `scriptArgs` or `inputs` and running the script again. Only when the\nmessage does not explain it, load the script's source to inspect it — call the\n`skill` tool with `name: \"pdf\"` and `file: \"scripts/<name>.py\"` — then either\nrerun the script with fixed arguments or fall back to an inline `command`\nadapted from that source. Never load script sources up front; run them.\n\n## Coded Transforms\n\nSplit, watermark, and form filling take shapes a fixed script cannot cover, so\nthey stay inline Python in `command` (no `script` key). Each recipe below is\ncomplete; stage the attachment(s) as `inputs` first. `reader.pages` is\nzero-indexed. Always write a new output name — never the staged input's.\n\n**Split** — keep a page range (here: pages 1-3, i.e. indexes 0-2):\n\n```python\nfrom pypdf import PdfReader, PdfWriter\n\nreader = PdfReader(\"existing.pdf\")\npart = PdfWriter()\nfor page in reader.pages[0:3]:\n part.add_page(page)\npart.write(\"pages-1-3.pdf\")\nprint(f\"{len(part.pages)} pages\")\n```\n\n**Watermark** — build the stamp with fpdf2 in memory, merge it onto every\npage. Needs both packages (`pypdf==6.15.0` and `fpdf2==2.8.8`). `over=False`\nputs the mark under the content, `over=True` on top:\n\n```python\nimport io\nfrom fpdf import FPDF\nfrom pypdf import PdfReader, PdfWriter\n\nmark = FPDF(format=\"A4\")\nmark.add_page()\nmark.set_font(\"helvetica\", style=\"B\", size=60)\nmark.set_text_color(200, 200, 200)\nwith mark.rotation(45, x=105, y=148):\n mark.text(30, 160, \"DRAFT\") # text(x, y, s) is stamp-only: fixed point, no wrapping\nstamp = PdfReader(io.BytesIO(mark.output())).pages[0]\n\nwriter = PdfWriter(clone_from=\"existing.pdf\")\nfor page in writer.pages:\n page.merge_page(stamp, over=False)\nwriter.write(\"watermarked.pdf\")\nprint(f\"{len(writer.pages)} pages\")\n```\n\n**Fill form fields** — inspect the field names first (`get_fields`), then set\nthem by exact name:\n\n```python\nfrom pypdf import PdfReader, PdfWriter\n\nreader = PdfReader(\"form.pdf\")\nfields = reader.get_fields()\nprint(\"fields:\", sorted(fields) if fields else \"none\")\n\nwriter = PdfWriter(clone_from=\"form.pdf\")\nwriter.update_page_form_field_values(\n writer.pages[0], {\"name\": \"Ada Lovelace\", \"email\": \"ada@example.com\"}\n)\nwriter.write(\"filled.pdf\")\nprint(f\"{len(writer.pages)} pages\")\n```\n\nIf `get_fields()` returns `None` the PDF has no form layer — flattened forms\ncannot be filled; say so instead of guessing at overlay coordinates.\n\n## Errors\n\n- `ModuleNotFoundError: No module named 'pypdf'` — `packages` was missing or\n wrong; the pin is `pypdf==6.15.0`. Never try to install inside the script.\n- `DependencyError: cryptography>=3.1 is required for AES algorithm` — add\n `\"cryptography\"` to `packages` and rerun. The encrypt/decrypt scripts clear\n pypdf's cached crypto backend themselves.\n- `usage: <script>.py …` on stderr — the `scriptArgs` were wrong (order,\n count, or a non-numeric degrees). Fix the arguments and run the script\n again; do not rewrite the script as inline code for an argument mistake.\n- `EOF marker not found` or `PdfReadError` on open — the staged file is not a\n complete PDF; check the right attachment was staged, or ask the user to\n re-attach it.\n- `File has not been decrypted` — the input is password-protected: ask the\n user for the password and run the decrypt recipe first.\n- `FileNotFoundError` — the file was never staged, or the name does not match\n an `inputs` path. The working directory starts empty on every call: stage\n the PDF via `inputs` with the `attachmentId` from the tool result that\n produced it, or rebuild from scratch. Do not invent ids.\n- On an `AttributeError` or `TypeError` from pypdf the API name or arguments\n are wrong — fix against this file's recipes. Do not retry the same call and\n do not switch to a shell.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the output file, stop tool use\nand answer with one line: file name + the page count from stdout. Exactly one\nsuccessful `exec` per request.\n",
|
|
66
|
+
"pdf/scripts/decrypt.py": "\"\"\"Decrypt a password-protected PDF (requires the \"cryptography\" package entry for AES files).\n\nUsage: decrypt.py <input.pdf> <output.pdf> <password>\n\"\"\"\n\nimport sys\n\n# pypdf picks its crypto backend at first import and the interpreter is reused\n# across calls — clear it so the backend is chosen with \"cryptography\" present.\nfor name in [m for m in sys.modules if m.startswith(\"pypdf\")]:\n del sys.modules[name]\nfrom pypdf import PdfReader, PdfWriter\n\nif len(sys.argv) != 4:\n sys.exit(\"usage: decrypt.py <input.pdf> <output.pdf> <password> — the password never prints\")\n\nsource, target, password = sys.argv[1], sys.argv[2], sys.argv[3]\nif source == target:\n sys.exit(f\"output {target} must be a new name, not the staged input\")\n\nreader = PdfReader(source)\nif reader.is_encrypted and not reader.decrypt(password):\n sys.exit(\"wrong password — ask the user for the correct one; do not retry blind\")\nwriter = PdfWriter(clone_from=reader)\nwriter.write(target)\nprint(f\"{len(writer.pages)} pages\")\n",
|
|
67
|
+
"pdf/scripts/encrypt.py": "\"\"\"Encrypt a PDF with AES-256 (requires the \"cryptography\" package entry).\n\nUsage: encrypt.py <input.pdf> <output.pdf> <password>\n\"\"\"\n\nimport sys\n\n# pypdf picks its crypto backend at first import and the interpreter is reused\n# across calls, so an earlier pypdf run without \"cryptography\" would otherwise\n# pin the backend without AES.\nfor name in [m for m in sys.modules if m.startswith(\"pypdf\")]:\n del sys.modules[name]\nfrom pypdf import PdfWriter\n\nif len(sys.argv) != 4:\n sys.exit(\"usage: encrypt.py <input.pdf> <output.pdf> <password> — the password never prints\")\n\nsource, target, password = sys.argv[1], sys.argv[2], sys.argv[3]\nif source == target:\n sys.exit(f\"output {target} must be a new name, not the staged input\")\n\nwriter = PdfWriter(clone_from=source)\nwriter.encrypt(user_password=password, algorithm=\"AES-256\")\nwriter.write(target)\nprint(f\"{len(writer.pages)} pages, AES-256\")\n",
|
|
68
|
+
"pdf/scripts/extract_text.py": "\"\"\"Extract all text from a PDF into a .txt file.\n\nUsage: extract_text.py <input.pdf> <output.txt>\n\"\"\"\n\nimport sys\n\nfrom pypdf import PdfReader\n\nif len(sys.argv) != 3:\n sys.exit(f\"usage: extract_text.py <input.pdf> <output.txt> — got {sys.argv[1:]}\")\n\nsource, target = sys.argv[1], sys.argv[2]\nreader = PdfReader(source)\ntext = \"\\n\\n\".join(page.extract_text() for page in reader.pages)\n\nif not text.strip():\n sys.exit(\n f\"{source} has no text layer ({len(reader.pages)} pages are images; \"\n \"there is no OCR in this runtime) — tell the user instead of rerunning\"\n )\n\nwith open(target, \"w\") as f:\n f.write(text)\nprint(f\"{len(reader.pages)} pages, {len(text)} characters\")\n",
|
|
69
|
+
"pdf/scripts/merge.py": "\"\"\"Merge two or more PDFs into one, in argument order.\n\nUsage: merge.py <output.pdf> <input1.pdf> <input2.pdf> [more inputs...]\n\"\"\"\n\nimport sys\n\nfrom pypdf import PdfWriter\n\nif len(sys.argv) < 4:\n sys.exit(f\"usage: merge.py <output.pdf> <input1.pdf> <input2.pdf> [...] — got {sys.argv[1:]}\")\n\ntarget, sources = sys.argv[1], sys.argv[2:]\nif target in sources:\n sys.exit(f\"output {target} must be a new name, not one of the staged inputs\")\n\nmerged = PdfWriter()\nfor source in sources:\n merged.append(source)\nmerged.write(target)\nprint(f\"{len(merged.pages)} pages from {len(sources)} files\")\n",
|
|
70
|
+
"pdf/scripts/rotate.py": "\"\"\"Rotate every page of a PDF clockwise by a multiple of 90 degrees.\n\nUsage: rotate.py <input.pdf> <output.pdf> <degrees>\n\"\"\"\n\nimport sys\n\nfrom pypdf import PdfWriter\n\nif len(sys.argv) != 4:\n sys.exit(f\"usage: rotate.py <input.pdf> <output.pdf> <degrees> — got {sys.argv[1:]}\")\n\nsource, target, degrees_raw = sys.argv[1], sys.argv[2], sys.argv[3]\nif source == target:\n sys.exit(f\"output {target} must be a new name, not the staged input\")\ntry:\n degrees = int(degrees_raw)\nexcept ValueError:\n sys.exit(f\"degrees must be an integer multiple of 90, got: {degrees_raw}\")\nif degrees % 90 != 0:\n sys.exit(f\"degrees must be a multiple of 90, got: {degrees}\")\n\nwriter = PdfWriter(clone_from=source)\nfor page in writer.pages:\n page.rotate(degrees)\nwriter.write(target)\nprint(f\"{len(writer.pages)} pages rotated {degrees} degrees\")\n",
|
|
71
|
+
"presentations/SKILL.md": "---\nname: presentations\ndescription: Create, edit, or read PowerPoint (.pptx) decks with python-pptx — deliver decks as chat attachments, or read an attached one to summarize it or answer questions in the chat. Can embed images the user attached to the chat as well as images generated in it.\ntools: [exec(python)]\nplatform: [darwin, linux, win32]\nmetadata:\n {\n \"openclaw\":\n {\n \"setup\":\n {\n \"summary\": \"Runs python-pptx in the in-process Python runtime, from packages that ship with the app. The first use waits for the runtime to start.\"\n }\n }\n }\n---\n\n# Presentations\n\nBuild, edit, or read `.pptx` decks by running python-pptx through the `exec`\ntool with `language: \"python\"`. Declare a produced deck in `outputs` and it\ncomes back as a chat attachment the user can save — exactly like a\n`generate_image` result, the `exec` result carries\n`attachments: [{ attachmentId, fileName, byteLength }]`. To answer *from* a\ndeck instead of building one, run a read call alone — no `outputs` — and reply\nin the chat.\n\n## Load the Recipe File First\n\nThis file contains no Python. The working recipes live in three reference\nfiles — load the one for the job with the `skill` tool BEFORE writing any\nPython, then copy its recipe and change the content:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Building a new deck** (no existing deck involved; includes embedding\n images generated or uploaded in this chat): call the `skill` tool with\n `name: \"presentations\"` and `file: \"references/create.md\"`.\n- **Editing a deck already in this chat** (retitle, recolor, add or revise\n slides on a deck the user attached or a prior call built): call the `skill`\n tool with `name: \"presentations\"` and `file: \"references/edit.md\"`.\n- **Answering from an attached deck** (a summary, a question answered,\n content pulled into the chat — no file produced): call the `skill` tool\n with `name: \"presentations\"` and `file: \"references/read.md\"`.\n- **A summary delivered as a new file** is a read followed by a build: load\n both `references/read.md` and `references/create.md`.\n\nNever write the Python from memory. The recipes carry required rules (typed\n`Inches`/`Pt` lengths, layout reuse, the read-before-edit flow, exact version\npins, attachment staging) that fail in non-obvious ways when improvised;\nloading the file is one cheap read-only call.\n\n## When to Use\n\n- The user asks for a presentation, deck, slides, `.pptx`, PowerPoint, or Keynote-openable file.\n- The user attaches a `.pptx` and asks what it says — a summary, a question\n answered, or content pulled out into the chat.\n- The user wants a slide deck that embeds images generated in this chat.\n- The user attaches an image — a photo, a logo, a screenshot — and wants it on a\n slide. You can see the image, and you can also stage the file it came from:\n load `references/create.md` (new deck) or `references/edit.md` (existing\n deck). Generating a lookalike instead is a failed turn.\n\n## When NOT to Use\n\n- The user wants markdown or a document in the chat and no deck is involved —\n just write it. Summarizing or answering from an attached `.pptx` **is** this\n skill: load `references/read.md`.\n- The user wants a single image — call `generate_image` alone.\n\n## Rules for Every Job\n\n**You build it, not the user.** Deliver the deck, never the recipe. Do NOT\nprint the python source in chat, do NOT tell the user to install python-pptx,\nrun a script, or open a terminal — they have no terminal in this chat and the\ncode would not run there. The deck exists only if an `exec` call with\n`outputs` succeeds and returns the attachment. Falling back to \"here is the\nscript, run it yourself\" is a failed turn, and so is writing slides as\nmarkdown/chat text instead of the `.pptx` the user asked for — answering a\n*read* request as chat text is the read flow's finish, not this failure.\n\n**Success = stop.** When `exitCode` is `0` and `attachments` lists the\n`.pptx`, the deck is done. Do not call `exec` again for the same request —\nnot to \"confirm\", not to \"improve\", not a second identical build; a second\ndeck attach is rejected. Exactly one successful **build** `exec` per deck\nrequest — a no-`outputs` read delivers nothing and is not one of them: it\nbelongs before the build on an edit, never after it, and on a read request it\nstands alone. Reply with a single line: file name + slide count from stdout.\nIf the result has `missingOutputs` instead, the file was never written: check\nthe `save()` name matches the declared output and rerun once.\n\n**Failures are fixed in the code, not around it.** If a run fails, fix the\nPython against the loaded reference file's recipes and errors table and call\n`exec` again. If two consecutive calls fail with the same error, the fix from\nthe first attempt did not land — re-read the traceback line-by-line before a\nthird call; retrying the identical `command`, or a version with only cosmetic\nchanges, is a loop, not a fix. An error in your code is never a fault in\npython-pptx or in the runtime: do not switch package pins or hunt a \"more\ncompatible\" version — keep `packages: [\"python-pptx==1.0.2\"]` — do not wrap\nsource in `python -c \"…\"`, `python3`, `pip`, or shell, and do not \"debug\" with\n`os.listdir`, `print`, or a non-deck script while `outputs` still lists the\ndeck. Never search the web about an error; the answer is always in the result\nyou already have.\n\n**The runtime is sealed.** There is no shell — `ls`, `cat` and `file` raise\n`SyntaxError`, because the `command` is Python source — and no filesystem to\ncheck outside the `exec` result. There is no network: `requests`, `urllib`,\nand `socket` all fail, and `http_request` returns truncated text, never image\nbytes — an image behind a URL cannot be downloaded; ask the user to attach it\nor offer `generate_image`. The working directory starts empty on every call:\na file from an earlier call is gone unless staged again by its attachment id,\nand a file you write but do not declare in `outputs` is discarded.\n\n**Never overwrite a staged input.** An edit always saves under a new,\nversion-bumped name — `deck.pptx` → `deck-v2.pptx` — and a corrected rebuild\nafter a broken delivery goes out under the next version, never the name\nalready attached this turn.\n",
|
|
72
|
+
"presentations/references/create.md": "# Building a New Deck (python-pptx)\n\nCreate a new `.pptx` from scratch by running Python through the `exec` tool.\nA new deck needs **no** `inputs` — do not invent attachment ids. **Exactly\none** `exec` call per user request when that call succeeds.\n\n**A deck that already exists in this chat is never rebuilt here.** \"Add a\nslide\", \"change a title\", \"revise the deck\" — any request that starts from an\nexisting `.pptx` is an EDIT: load `references/edit.md` and stage the deck by\nits `attachmentId`. Building a fresh deck for an edit request throws away\nevery slide the user already has.\n\n## The exec call\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-pptx==1.0.2\"],\n \"outputs\": [\"deck.pptx\"],\n \"command\": \"...\"\n}\n```\n\n- `language` (required) — always `\"python\"`.\n- `packages` (required) — `[\"python-pptx==1.0.2\"]` on every call. Pin the\n version; an unpinned install resolves a potentially different library\n version. This exact version ships with the app and installs with no network;\n any other version has to be downloaded, which fails on a device that is\n offline.\n- `outputs` — `[\"deck.pptx\"]`. `save(\"deck.pptx\")` must match the declared\n output name exactly. A file you write but do not declare here is discarded.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a\n semicolon is a `SyntaxError`. `command` is the program: its first line is\n the first line of Python that runs. There is no shell and no interpreter to\n invoke, and no installer — packages are declared in `packages`.\n- No `inputs` key at all for a new deck.\n- `maxOutputChars` (stdout cap, default 8192, max 65536) is never needed on a\n build call — it prints one line.\n\n## Embedding images\n\nTwo kinds of image input, told apart by where the image came from:\n\n**Tool-produced files** (`generate_image` output, a prior deck return): stage\nthem with the exact `attachmentId` from the tool result — never placeholders\nlike `att_deck`, `att_image`, or any id you made up.\n\n**Images the user uploaded** (\"use this image\", a photo attached to their\nmessage): there is no id to copy — an uploaded image shows none. Stage them\nwith `path` only and **no `attachmentId` key**; the first id-less entry is the\nfirst image of the user's latest message, the second is its second image, and\nso on — never more id-less entries than that message has images. When it has\nnone, a single id-less entry resolves to the chat's most recent image instead.\n\nSeeing the image in your context is not the same as staging it: the deck is\nbuilt by Python, which reads the working directory and never your context, so\nan uploaded image reaches a slide only through an id-less `inputs` entry. Do\nnot call `generate_image` to recreate what the user attached, and do not tell\nthem the image cannot be used — the id-less entry is how it is used.\n\n**Files the user uploaded that are not images** (a `.pptx` to revise, any\ndocument): these *do* show an id, on the `[Attached file \"…\" — attachmentId:\n…]` line of the message that carried them. Copy it verbatim into\n`attachmentId`, exactly as for a tool-produced file. The id-less form never\nreaches them. (Revising an existing deck is its own flow — load\n`references/edit.md`.)\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-pptx==1.0.2\"],\n \"inputs\": [\n { \"attachmentId\": \"<id from generate_image or prior deck>\", \"path\": \"slide1.png\" },\n { \"path\": \"uploaded.png\" }\n ],\n \"outputs\": [\"deck.pptx\"],\n \"command\": \"...\"\n}\n```\n\nStaged files land in the working directory under the bare `path` names —\nreference `slide.shapes.add_picture(\"slide1.png\", …)` by that name only.\nPaths must be unique bare filenames. The working directory is fresh on every\ncall, so a file written by an earlier call is gone unless it is staged again\nas a chat attachment; an attachment from an earlier turn can be used when its\nattachment id is available in the conversation — from a tool result or an\n`[Attached file …]` line — otherwise ask the user to attach the file again.\n\n`attachment … not found in this chat` means you invented an id or the file is\nnot attached. If the file you meant is an image the user uploaded, drop the\n`attachmentId` key; if it came from a tool result or an `[Attached file …]`\nline, re-copy the exact id; for a new deck drop `inputs` entirely; otherwise\nask the user to re-attach.\n\nIf an image was staged in `inputs`, embed it in **that** single build with\n`slide.shapes.add_picture` — never deliver a deck and then rebuild to add the\nimage. Soft-failing (`try`/`except` around the picture) and saving without it\nis a failed turn, not a success.\n\n## Image URLs do not work — never download\n\nYour Python code has **no network access**: `requests`, `urllib`, and `socket`\nall fail with a network error, and `http_request` returns truncated text, never\nimage bytes. When the user gives an image URL, do not try to fetch it from Python\nand do not retry through other tools — that is a dead end. Say the link cannot be\ndownloaded and ask the user to attach the image itself, or offer `generate_image`\nfor a similar visual. Then build the deck with the staged attachment as above.\n\nSay it in the reply, every time. A deck that quietly ships without the image the\nuser linked is a failed turn: they asked for that image, and silence reads as\nthough it is on the slide. Name the URL you could not fetch and what you need\ninstead.\n\n## Writing the Deck\n\nStart from this. It is a complete, working deck — a title slide and a bullet slide,\n16:9, every paragraph sized, saved under the declared output name. Copy it and change\nthe content; do not assemble a deck from memory.\n\n**Keep the source multi-line.** A `for`/`if`/`with` after a semicolon is a\n`SyntaxError` — paste the block with real newlines, not `stmt; for x in y: …`.\n\n**Every length is a typed length — `Inches(...)`, `Pt(...)` or `Emu(...)`, never\na bare number.** This holds for every position and size anywhere in the deck:\nboth pairs of `add_textbox(left, top, width, height)`, the `left`/`top` and the\n`width`/`height` of `add_picture(...)`, `prs.slide_width` and `prs.slide_height`,\ntable column widths and row heights, and every margin or offset.\n\npython-pptx reads a bare number as **EMU**, and there are 914400 EMU to the inch.\nSo `add_textbox(0, 0, 12, 0)` is not \"12 wide\" — it is a box 0.000013in wide and\n0in tall, pinned to the top-left corner. Nothing raises: `exitCode` is `0`, there\nis no traceback and no warning, and the deck is delivered looking broken. Write\n`add_textbox(Inches(0.75), Inches(0.5), Inches(11.83), Inches(1.2))` instead.\n\nRecognise the symptom, because it is the only signal you get: **text crammed\ninto the top-left corner, or a box that has no size**, means a raw number reached\nan argument that required a typed length. Do not tune the numbers — wrap them.\n\n```python\nfrom pptx.util import Inches\n\nbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(8), Inches(1.5))\n# NOT add_textbox(1, 1, 8, 2) — that is 8 EMU wide, an invisible box\n```\n\n**Keep every underscore in API names.** `text_frame`, `add_slide`, `slide_layouts`,\n`slide_width`, `word_wrap`, `add_paragraph`, `add_picture`, `add_textbox`, `PP_ALIGN`\n— stripping them to `textframe` / `addslide` / `addpicture` fails. Copy identifiers\nexactly as written below:\n\n```python\nfrom pptx import Presentation\nfrom pptx.util import Inches, Pt\nfrom pptx.enum.text import PP_ALIGN\n\nprs = Presentation()\nprs.slide_width = Inches(13.333) # 16:9 is not the default\nprs.slide_height = Inches(7.5)\n\n# Title slide — layout 0 owns a title and a subtitle\nslide = prs.slides.add_slide(prs.slide_layouts[0])\nslide.shapes.title.text = \"Why the Sky Is Blue\"\nslide.shapes.title.text_frame.paragraphs[0].font.size = Pt(44)\nsubtitle = slide.placeholders[1].text_frame\nsubtitle.text = \"Rayleigh scattering, in four points\"\nsubtitle.paragraphs[0].font.size = Pt(24)\n\n# Content slide — layout 1 owns a title and a body\nslide = prs.slides.add_slide(prs.slide_layouts[1])\nslide.shapes.title.text = \"What Happens\"\nslide.shapes.title.text_frame.paragraphs[0].font.size = Pt(36)\ntf = slide.placeholders[1].text_frame\ntf.word_wrap = True\nfor index, point in enumerate([\n \"Sunlight arrives carrying every visible wavelength\",\n \"Air molecules scatter short wavelengths hardest\",\n \"Blue scatters far more than red\",\n \"So the daytime sky reads blue in every direction\",\n]):\n p = tf.paragraphs[0] if index == 0 else tf.add_paragraph()\n p.text = point\n p.font.size = Pt(20)\n\nprs.save(\"deck.pptx\") # must match the declared output exactly\nprint(f\"{len(prs.slides)} slides\")\n```\n\nLayouts `0` and `1` own the placeholders that example writes to.\n\n**Choosing a layout has one rule, and it depends on where the deck came from:**\n\n- **Adding to a deck the user gave you** — reuse the layout its own slides\n already use: read a comparable existing slide and pass its `.slide_layout` to\n `add_slide`. That is the only way the new slide inherits the deck's theme.\n That flow is `references/edit.md` — load it.\n- **Building a new deck** — index the bundled template, which commonly uses `0`\n (title), `1` (title + content), `5` (title only), and `6` (blank). Those\n indices belong to *that* template and mean nothing on an uploaded deck.\n\nA layout only owns the placeholders it declares, and python-pptx returns `None` for\nthe rest — on the blank layout `shapes.title` is `None`, so `shapes.title.text = …`\nraises `AttributeError: 'NoneType' object has no attribute 'text'`, and\n`placeholders[1]` raises `KeyError`. So each slide is one of exactly two kinds, never\na mix:\n\n| slide kind | layout | how you write text |\n| --- | --- | --- |\n| title / title + body | the deck's own layout, or `0`, `1`, `5` in a new deck | `shapes.title`, `placeholders[1]` |\n| hand-designed | `6` (blank) | `shapes.add_textbox(...)` for **every** box, title included |\n\nOn layout `6` there is no title to reach for — the title is a text box you add.\n\nThe blank layout is **not** a co-equal way to write a content slide. It is for a\nslide you are genuinely designing by hand — a full-bleed image, a diagram, a\ncustom split — and it inherits no font, size, colour or position from the\ntemplate. Using it plus `add_textbox` to hold ordinary title-and-bullets content\non a deck the user uploaded produces a slide that visibly does not belong: wrong\ntypeface, wrong sizes, wrong margins, and no bullets. Placeholders exist so you\ndo not have to reproduce a theme you cannot see.\n\nBullets — set `tf.text` for the first bullet, then `add_paragraph()` for the rest.\nUsing `add_paragraph()` for the first one leaves a blank leading line.\n\nA new text frame holds exactly **one** paragraph, and `tf.paragraphs` is a tuple, so\n`tf.paragraphs[1]` raises `IndexError: tuple index out of range` until you have added\nit. Grow the frame with `p = tf.add_paragraph()`, which returns the new paragraph, and\nwrite through that. A paragraph owns `.text`, `.font` and `.alignment` and nothing\nelse — it has no `.paragraphs` and no `.add_paragraph()`, so never reassign your frame\nvariable to a paragraph.\n\nThe template's body placeholder inherits 28pt, so size every paragraph you add —\nincluding sub-levels — or it renders far larger than intended:\n\n```python\nfrom pptx.util import Pt\n\nslide = prs.slides.add_slide(prs.slide_layouts[1])\nslide.shapes.title.text = \"Agenda\"\ntf = slide.placeholders[1].text_frame\ntf.word_wrap = True\nfor index, point in enumerate([\"first point\", \"second point\"]):\n p = tf.paragraphs[0] if index == 0 else tf.add_paragraph()\n p.text = point\n p.font.size = Pt(20) # an unsized paragraph inherits 28pt\n```\n\nEvery deck returned through `outputs` is fitted before it reaches the user, so text\nthat would overflow its box is shrunk to fit automatically. That is a safety net, not\na licence to overfill: shrinking below about 16pt is unreadable from a room. Budget\neach slide at no more than five bullets of about 100 characters. A sixth bullet is a\nsecond slide titled `... (cont.)`, never a smaller font — and prose belongs in the\nchat reply, not on a slide.\n\nFree text — the only way to add text outside a placeholder is\n`shapes.add_textbox(left, top, width, height)` — all four are required, and all\nfour are typed lengths, never bare numbers — then write into its `.text_frame`.\nThere is no `add_text_frame`, no `add_text`, and no `add_paragraph` on `shapes`:\n\n```python\nslide = prs.slides.add_slide(prs.slide_layouts[6])\nbox = slide.shapes.add_textbox(Inches(0.75), Inches(0.5), Inches(11.83), Inches(1.2))\ntf = box.text_frame\ntf.word_wrap = True\ntf.text = \"Why the sky is blue\"\ntf.paragraphs[0].font.size = Pt(40) # from pptx.util import Pt\n```\n\nText always lives on the `.text_frame`, never on the shape: `box.paragraphs`,\n`box.add_paragraph()`, `box.word_wrap`, and `box.font` all raise AttributeError. Go\nthrough `tf = box.text_frame` first — `tf.text`, `tf.paragraphs[0]`,\n`tf.add_paragraph()`, `tf.word_wrap`. `shape.text` is the one shortcut that reads\nthrough to the frame; there is no matching `shape.font`.\n\nFormatting lives one level lower still — on a paragraph or a run, never on a shape or\na frame. A title is sized through its paragraph:\n\n```python\ntitle = slide.shapes.title\ntitle.text = \"Why the Sky is Blue\"\ntitle.text_frame.paragraphs[0].font.size = Pt(44) # not title.font.size\ntitle.text_frame.paragraphs[0].font.bold = True\ntitle.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER\n```\n\nSlides are added with `prs.slides.add_slide(layout)` — `prs.add_slide` does not\nexist. Alignment comes from an enum import, not an attribute path:\n\n```python\nfrom pptx.enum.text import PP_ALIGN\n\ntf.paragraphs[0].alignment = PP_ALIGN.CENTER\n```\n\nBullet characters are not needed — a placeholder body renders bullets itself. Give\neach bullet its own paragraph; never pack several `\\n`-joined bullets into one.\nNever type the marker into the text: `\"1. \"`, `\"2. \"`, `\"- \"` and `\"• \"` prefixes\nrender *next to* the bullet the placeholder already draws, in the wrong font.\nBullets belong in a body placeholder for exactly this reason — a bare\n`add_textbox` draws none, and typing them by hand to compensate is the wrong fix.\nPut the content in a placeholder instead.\n\nFont color — the type is `RGBColor` with **RGB in all caps**. Not `RgbColor`,\n`rgbColor`, or `rgb_color`:\n\n```python\nfrom pptx.dml.color import RGBColor\n\ntitle.text_frame.paragraphs[0].font.color.rgb = RGBColor(0x1A, 0x73, 0xE8)\n```\n\nBackgrounds and fills — `background` hangs off the slide itself, never off\n`slide.shapes` or a shape, and the fill is set in two steps: `solid()` first,\nthen the color:\n\n```python\nfrom pptx.dml.color import RGBColor\n\nfill = slide.background.fill # slide.shapes has no background\nfill.solid()\nfill.fore_color.rgb = RGBColor(0x0B, 0x1F, 0x3A)\n\nbox.fill.solid() # a shape is tinted through its own .fill\nbox.fill.fore_color.rgb = RGBColor(0xF2, 0xF2, 0xF2)\n```\n\nA paragraph has no `.fill` at all — coloring text goes through the font,\n`paragraph.font.color.rgb = RGBColor(...)`, as above. `fill` exists on a shape\nand on `slide.background`, nowhere else you will need.\n\nImages — go through the shapes collection: `slide.shapes.add_picture(...)`. A\n`Slide` has no picture or text-box methods of its own — `slide.add_picture`,\n`slide.addpicture`, `slide.add_textbox`, and `slide.addtextbox` all raise\n`AttributeError: 'Slide' object has no attribute '…'`. Fix: put `.shapes` between\n`slide` and the method. Pass only one of `width`/`height`; passing both distorts\nthe picture. Generate image-slide visuals at 1024×512 so they fill the content box;\na 512×512 square letterboxes with wide empty bands either side.\n\n**Do not soft-fail images or imports.** Never wrap `add_picture` or color imports in\n`try`/`except` that prints a warning and continues. A missing file or\n`cannot import name 'RgbColor'` must raise so you fix it and rerun — a deck that\nsaves without the requested image is a failed turn, not a success.\n\n```python\nslide = prs.slides.add_slide(prs.slide_layouts[6])\n# correct: slide.shapes.add_picture — never slide.add_picture / slide.addpicture\nslide.shapes.add_picture(\"slide1.png\", Inches(0.75), Inches(1.0), width=Inches(11.83))\nprs.save(\"deck.pptx\")\nprint(f\"{len(prs.slides)} slides\")\n```\n\n## Errors\n\n- Never print the deck's bytes or base64 — stdout is capped (8 KB by default)\n and the file travels through `outputs`. A build call prints only the slide\n count line (e.g. `7 slides`). No \"Presentation created successfully\", no\n try/except warnings on stdout.\n- `cannot import name 'RgbColor' from 'pptx.dml.color'` means the name is wrong —\n use `RGBColor` (all-caps RGB). Do not catch the ImportError and save anyway.\n- Never pass an absolute path to `save()`.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`text_frame`, not `textframe`) must stay. Do not switch to `python -c`\n or change the package pin.\n- `outputs declare a .pptx but command does not build one` means the source never\n calls `Presentation(...).save(...)` — paste the skill sample (edited for content),\n not a diagnostic `os.listdir` or shell wrapper.\n- `ModuleNotFoundError: No module named 'pptx'` means `packages` was missing or\n wrong — add `[\"python-pptx==1.0.2\"]` and rerun. Never try to install it.\n- `attachment … not found in this chat` means `inputs` listed an id that is not in\n this chat (often a copied placeholder like `att_deck`). For a new deck, omit\n `inputs` entirely and rerun. Only stage real ids from prior tool results.\n- Text crammed into the top-left corner, or a shape with no visible size, means a\n bare number reached an argument that required a typed length — python-pptx reads\n it as EMU (914400 to the inch), so `add_textbox(0, 0, 12, 0)` is an invisible box\n in the corner. Nothing raises and `exitCode` is `0`, so this only ever shows up in\n the delivered deck. Wrap every position and size in `Inches(...)` / `Pt(...)` /\n `Emu(...)` and rerun — do not tune the raw numbers.\n- On an `AttributeError` from python-pptx the API name is wrong, and on a `TypeError`\n about missing positional arguments a required argument was left out — fix either\n against this file's examples. Do not retry the same call, and do not switch to a shell.\n- `AttributeError: 'Slide' object has no attribute 'add_picture'` (or `addpicture`,\n `add_textbox`, `addtextbox`) means the call skipped `.shapes` — use\n `slide.shapes.add_picture(...)` / `slide.shapes.add_textbox(...)`, never\n `slide.add_*`.\n- `AttributeError: 'SlideShapes' object has no attribute 'background'` means the\n background was reached through the shapes collection — it lives on the slide:\n `slide.background.fill.solid()` then `fill.fore_color.rgb = RGBColor(...)`.\n- `AttributeError: '_Paragraph' object has no attribute 'fill'` means a fill was\n asked of text — paragraphs have none. Color text with\n `paragraph.font.color.rgb = RGBColor(...)`; `.fill` belongs to a shape or to\n `slide.background`.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the `.pptx`, stop tool use and\nanswer with one line: file name + the slide count from stdout. Exactly one\nsuccessful build `exec` per request — re-running the same build is spam, not\nquality.\n",
|
|
73
|
+
"presentations/references/edit.md": "# Editing an Existing Deck (python-pptx)\n\nEditing means opening the deck that already exists and changing only what the\nuser asked for.\n\n**The first line of an edit is always `Presentation(\"<staged path>\")`.** A bare\n`Presentation()` is only ever for a brand-new deck — it opens the bundled blank\ntemplate, not the user's file, so retyping the slides regenerates their text and\nthrows away the original content and design. A rebuilt deck is a failed turn.\nStage the deck as an input by its real `attachmentId` and open **that staged\nfile**. If no attachment id for the deck is available, ask the user to attach it\nagain.\n\n## Staging the deck\n\nThe id comes from wherever the deck entered the chat: the `exec` result that\ndelivered it, or — for a deck the **user uploaded** — the `[Attached file …]`\nline on their message, which names every non-image upload:\n\n```\n[Attached file \"quarterly.pptx\" (application/vnd.openxmlformats-officedocument.presentationml.presentation) — attachmentId: 4f9c2ab1]\n```\n\nCopy that id verbatim — never placeholders like `att_deck` or any id you made\nup. A `.pptx` is never staged id-less: the id-less form resolves to an uploaded\n*image*, so it cannot reach a deck. An attachment from an earlier turn can be\nused when its attachment id is available in the conversation — from a tool\nresult or an `[Attached file …]` line. Otherwise, ask the user to attach the\nfile again.\n\n## Read first, then edit\n\n**An edit that writes new prose is two `exec` calls, in this order:** a **read**\nthat stages the deck, prints what is on the slides and declares **no `outputs`**;\nthen the **edit** that stages the same deck, makes the change and declares the\noutput.\n\nThe read has to be its own call, because one call cannot inform itself. The words\nyou put on a new slide are in the source you submit — fixed before the program\nruns — so a `print` in that same program reports the deck back to you only after\nthe slide was already written and saved. A single call can still *compute*\nagainst the deck (`prs.slides[1].slide_layout`, `len(prs.slides)`), because that\nis code the runtime evaluates against the real file. What it cannot do is let you\n**write** from what the deck says.\n\nA mechanical edit needs no read: a font size, a colour, a slide whose text the\nuser already gave you. Read first when the new text has to agree with the deck —\na conclusion, a summary, a \"what changed\" slide — and go straight to the edit\nwhen it does not.\n\nStill forbidden, and unchanged: an `exec` opened *after* the deck is delivered to\ncheck what you sent. The result you already hold is the whole account of that\nrun — that is the loop `Success = stop` closes.\n\nThe read call declares **no `outputs`** — it builds nothing, it only reports:\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-pptx==1.0.2\"],\n \"inputs\": [{ \"attachmentId\": \"<id of the deck in this chat>\", \"path\": \"deck.pptx\" }],\n \"maxOutputChars\": 24000,\n \"command\": \"...\"\n}\n```\n\n`maxOutputChars` raises the stdout cap so the whole deck comes back in one\nresult — without it stdout is capped at 8 KB. Keep the sample's 24000 (the cap's\nmaximum is 65536). A build call prints one line and never needs it.\n\nThe edit call is the one that builds, and there is **exactly one** of those:\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-pptx==1.0.2\"],\n \"inputs\": [{ \"attachmentId\": \"<id of the deck in this chat>\", \"path\": \"deck.pptx\" }],\n \"outputs\": [\"deck-v2.pptx\"],\n \"command\": \"...\"\n}\n```\n\n- `packages` — pin exactly `python-pptx==1.0.2` on every call; an unpinned\n install resolves a potentially different library version. This exact version\n ships with the app and installs with no network; any other version has to be\n downloaded, which fails on a device that is offline.\n- `inputs` — the staged deck. Paths must be unique bare filenames; staged files\n land in the working directory under those names — reference\n `Presentation(\"deck.pptx\")` by that name only.\n- `outputs` — the file to deliver. A file you write but do not declare here is\n discarded. Omit on a read call — a read builds nothing.\n- `command` — the multi-line Python source, with real newline characters. Never\n collapse it to one line joined by `;` — a `for`/`if`/`with` after a semicolon\n is a `SyntaxError`.\n\nBoth stage the same deck by the same `attachmentId`: the working directory is\nfresh on every call, so the read leaves nothing behind for the edit to reuse.\n\nName the output after the deck you opened: keep its stem and bump a version —\n`deck.pptx` → `deck-v2.pptx`, and an edit of that one → `deck-v3.pptx`. The\nshared stem reads as one document's history in the chat, and the new name leaves\nthe version you opened still openable. Never overwrite the staged input.\n\n## The read call\n\nThe read call's whole program is the loop — layout name and every line, so the\nedit that follows can be written against real content:\n\n```python\nfrom pptx import Presentation\n\nprs = Presentation(\"deck.pptx\") # the staged input — never Presentation()\nfor index, slide in enumerate(prs.slides):\n lines = [s.text_frame.text.replace(\"\\n\", \" \") for s in slide.shapes if s.has_text_frame]\n print(f\"{index}: [{slide.slide_layout.name}] {' | '.join(lines)}\")\n```\n\nThe `replace` is load-bearing: a multi-paragraph body embeds `\"\\n\"` between its\nbullets, and an embedded newline would split one slide across several printed\nlines. Flattened, every printed line is exactly one slide, starting with its\nindex and layout name.\n\nIt saves nothing and declares no `outputs`. Read its result before writing the\nedit: the layout names decide which layout the new slide copies, and the lines\ndecide what it can truthfully say.\n\n**Read the deck before you write into it.** New content has to agree with what\nis already on the slides, and you cannot write a conclusion, a summary, or a\n\"what changed\" slide from the titles alone — the titles are headings, and the\nsubstance is in the bodies underneath them. Loop every slide and print every\nshape with `shape.has_text_frame` in the **read** call, then write the edit\nagainst what came back. A slide written from titles only reads as though it\nbelongs to a different deck: it restates the headings, invents specifics the\ndeck never claimed, and contradicts the bullets it is supposed to close.\n\nRead through `slide.shapes` **only**. `slide.placeholders` is not a second place\nto look — every placeholder is already in `slide.shapes`, the same shape reached\nby a narrower door, so looping both prints the whole deck twice and doubles what\nyou have to read back. Nor can you dedupe your way out of it: python-pptx builds\na fresh wrapper on each access, so the title reached through `shapes` and the\ntitle reached through `placeholders` are `==`-distinct objects over one XML\nelement — `in`, `is` and `set()` all fail to spot the repeat. One loop over\n`slide.shapes`, guarded by `shape.has_text_frame`, is the whole read.\n\nPrint `slide.slide_layout.name`, never the layout object — `print(slide.slide_layout)`\ngives `<pptx.slide.SlideLayout object at 0x…>`, which tells you nothing and leaves\nthe layout choice to guesswork. The name is the template's own label, like\n`Title Slide`, `Title and Content`, or `Section Header`.\n\nThat same read locates the slide to change: match on the text you printed, and\nedit through the shape you matched. python-pptx has no API to delete or reorder\nslides — say so instead of hacking at the XML.\n\n## The edit call\n\nThe edit call then opens the same staged file, changes it in place, and saves\nunder the versioned name — never over the staged input:\n\n```python\nfrom pptx import Presentation\nfrom pptx.util import Pt\nfrom pptx.dml.color import RGBColor\n\nprs = Presentation(\"deck.pptx\") # the staged input — never Presentation()\nbefore = len(prs.slides)\n\n# Retitle the first slide in place — every other shape keeps its text\ntitle = prs.slides[0].shapes.title\ntitle.text = \"Why the Sky Is Blue — Revised\"\ntitle.text_frame.paragraphs[0].font.size = Pt(44)\n\n# Recolor existing text through its paragraph font\nfirst_body = prs.slides[1].placeholders[1].text_frame\nfirst_body.paragraphs[0].font.color.rgb = RGBColor(0x1A, 0x73, 0xE8)\n\n# One new slide, on the layout a comparable BODY slide uses — never the cover's\nmodel = prs.slides[1] # a content slide; slide 0 is usually the cover\nslide = prs.slides.add_slide(model.slide_layout)\nslide.shapes.title.text = \"What Changed\"\ntf = slide.placeholders[1].text_frame\ntf.word_wrap = True\ntf.text = \"One new closing slide, nothing else touched\"\ntf.paragraphs[0].font.size = Pt(20)\n\nprs.save(\"deck-v2.pptx\") # the declared output — not deck.pptx\nprint(f\"{before} -> {len(prs.slides)} slides\")\n```\n\n**Reuse the deck's own layout — never the blank one.** `prs.slide_layouts[…]`\nindexes the *template's* layout list, and on an uploaded deck those indices mean\nwhatever that template says; a slide's own `.slide_layout` is the layout it is\nalready built on, so passing that to `add_slide` gives the new slide the same\nplaceholders, fonts, colours and positions as its neighbours. Choose the index\nby reading the deck — pick the existing slide that most resembles the one you\nare adding, a content slide for a content slide — and fill the placeholders it\nhands you. The index above is that choice, not a constant: a one-slide deck has\nonly `prs.slides[0]`, and `prs.slides[1]` raises `IndexError`.\n\n**Slide 0 is almost always the cover**, on a `Title Slide` layout that owns a big\ncentred title and a subtitle and nothing else. Copying *that* layout for a\nconclusion produces a second cover page in the middle of the deck — placeholders\nthat fit one line, no bullet body, and title styling that shouts. Take the layout\nfrom a slide that carries real content — typically `Title and Content` — and reach\nfor the cover's layout only when you are genuinely adding another cover. Reaching\nfor `slide_layouts[6]` (blank) and hand-placing text boxes on a themed deck\ninherits none of the theme and **guarantees a visual mismatch** with the slides\nbeside it.\n\n**Every length is a typed length — `Inches(...)`, `Pt(...)` or `Emu(...)`, never\na bare number** — for every `add_textbox(left, top, width, height)` argument,\nevery `add_picture(...)` position and size, and every margin or offset.\npython-pptx reads a bare number as EMU (914400 to the inch), nothing raises, and\nthe deck is delivered with text crammed into the top-left corner or a box that\nhas no size. Do not tune the numbers — wrap them:\n\n```python\nfrom pptx.util import Inches\n\nbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(8), Inches(1.5))\n# NOT add_textbox(1, 1, 8, 2) — that is 8 EMU wide, an invisible box\n```\n\nSize every paragraph you add (`p.font.size = Pt(20)`, as in the sample) — the\ntemplate's body placeholder inherits 28pt, so an unsized paragraph renders far\nlarger than intended. Font color goes through `RGBColor` with **RGB in all\ncaps** (never `RgbColor`), on a paragraph's font — a paragraph has no `.fill`.\nFor the full slide-authoring API — placeholders vs. text boxes, bullets,\nformatting, backgrounds, pictures — load `references/create.md`.\n\nIf an image was staged in `inputs`, embed it in **that** single build with\n`slide.shapes.add_picture` — never deliver a deck and then rebuild to add the\nimage. Soft-failing (`try`/`except` around the picture) and saving without it\nis a failed turn, not a success.\n\n## Verify the slide count\n\n**Verify an added slide by the slide count.** Print `before` and `after` as the\nsample does, then read the number back: the delta has to be exactly what the user\nasked for — one added slide is `2 -> 3 slides`, and `2 -> 4 slides` means the\nslide got appended twice. A delta that does not match the request is a **failed\nturn to diagnose, not a result to report**: find the second `add_slide` (or the\none that never ran) and rerun. Note the count can only ever grow, since there is\nno API to delete a slide. This check is about slides you add — an edit that only\nchanges text on existing slides leaves the count flat, and that is correct.\n\nThat check lives inside the build, so it takes no extra call: the counts come\nfrom one `print` in the same `exec` that does the edit. A delivered deck is still\nnever reopened to \"verify\" it.\n\n**This is the one exception to `Success = stop`, and it is not a second call.**\nWhen the edit added slides, `exitCode: 0` plus an attachment cannot tell the\nedit you were asked for apart from one that fired twice or not at all — every\none of those produces both. Read the before/after count printed by that same\nrun before you reply. The fix is a corrected build, never an `exec` opened to\ninspect what was already delivered.\n\nThat corrected build goes out under the **next** version: `deck-v3.pptx` after a\nbroken `deck-v2.pptx`. The name you already delivered is refused on a second\nattach — `\"deck-v2.pptx\" was already attached this turn` — so reusing it turns a\nrecoverable turn into a dead one. The broken version stays in the chat either\nway, so name the good file in your reply.\n\n## Errors\n\n- `ModuleNotFoundError: No module named 'pptx'` means `packages` was missing or\n wrong — add `[\"python-pptx==1.0.2\"]` and rerun. Never try to install it.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_deck`). Re-copy the exact\n id from the tool result or the `[Attached file …]` line that names the deck;\n if neither exists, ask the user to attach it again instead of retrying.\n- Every line showing up twice in the read output means the loop walked\n `slide.shapes` *and* `slide.placeholders`. Placeholders are already shapes, so\n the second pass re-reads what the first one found. Drop it — one loop over\n `slide.shapes` is the whole read.\n- A read result ending in `… [truncated]` means the deck outgrew the cap:\n answer from what came back and say the answer covers the deck up to that\n point. Do not rerun the read — it prints the same beginning again.\n- A slide count whose delta does not match the request (`2 -> 4 slides` when one\n slide was asked for) means `add_slide` ran twice, even at `exitCode 0`. Read the\n printed before/after count before replying — see Verify the slide count.\n- A new slide that does not match the deck around it — different font, size or\n colour, bullets missing — was added on the blank layout instead of the deck's\n own. Read a comparable existing slide, pass its `.slide_layout` to `add_slide`,\n and fill its placeholders.\n- Text crammed into the top-left corner, or a shape with no visible size, means a\n bare number reached an argument that required a typed length — wrap every\n position and size in `Inches(...)` / `Pt(...)` / `Emu(...)` and rerun.\n- `cannot import name 'RgbColor' from 'pptx.dml.color'` means the name is wrong —\n use `RGBColor` (all-caps RGB). Do not catch the ImportError and save anyway.\n- Never pass an absolute path to `save()`.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`text_frame`, not `textframe`) must stay. Do not switch to `python -c`\n or change the package pin.\n- `outputs declare a .pptx but command does not build one` means the source never\n calls `Presentation(...).save(...)` — paste the skill sample (edited for content),\n not a diagnostic `os.listdir` or shell wrapper.\n- On an `AttributeError` from python-pptx the API name is wrong, and on a `TypeError`\n about missing positional arguments a required argument was left out — fix either\n against this file's examples. Do not retry the same call, and do not switch to a shell.\n- `AttributeError: 'Slide' object has no attribute 'add_picture'` (or `addpicture`,\n `add_textbox`, `addtextbox`) means the call skipped `.shapes` — use\n `slide.shapes.add_picture(...)` / `slide.shapes.add_textbox(...)`, never\n `slide.add_*`.\n- Never print the deck's bytes or base64 — stdout is capped and the file travels\n through `outputs`. A build call prints only the before/after slide count line;\n only a read call prints slide text.\n\n## Finish\n\nWhen `exitCode` is `0`, `attachments` lists the `.pptx`, and the printed\nbefore/after count matches the request, stop tool use and answer with one line:\nfile name + slide count from stdout. Exactly one successful build `exec` per\nrequest — the no-`outputs` read attaches nothing and is not that call.\n",
|
|
74
|
+
"presentations/references/read.md": "# Reading a Deck to Answer in Chat (python-pptx)\n\nWhen the user asks what an attached `.pptx` *says* — a summary, a question\nanswered, specific content pulled out — the deliverable is your reply in the\nchat, not a file. This is a **read request**: one no-`outputs` read call,\nstaged by the deck's real `attachmentId`, is the only `exec` of the turn — no\nbuild call follows it.\n\n**You cannot summarize in the call that reads.** The words in `command` are\nfixed before the program runs, so one call cannot inform itself: any summary\nwritten into it was written blind — recalled or invented, not read. Python only\n*transports* the slides; the summarizing happens in your reply, after the\nresult comes back.\n\n## Staging the deck\n\nThe id comes from wherever the deck entered the chat: the `exec` result that\ndelivered it, or — for a deck the **user uploaded** — the `[Attached file …]`\nline on their message, which names every non-image upload:\n\n```\n[Attached file \"quarterly.pptx\" (application/vnd.openxmlformats-officedocument.presentationml.presentation) — attachmentId: 4f9c2ab1]\n```\n\nCopy that id verbatim — never placeholders like `att_deck` or any id you made\nup. A `.pptx` is never staged id-less: the id-less form resolves to an uploaded\n*image*, so it cannot reach a deck. If no attachment id for the deck is\navailable anywhere in the chat, ask the user to attach it again — the working\ndirectory is fresh on every call, so a file from an earlier call is gone unless\nstaged again by its id.\n\n## The exec call\n\nThe read call declares **no `outputs`** — it builds nothing, it only reports:\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-pptx==1.0.2\"],\n \"inputs\": [{ \"attachmentId\": \"<id of the deck in this chat>\", \"path\": \"deck.pptx\" }],\n \"maxOutputChars\": 24000,\n \"command\": \"...\"\n}\n```\n\n- `packages` — pin exactly `python-pptx==1.0.2`; this exact version ships with\n the app and installs with no network; any other version has to be downloaded,\n which fails on a device that is offline.\n- `inputs` — the staged deck, under a unique bare filename; reference\n `Presentation(\"deck.pptx\")` by that name only.\n- `maxOutputChars` — raises the stdout cap so the whole deck comes back in one\n result — without it stdout is capped at 8 KB. Keep the sample's 24000\n (default 8192, max 65536). Only a read call prints slide text.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;`.\n\n## The recipe\n\nThe read call's whole program is the loop — layout name and every line:\n\n```python\nfrom pptx import Presentation\n\nprs = Presentation(\"deck.pptx\") # the staged input — never Presentation()\nfor index, slide in enumerate(prs.slides):\n lines = [s.text_frame.text.replace(\"\\n\", \" \") for s in slide.shapes if s.has_text_frame]\n print(f\"{index}: [{slide.slide_layout.name}] {' | '.join(lines)}\")\n```\n\nThe `replace` is load-bearing: a multi-paragraph body embeds `\"\\n\"` between its\nbullets, and an embedded newline would split one slide across several printed\nlines. Flattened, every printed line is exactly one slide, starting with its\nindex and layout name.\n\nA bare `Presentation()` opens the bundled blank template, not the user's file —\nthe first line is always the staged path. Loop every slide and print every\nshape guarded by `shape.has_text_frame`: the titles are headings, and the\nsubstance is in the bodies underneath them.\n\nRead through `slide.shapes` **only**. `slide.placeholders` is not a second place\nto look — every placeholder is already in `slide.shapes`, the same shape reached\nby a narrower door, so looping both prints the whole deck twice and doubles what\nyou have to read back. Nor can you dedupe your way out of it: python-pptx builds\na fresh wrapper on each access, so the title reached through `shapes` and the\ntitle reached through `placeholders` are `==`-distinct objects over one XML\nelement — `in`, `is` and `set()` all fail to spot the repeat. One loop over\n`slide.shapes`, guarded by `shape.has_text_frame`, is the whole read.\n\n## Finish: answer in the chat\n\n**A successful read ends tool use.** When the result prints the slides, reply\nwith the summary or the answer as chat text. **Scale the reply to the deck**: a\nsummary is much shorter than what it summarizes — a handful of slides earns\nthree to five sentences, and only a long deck earns sections. Restating every\nslide is not a summary. Do **not**:\n\n- call `exec` again to \"re-check\", \"read more\", or read the same deck a second\n time;\n- build a summary `.pptx` the user never asked for — an unrequested file is a\n failed turn, not a bonus.\n\nIf stdout ends with `… [truncated]`, the deck is longer than the cap: answer\nfrom what came back and say the answer covers the deck up to that point. Do not\nrerun the read — it prints the same beginning again.\n\nIf the user asks for the summary **as a file**, that is a read followed by a\nbuild: the read call first, then one build call that writes the new deck from\nthe slides you actually read. The build call follows `references/create.md` —\nload it; the read still declares no `outputs`.\n\n## Errors\n\n- `ModuleNotFoundError: No module named 'pptx'` means `packages` was missing or\n wrong — add `[\"python-pptx==1.0.2\"]` and rerun. Never try to install it.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_deck`). Re-copy the exact\n id from the tool result or the `[Attached file …]` line that names the deck;\n if neither exists, ask the user to attach it again instead of retrying.\n- Every line showing up twice in the read output means the loop walked\n `slide.shapes` *and* `slide.placeholders`. Placeholders are already shapes —\n drop the second loop; one loop over `slide.shapes` is the whole read.\n- A read result ending in `… [truncated]` means the deck outgrew the cap:\n answer from what came back — do not rerun the read.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`text_frame`, not `textframe`) must stay. Do not switch to `python -c`\n or change the package pin.\n",
|
|
75
|
+
"spotify/SKILL.md": "---\nname: spotify\ndescription: Play, search, and control music on Spotify — songs, artists, albums, playlists, and playback.\nemoji: 🎵\ntools: [http_request]\nplatform: [darwin, linux, win32, ios, android]\ncredentials: [spotify_access_token]\nallow_list: [https://api.spotify.com/v1/]\nmetadata:\n {\n \"openclaw\":\n {\n \"requires\":\n {\n \"credentials\": [\"spotify_access_token\"],\n \"credentialChecks\":\n { \"spotify_access_token\": { \"url\": \"https://api.spotify.com/v1/me\" } }\n }\n }\n }\n---\n\n# Spotify\n\nUse `http_request` against `https://api.spotify.com/v1`. The Spotify credential is attached automatically to every `api.spotify.com` request — **never include an `auth` block**. Never invent track/album/artist URIs — search first and copy `uri` from the JSON response.\n\n```json\n{\n \"url\": \"https://api.spotify.com/v1/search\",\n \"method\": \"GET\",\n \"query\": { \"q\": \"Radiohead Creep\", \"type\": \"track\", \"limit\": 5 }\n}\n```\n\n## Hard rules\n\n- A bare song/artist/album/playlist name is enough — search immediately, take the top match, and tell the user what you picked. Do not ask \"which one?\" before searching.\n- Always search before playing by name. Play carries URIs only in the JSON `body` (`\"uris\": [\"…\"]`), never as query parameters.\n- A bare `PUT /me/player/play` with no body only resumes paused playback — it never plays a requested song. For an album/artist/playlist use `{ \"context_uri\": \"<uri>\" }` instead of `uris`.\n- Do not ask about tokens or setup up front. A **401** means Spotify isn't connected (tell the user to run `/connect spotify`). A **404** from `/me/player` means no active device (tell them to open Spotify).\n\n## Recipe: play a song by name\n\n1. Search:\n\n```json\n{\n \"url\": \"https://api.spotify.com/v1/search\",\n \"method\": \"GET\",\n \"query\": { \"q\": \"Radiohead Creep\", \"type\": \"track\", \"limit\": 5 }\n}\n```\n\n2. Copy `tracks.items[0].uri` into the play body:\n\n```json\n{\n \"url\": \"https://api.spotify.com/v1/me/player/play\",\n \"method\": \"PUT\",\n \"body\": { \"uris\": [\"spotify:track:70LcF31zb1H0PyJoS1Sx1r\"] }\n}\n```\n\n## Other operations\n\nAll paths are under `https://api.spotify.com/v1`.\n\n| Ask | Method and path |\n| --------------- | ----------------------------------------------------------------------- |\n| What's playing? | `GET /me/player/currently-playing` |\n| Pause | `PUT /me/player/pause` |\n| Resume | `PUT /me/player/play` (no body) |\n| Next track | `POST /me/player/next` |\n| Add to queue | `POST /me/player/queue` with `query`: `{ \"uri\": \"spotify:track:<id>\" }` |\n| My playlists | `GET /me/playlists` |\n| Top tracks | `GET /me/top/tracks` with `query`: `{ \"time_range\": \"medium_term\" }` |\n| Recently played | `GET /me/player/recently-played` |\n| List devices | `GET /me/player/devices` |\n\n## Notes\n\n- Keep responses small — they truncate past 8KB, and raw JSON burns tokens on small models. Keep `limit` at 5. On playlist/track endpoints request only what you need with `fields` (e.g. `query`: `{ \"fields\": \"items(track(name,artists(name),uri))\" }`). Read just the top item unless the user asked for a list.\n- Present results as a short numbered list — track, artist, album, duration — and devices as `1. Name (active/idle)`. Never dump raw JSON to the user.\n- Playback commands return **204 No Content** on success (empty body). A **204** from currently-playing means nothing is playing.\n- **403** with `PREMIUM_REQUIRED` → user needs Spotify Premium. Other **403**s are usually app-scope/Development Mode limits — report and stop.\n- Never print the token or the `Authorization` header, and don't claim a write succeeded without a successful response in this turn.\n",
|
|
76
|
+
"weather/SKILL.md": "---\nname: weather\ndescription: Get current weather and short forecasts for cities via wttr.in.\ntools: [http_request]\nplatform: [darwin, linux, win32, ios, android]\nallow_list: [https://wttr.in/]\n---\n\n# Weather\n\nUse `http_request` with wttr.in for current conditions and short forecasts (max 3 days). Pick the smallest format — the tool output is fed back as your next prompt.\n\n## Format guide\n\n**Default to `?format=3` for any \"what's the weather…?\" / \"what about …?\" / single-location question.** Only escalate to a multi-day form if the user explicitly says \"tomorrow\", \"weekend\", \"next N days\".\n\n- Current / casual → `?format=3` (one line, smallest)\n- Today's forecast → `?1T`\n- Tomorrow / weekend (2 days) → `?2T`\n- Full 3-day forecast → `?T`\n\nEvery wttr.in call MUST end in one of these suffixes. **Never call `https://wttr.in/<location>` with no `?…` suffix** — the bare URL returns a multi-kilobyte response that will overflow the context.\n\n```json\n{ \"url\": \"https://wttr.in/London?format=3\", \"method\": \"GET\" }\n{ \"url\": \"https://wttr.in/New+York?2T\", \"method\": \"GET\" }\n```\n\n## Notes\n\n- No API key. Spaces in city → `+` (e.g. `New+York`). Ask for the location if missing.\n- **wttr.in caps at 3 days.** If the user asks for \"next week\" or longer, say so and offer `?T` (3-day grid). Options like `?7`, `format=11`, `num_of_days=` don't exist.\n- Summarize in plain language. Don't claim live weather unless the request succeeded.\n",
|
|
77
|
+
"word/SKILL.md": "---\nname: word\ndescription: Create, edit, or read Word (.docx) documents with python-docx — deliver documents as chat attachments, or read an attached one to summarize it or answer questions in the chat. Can embed images generated in the chat. Opens in Pages and Google Docs too.\naliases: [docx, word-document, memo]\npreload_on_name: false\ntools: [exec(python)]\nplatform: [darwin, linux, win32]\nmetadata:\n {\n \"openclaw\":\n {\n \"setup\":\n {\n \"summary\": \"Runs python-docx in the in-process Python runtime, from packages that ship with the app. The first use waits for the runtime to start.\"\n }\n }\n }\n---\n\n# Word\n\nBuild, edit, or read `.docx` documents by running python-docx through the\n`exec` tool with `language: \"python\"`. Declare a produced document in\n`outputs` and it comes back as a chat attachment the user can save. To answer\n_from_ a document instead of building one, run a read call — no `outputs` —\nand reply in the chat.\n\n## Load the Recipe File First\n\nThis file contains no Python. The working recipes live in four reference\nfiles — load the one for the job with the `skill` tool BEFORE writing any\nPython, then copy its recipe and change the content:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Creating a new document** (no existing `.docx` involved; may embed\n images): call the `skill` tool with `name: \"word\"` and\n `file: \"references/create.md\"`.\n- **Changing some of the facts, points, bullets, items, or paragraphs** of an\n existing `.docx` — \"replace the first 10 facts\", \"change fact 3\", \"swap the\n bullets for these\", \"reword paragraph 7\": call the `skill` tool with\n `name: \"word\"` and `file: \"references/replace.md\"`. This is the file even\n when the user says edit, replace, change, update, or rewrite; it runs two\n bundled scripts and no Python is written.\n- **Any other edit of an existing document** (extend it, trim it, rework a\n whole section, resize the text, embed an image into it): call the `skill`\n tool with `name: \"word\"` and `file: \"references/edit.md\"`.\n- **Reading a document to answer in chat** (a summary, a question answered,\n content pulled out — no file delivered): call the `skill` tool with\n `name: \"word\"` and `file: \"references/read.md\"`.\n- **A summary delivered as a file** is a read followed by a build: load both\n `references/read.md` and `references/create.md`.\n\nNever write the Python from memory. The recipes carry rules (exact version\npins, attachment staging, run-level formatting, in-place replacement, the only\nworking removal idiom) that fail in non-obvious ways when improvised; loading\nthe file is one cheap read-only call.\n\n## When to Use\n\n- The user asks for a document, report, letter, memo, `.docx`, or Word file.\n- The user attaches a `.docx` and wants its content changed, replaced in part,\n extended, trimmed, or reworked.\n- The user attaches a `.docx` and asks what it says — a summary, a question\n answered, or content pulled out into the chat.\n- The user wants a document that embeds images generated in this chat.\n\n## When NOT to Use\n\n- The user wants text in the chat and no document is involved — just write it.\n Summarizing or answering from an attached `.docx` **is** this skill: load\n `references/read.md`.\n- The user wants slides or a deck — that is the presentations skill.\n- The user wants a spreadsheet or a PDF — python-docx writes only `.docx`.\n\n## What This Skill Cannot Do\n\nSay so instead of faking these; a fake is worse than a clear \"not supported\":\n\n- **No table of contents.** A real TOC is a Word field that Word itself computes;\n python-docx cannot insert one. Do not fake a TOC by typing headings and page\n numbers — the page numbers would be wrong. Offer headings (`Heading 1..9`)\n instead; Word can generate a TOC from them later.\n- **No tracked changes or comments.** There is no revisions API. Edits land as\n plain content; say that when the user asks for a redline.\n- **No legacy `.doc`.** Only `.docx`. A `.doc` output name is rejected — name it\n `.docx`.\n- **No PDF export and no rendering.** The runtime cannot convert or preview the\n document; it can only write the file.\n\n## Rules for Every Job\n\n**You build it, not the user.** Deliver the document, never the recipe. Do NOT\nprint the python source in chat, do NOT tell the user to install python-docx,\nrun a script, or open a terminal — they have no terminal in this chat and the\ncode would not run there. The document exists only if an `exec` call with\n`outputs` succeeds and returns the attachment; falling back to \"here is the\nscript, run it yourself\" is a failed turn.\n\n**Success = stop.** When `exitCode` is `0` and the result's `attachments`\nlists the `.docx`, the document is done. Do not call `exec` again for the same\nrequest — not to \"confirm\", not to \"improve\", not to \"add the image\" after the\nfact. Exactly one successful _build_ `exec` per document request — a\nno-`outputs` read that precedes a build delivers nothing and is not one of\nthem, but it belongs before the build, never after it. Reply with a single\nline: file name + the count line from stdout. If the result has\n`missingOutputs` instead, the file was never written: read stderr first — an\n`AssertionError` there means a guard stopped the save on purpose (see the edit\nrecipe); only when stderr is clean check the `save()` name matches the\ndeclared output and rerun once.\n\n**Failures are fixed in the code, not around it.** An error in your code is\nnever a fault in python-docx or in the runtime; fix the Python against the\nloaded reference file's recipes and Errors and call `exec` again. If two\nconsecutive calls fail with the same error, re-read the traceback\nline-by-line before a third — retrying the identical `command`, or a version\nwith only cosmetic changes, is a loop, not a fix. Do not switch package pins\n(keep `python-docx==1.2.0`), do not wrap source in `python -c` / `pip` /\nshell, do not \"debug\" with `os.listdir` or no-op scripts while `outputs`\nstill lists the document, and do not write the document as markdown/chat text\ninstead of a `.docx`. Never search the web about an error; the answer is\nalways in the `exec` result you already have.\n\n**The runtime is sealed.** There is no shell — `ls`, `cat`, and `file` raise\n`SyntaxError` because `command` is Python source — and no network:\n`requests`, `urllib`, and `socket` all fail. The working directory starts\nempty on every call: a file from an earlier call is gone unless staged again,\nand a file you write but do not declare in `outputs` is discarded. The `exec`\nresult is the only account of what happened — there is no filesystem to check\nand no shell to check it with.\n\n**Never overwrite a staged input.** Edits always save a new output name,\nderived from the document edited — `report.docx` becomes `report_revised.docx`,\nnever a fresh name taken from the new content.\n\n**An edit changes the document in place.** `add_paragraph` and `add_heading`\nappend at the end and nowhere else, so replacing content that is already there\nmeans rewriting those paragraphs, not adding new ones. Delivering the original\nwith the new version appended is a failed turn — the edit recipe carries the\nguards that catch it.\n",
|
|
78
|
+
"word/references/create.md": "# Creating a Word Document (python-docx)\n\nCreate a new `.docx` from scratch by running Python through the `exec` tool.\nA new document needs **no** `inputs` — do not invent attachment ids — unless\nit embeds an image (see Embedding Images). **Exactly one** `exec` call per\nuser request when that call succeeds.\n\n**A document that already exists in this chat is never rebuilt here.** \"Add a\nsection\", \"reword this\", \"extend the doc\" — any request that starts from an\nexisting `.docx` is an EDIT: load `references/edit.md` and stage the document\nby its `attachmentId`. Building a fresh document for an edit request throws\naway everything the user already has.\n\n## The exec call\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"outputs\": [\"report.docx\"],\n \"command\": \"...\"\n}\n```\n\n- `language` — always `\"python\"`.\n- `packages` — `[\"python-docx==1.2.0\"]` on every call. The PyPI package is\n `python-docx` but the import is `docx`; never list `docx` as the package —\n that resolves a different, abandoned library. Pin the version; an unpinned\n install resolves a potentially different library version. This exact version\n ships with the app and installs with no network; any other version has to be\n downloaded, which fails on a device that is offline.\n- `outputs` — `[\"report.docx\"]`. `save(\"report.docx\")` must match the declared\n output name. A file you write but do not declare here is discarded. A `.doc`\n output name is rejected — name it `.docx`.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a\n semicolon is a `SyntaxError`. Its first line is the first line of Python\n that runs: there is no shell and no interpreter to invoke, and no\n installer — packages are declared in `packages`.\n\n## Embedding Images\n\nTwo kinds of image input, told apart by where the file came from:\n\n**Tool-produced images** (`generate_image` output): stage them with the exact\n`attachmentId` from the tool result — never placeholders like `att_image` or\nany id you made up.\n\n**Images the user uploaded** (\"use this photo\"): there is no id to copy — an\nuploaded image never shows one. Stage it with `path` only and **no\n`attachmentId` key**; the first id-less entry is the first image of the user's\nlatest message, the second is its second image, and so on. Id-less entries\nresolve _images only_.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"path\": \"photo.png\" }],\n \"outputs\": [\"report.docx\"],\n \"command\": \"...\"\n}\n```\n\nStaged files land in the working directory under the bare `path` names —\nreference `doc.add_picture(\"photo.png\", …)` by that name only. Paths must be\nunique bare filenames. `attachment … not found in this chat` means you\ninvented an id or the file is not attached: re-copy the exact id from the tool\nresult, or for a document with no image drop `inputs` entirely.\n\nIf the image was staged in `inputs`, embed it in **that** single build with\n`doc.add_picture` — never deliver a document and then rebuild to add the\nimage. Soft-failing (`try`/`except` around the picture) and saving without it\nis a failed turn, not a success.\n\n**Image URLs do not work — never download.** Your Python code has **no\nnetwork access**: `requests`, `urllib`, and `socket` all fail with a network\nerror, and `http_request` returns truncated text, never image bytes. When the\nuser gives an image URL, do not try to fetch it from Python and do not retry\nthrough other tools — that is a dead end. Say the link cannot be downloaded\nand ask the user to attach the image itself, or offer `generate_image` for a\nsimilar visual. Then build the document with the staged attachment as above.\n\n## The Recipe\n\nStart from this. It is a complete, working document — a title, headings,\nparagraphs with bold and italic runs, a bulleted list, and a table — saved\nunder the declared output name. Copy it and change the content; do not\nassemble a document from memory.\n\n**Keep the source multi-line.** A `for`/`if`/`with` after a semicolon is a\n`SyntaxError` — paste the block with real newlines, not `stmt; for x in y: …`.\n\n**Hold content in plain lists of strings, and walk them.** Every list of bullets\nis a flat `[\"…\", \"…\"]`, and every table is a list of row lists. Do not reach for\na dict, a tuple of mixed widths, or a nested comprehension to hold document\ncontent — those are where a `SyntaxError` or a\n`ValueError: too many values to unpack` comes from, and they buy nothing here.\n\n**Keep every underscore in API names.** `add_heading`, `add_paragraph`,\n`add_run`, `add_table`, `add_row`, `add_picture`, `add_page_break` — stripping\nthem to `addheading` / `addparagraph` fails. Copy identifiers exactly as written\nbelow:\n\n```python\nfrom docx import Document\nfrom docx.shared import Inches, Pt, RGBColor # one import line covers sizes, widths, colors\n\ndoc = Document()\n\ndoc.add_heading(\"Quarterly Report\", level=0)\ndoc.add_paragraph(\"Prepared by the finance team.\")\n\ndoc.add_heading(\"Summary\", level=1)\np = doc.add_paragraph(\"Revenue grew \")\nstrong = p.add_run(\"18 percent\")\nstrong.bold = True\np.add_run(\" against a \")\nemphasis = p.add_run(\"flat\")\nemphasis.italic = True\np.add_run(\" cost base.\")\n\ndoc.add_heading(\"Highlights\", level=1)\nfor point in [\n \"New retail partners in two regions\",\n \"Churn down for the third quarter\",\n \"Support backlog cleared\",\n]:\n doc.add_paragraph(point, style=\"List Bullet\")\n\ndoc.add_heading(\"Key Figures\", level=1)\nfigures = [\n [\"Metric\", \"Q3\", \"Q4\"], # first list is the header row\n [\"Revenue\", \"$1.2M\", \"$1.4M\"],\n [\"Costs\", \"$0.9M\", \"$0.9M\"],\n]\ntable = doc.add_table(rows=1, cols=len(figures[0]))\ntable.style = \"Table Grid\"\nfor index, cells in enumerate(figures):\n row = table.rows[0].cells if index == 0 else table.add_row().cells\n for column, value in enumerate(cells):\n row[column].text = value\n\ndoc.save(\"report.docx\") # must match the declared output exactly\nprint(f\"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)\")\n```\n\n## Write a document, not markdown\n\nA `.docx` carries real styles, so the structure is the style — never the\npunctuation. Markdown written into text stays there verbatim and reads as a\ntypo in the finished document:\n\n- **No markdown characters in any string.** `#`, `##`, `-`, `*`, `1.`, `**bold**`\n and backticks all render literally. `add_heading(\"Security\", level=2)` — never\n `add_heading(\"- Security\", level=2)` or `\"## Security\"`. A numbered list is\n `style=\"List Number\"`, which numbers itself; a typed `\"1. \"` prefix double-numbers.\n- **No typed rules or line breaks.** A row of dashes or underscores as a section\n divider is just those characters on the page, and a leading `\"\\n\"` is a blank\n line inside the paragraph. Headings already separate sections.\n- **Every section title is a heading.** A first section called \"Introduction\" or\n \"Overview\" goes through `add_heading(..., level=1)` like every other one; as a\n plain `add_paragraph` it renders as body text and the document looks unstructured.\n- **No blank paragraphs for spacing.** `add_paragraph(\"\")` leaves a visible gap —\n the heading and body styles already carry their own space before and after.\n- **Bold is for a few words, not a sentence.** A fully bold paragraph reads as a\n formatting mistake; bold the term, then continue in a normal run.\n\n## One paragraph, one string\n\n`add_paragraph` takes a single text string, optionally with `style=` — nothing\nelse. Several sentences passed positionally raise\n`TypeError: Document.add_paragraph() takes from 1 to 3 positional arguments but 4\nwere given`. Join them into one string, or open the paragraph with the first\npiece and add the rest as runs:\n\n```python\np = doc.add_paragraph(\"As of 2026, Bitcoin is widely held. \")\np.add_run(\"Adoption keeps growing.\")\n```\n\n**The text you pass to `add_paragraph` is already the paragraph's first run.** A\nrun added afterwards _appends_ — repeating any of those words writes them twice\ninto the document (`\"…finite supplyfinite supply\"`). Each run carries the next\nwords and only those, so give a mixed-format paragraph an empty start and add\nevery piece as its own run:\n\n```python\np = doc.add_paragraph()\np.add_run(\"Digital scarcity \")\ntail = p.add_run(\"and a finite supply\")\ntail.italic = True\n```\n\n## Bold and italic live on runs, never on paragraphs\n\n`paragraph.bold = True` raises no error and changes **nothing** in the file — a\nparagraph has no bold; the assignment lands on the Python object and is silently\ndiscarded on save. Formatting belongs to runs:\n\n```python\np = doc.add_paragraph(\"normal, then \")\nstrong = p.add_run(\"bold\")\nstrong.bold = True\np.add_run(\" and \")\nemphasis = p.add_run(\"italic\")\nemphasis.italic = True\n```\n\nTwo rules make that shape the only one to write:\n\n- **`add_run` takes the text and nothing else.** `p.add_run(\"x\", bold=True)`\n raises `TypeError: Paragraph.add_run() got an unexpected keyword argument\n'bold'` — create the run, then set the attribute.\n- **Never chain an attribute onto the `add_run(...)` call.** Name the run on one\n line and format it on the next, as above. A run that needs no formatting is a\n bare `p.add_run(\"plain text\")` and the line ends there — a trailing `.` left\n over from a half-written chain is `SyntaxError: invalid syntax`.\n- **Runs join with no gap between them.** The next run starts exactly where the\n last one ended, so the separating space belongs inside one of the strings —\n `\"…without intermediaries. \"` then `\"It was invented\"`, never\n `\"…intermediaries.\"` followed by `\"It was invented\"`.\n\n**`add_run` belongs to the paragraph, not to a run.** Keep the paragraph in a\nvariable and call `p.add_run(...)` for every run in it — chaining a second run off\nthe first raises `AttributeError: 'Run' object has no attribute 'add_run'`. A run\nowns `.text`, `.bold`, `.italic` and `.font`, and nothing else: it has no\n`add_run`, no `add_paragraph`, and no `.style`.\n\nA run is also not a string: `p.add_run(\" \") * 2` raises\n`TypeError: unsupported operand type(s) for *: 'Run' and 'int'`. Put any repeated\ntext inside the string itself — and reach for neither, since spacing is the\nstyle's job, not padding you type.\n\nCharacter detail goes through `run.font` — size, color:\n\n```python\nfrom docx.shared import Pt, RGBColor\n\np = doc.add_paragraph()\nrun = p.add_run(\"Key finding\")\nrun.font.size = Pt(14)\nrun.font.color.rgb = RGBColor(0x1A, 0x73, 0xE8) # RGB in all caps\n```\n\n`Pt`, `Inches`, and `RGBColor` all import from `docx.shared` — there is no\n`docx.util` and no `docx.dml.color`; those are python-pptx paths and fail here.\n\n## Styles must exist in the document\n\n`style=\"List Bullet\"` names a style **inside the document**. A missing name\nraises `KeyError: \"no style with name 'List Bullet'\"` at `add_paragraph` time.\n\nA **new** `Document()` ships these styles — safe to use without checking:\n`Title`, `Heading 1` … `Heading 9`, `Normal`, `List Bullet` (+ ` 2`, ` 3`),\n`List Number` (+ ` 2`, ` 3`), `Intense Quote`, and the table style `Table Grid`.\nDo not invent other names for a new document. (An uploaded document carries\nonly its own styles — when editing one, load `references/edit.md` for the\nguard.)\n\n## Headings and lists\n\n- `doc.add_heading(text, level=N)` — level `0` is the document title style,\n `1`–`9` map to `Heading 1`–`Heading 9`. Any other level raises\n `ValueError: level must be in range 0-9`.\n- Bullets: one `add_paragraph(point, style=\"List Bullet\")` per point, over a flat\n list of plain strings. Never pack several points into one paragraph with `\\n` —\n a `\\n` is a soft line break inside the same list item, not a new bullet. A\n bullet that needs a label and a detail is one string (`\"Limited supply — 21\nmillion coins\"`), never a dict entry or a tuple.\n- Numbered lists: `style=\"List Number\"`. Indent a level with `List Bullet 2` /\n `List Number 2`.\n\n## Tables\n\nWrite the whole table as a list of row lists — header first — then let the code\nabove derive everything from it. **Always `rows=1` and `cols=len(rows[0])`**:\n\n```python\nrows = [\n [\"Item\", \"Status\"], # header\n [\"Search\", \"Shipped\"],\n [\"Export\", \"In review\"],\n]\ntable = doc.add_table(rows=1, cols=len(rows[0]))\ntable.style = \"Table Grid\" # borders; omit for invisible grid\nfor index, cells in enumerate(rows):\n row = table.rows[0].cells if index == 0 else table.add_row().cells\n for column, value in enumerate(cells):\n row[column].text = value\n```\n\nThat shape exists because the two hand-written alternatives both fail:\n\n- **`rows=` is a count of blank rows created immediately, not a maximum.**\n `add_table(rows=4, …)` followed by `add_row()` per entry leaves three empty\n rows sitting between the header and the data, plainly visible in the finished\n document. `rows=1` is the header; every other row comes from `add_row()`.\n- **Unpacking a row into fixed names breaks the moment a row is a different\n width.** `for name, q3, q4 in data:` raises\n `ValueError: too many values to unpack (expected 3, got 4)`, and hand-counting\n `cols=` against the data is the same mistake one step earlier. Index the cells\n instead, and take the column count from the header.\n\nAddress cells as `table.cell(row, col)` or `table.rows[r].cells[c]` — they are\nthe same cell. Rows only grow at the bottom: there is no insert-at.\n`table.rows[9]` on a 4-row table raises `IndexError`. Write text with\n`cell.text = \"…\"`; for formatting inside a cell go through `cell.paragraphs[0]`\nand its runs like any other paragraph.\n\n## Images and page breaks\n\n`doc.add_picture(name, width=…)` appends the image in its own paragraph. Pass\nonly one of `width`/`height`; passing both distorts the picture.\n\n```python\nfrom docx.shared import Inches\n\ndoc.add_picture(\"figure1.png\", width=Inches(5.5))\ndoc.add_page_break()\n```\n\n**Do not soft-fail images or imports.** Never wrap `add_picture` or an import in\n`try`/`except` that prints a warning and continues. A missing file must raise so\nyou fix it and rerun — a document saved without the requested image is a failed\nturn, not a success.\n\n## Errors\n\n- `ModuleNotFoundError: No module named 'docx'` means `packages` was missing or\n wrong — add `[\"python-docx==1.2.0\"]` and rerun. Never try to install it, and\n never \"fix\" it by importing `python_docx`; the import stays `docx`.\n- `TypeError: 'Table' object is not subscriptable` — a table was indexed\n directly (`table[0]`). Cells are reached through `table.rows[r].cells[c]` or\n `table.cell(r, c)`; a whole row of cells is `table.add_row().cells`.\n- `KeyError: \"no style with name '…'\"` — the style is not in this document. For\n a new document use only the names listed under Styles.\n- `NameError: name 'RGBColor' is not defined` (or `Pt`, `Inches`) — the import\n line is missing that name. Keep the sample's single\n `from docx.shared import Inches, Pt, RGBColor` rather than importing one at a time.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`add_paragraph`, not `addparagraph`) must stay. Do not switch to\n `python -c` or change the package pin.\n- On an `AttributeError` from python-docx the API name is wrong; on a `TypeError`\n about positional arguments the call passes the wrong number of them — usually\n several strings where one is allowed. Fix either against this file's examples,\n reading the line number in the traceback. Do not retry the same call, and do\n not switch to a shell.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_doc`). For a new document,\n omit `inputs` entirely and rerun. Only stage real ids from prior tool results.\n- Never print the document's bytes or base64 — stdout is capped and the file\n travels through `outputs`. A build call prints exactly one line (e.g. `9\nparagraphs, 1 table(s)`).\n- Never pass an absolute path to `save()`.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the `.docx`, the document is\ndone — the `exec` result carries\n`attachments: [{ attachmentId, fileName, byteLength }]` and the file is already\nattached to the chat for the user to open or save, exactly like a\n`generate_image` result. Stop tool use and reply with a single line: file name\n\n- the count line from stdout. Exactly one successful `exec` per request. If\n the result has `missingOutputs` instead, the file was never written: check the\n `save()` name matches the declared output and rerun once.\n",
|
|
79
|
+
"word/references/edit.md": "# Editing an Existing Word Document (python-docx)\n\n**Stop here if the request changes some of the facts, points, bullets, items,\nor paragraphs** — \"replace the first 10 facts\", \"change fact 3\", \"swap the\nbullets\", \"reword paragraph 7\". That job is `references/replace.md`: call the\n`skill` tool with `name: \"word\"` and `file: \"references/replace.md\"` now, and\ndo not use anything in this file for it. No Python is written for that job.\n\nChange, replace, extend, trim, or rework a `.docx` that is already in this\nchat by running Python through the `exec` tool: stage it as an input, modify\nparagraphs and tables, and save a **new** output such as\n`existing_revised.docx`. Never overwrite the staged input.\n\n## Staging the Document\n\nStage the document as an input **by its `attachmentId`** and open it with\n`Document(\"existing.docx\")`. The id comes from wherever the document entered\nthe chat:\n\n- **Produced earlier in this chat** — the `attachmentId` is in that `exec` result.\n- **Uploaded by the user** — the `[Attached file …]` line on their message names\n it, when the message carries one:\n\n ```\n [Attached file \"report.docx\" (application/vnd.openxmlformats-officedocument.wordprocessingml.document) — attachmentId: 4f9c2ab1]\n ```\n\nCopy the id verbatim — never placeholders like `att_doc`, `att_image`, or any\nid you made up. A `.docx` is **never** staged id-less: an id-less input\nresolves to an uploaded _image_, so it can never reach a document. If no\n`attachmentId` for the document appears anywhere in the chat, say you cannot\nopen that file for editing and ask the user to attach it again — do not invent\nan id, do not stage it id-less, and do not retry. An attachment from an\nearlier turn can be used when its attachment id is available in the\nconversation.\n\n**The file only exists if this same `exec` call stages it.** The working\ndirectory starts empty on every call, so an edit needs an `inputs` entry\nnaming the attachment, and `Document(\"existing.docx\")` must use that entry's\nexact `path`. Opening a name that was never staged raises\n`PackageNotFoundError: Package not found at '…'` — the fix is the missing\n`inputs`, never a different file name.\n\n## The exec call\n\nImages can be staged alongside the document. Tool-produced images\n(`generate_image` output) take the exact `attachmentId` from the tool result;\nan image the user uploaded is staged with `path` only and **no\n`attachmentId` key** — the first id-less entry is the first image of the\nuser's latest message, and so on. Id-less entries resolve _images only_.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [\n {\n \"attachmentId\": \"<id from the exec result or the [Attached file …] line>\",\n \"path\": \"existing.docx\"\n },\n { \"path\": \"photo.png\" }\n ],\n \"outputs\": [\"existing_revised.docx\"],\n \"command\": \"...\"\n}\n```\n\n- `packages` — `[\"python-docx==1.2.0\"]` on every call. The PyPI package is\n `python-docx` but the import is `docx`; never list `docx` as the package —\n that resolves a different, abandoned library. Pin the version; this exact\n version ships with the app and installs with no network; any other version\n has to be downloaded, which fails on a device that is offline.\n- `inputs` — staged files land in the working directory under the bare `path`\n names — reference `Document(\"existing.docx\")` /\n `doc.add_picture(\"photo.png\", …)` by that name only. Paths must be unique\n bare filenames.\n- `outputs` — the new file to deliver; a file you write but do not declare\n here is discarded. Never the staged input's name. **Name it after the\n document you edited**, not after the change: keep the input's stem and add a\n marker — `report.docx` edited is `report_revised.docx`. A fresh name picked\n from the new content (`cats.docx` for an edit of `parrot_facts.docx`) reads\n as a second, unrelated document and hides the fact that an edit happened at\n all.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a\n semicolon is a `SyntaxError`. There is no shell and no installer — packages\n are declared in `packages`.\n\nIf the image was staged in `inputs`, embed it in **that** single build with\n`doc.add_picture(\"photo.png\", width=Inches(5.5))` — never deliver a document\nand then rebuild to add the image. **Do not soft-fail images or imports**:\nnever wrap `add_picture` or an import in `try`/`except` that prints a warning\nand continues — a document saved without the requested image is a failed\nturn, not a success. Pass only one of `width`/`height`; passing both distorts\nthe picture.\n\n**Image URLs do not work — never download.** Your Python code has **no\nnetwork access**: `requests`, `urllib`, and `socket` all fail with a network\nerror, and `http_request` returns truncated text, never image bytes. Say the\nlink cannot be downloaded and ask the user to attach the image itself, or\noffer `generate_image` for a similar visual.\n\n## Editing: Work the Objects, Save a New Name\n\nOne call does the whole edit: open, change, verify the document actually\nchanged, save. Keep the fingerprint lines exactly as written — they are what\nstops an edit that silently matched nothing (or a read that only inspected)\nfrom delivering an unchanged copy of the user's document at `exitCode 0`. The\nfingerprint covers the body **and** the styles part, so a style-only change —\nthe resize recipe below — counts as a change too:\n\n```python\nimport hashlib\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\nfingerprint = hashlib.md5((doc.element.xml + doc.styles.element.xml).encode()).hexdigest()\n\nfor paragraph in doc.paragraphs:\n if paragraph.text == \"Prepared by the finance team.\":\n paragraph.text = \"Prepared by the finance team. Revised after board review.\"\n\ntable = doc.tables[0]\nrow = table.add_row().cells\nrow[0].text = \"Margin\"\nrow[1].text = \"25%\"\nrow[2].text = \"36%\"\n\ndoc.add_heading(\"Appendix\", level=1)\ndoc.add_paragraph(\"Margins recovered as one-off costs rolled out of the base.\")\n\nassert (\n hashlib.md5((doc.element.xml + doc.styles.element.xml).encode()).hexdigest() != fingerprint\n), \"nothing changed — the edit matched nothing or never ran; fix it, never deliver an unchanged copy\"\ndoc.save(\"existing_revised.docx\") # a NEW name — never the staged input\nprint(f\"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)\")\n```\n\n**An assert that fires is a failed turn to diagnose, not a document to\ndeliver**: the usual cause is a paragraph match on text that is not exactly\nthere — print the real `.text` values in the rerun, fix the match, and never\ndelete the assert to get a file out.\n\n**Keep it an `assert`, never a `print` or an `if`.** Two `print` lines showing\nthe old and new hashes let a no-op save and deliver anyway, which is the one\nthing the assert exists to stop. They also invite a second miscoding: taking\nboth hashes together, before the change. Then they match whatever the edit did,\nand the run reports \"nothing changed\" over a document that changed correctly.\nTake the second hash after the last mutation and before `save`, and let the\nassert raise.\n\n**A printed line is never a reason to call `exec` again.** The guard is the\nassert: if it did not fire and the result carries an attachment, the document is\ndelivered and the turn is over, whatever stdout says about it. Re-running to\ncheck saves the same edit under a second name, and the user gets two documents\nfor one request.\n\n`doc.paragraphs` walks only the document body — text inside tables, headers, and\nfooters is **not** in it. Table text is reached through `doc.tables`; match\nparagraphs by their exact `.text` before rewriting them, and remember the\nformatting-loss rule below.\n\nThe `add_heading`/`add_paragraph` pair above appends an **Appendix** because\nthat is what the sample edit asks for. Copy that shape only when the user\ngenuinely wants new content at the end. Substituting content that is already\nin the document — \"change the first five points\", \"rewrite section 2\" — is a\ndifferent job with its own recipe and its own guards: see Replacing Content In\nPlace.\n\nWhen an edit adds substantial new content — new sections, formatted runs,\nbulleted lists, whole tables — the writing rules apply unchanged: load\n`references/create.md` too and copy its shapes (no markdown characters in\nstrings, bold/italic on runs never paragraphs, one string per `add_paragraph`,\ntables built from a header row with `rows=1`).\n\n### Setting `paragraph.text` erases formatting\n\nAssigning `paragraph.text = \"…\"` replaces **all** runs with one plain run: every\nbold, italic, size, and color in that paragraph is gone. Fine for plain\nparagraphs; on a formatted paragraph edit the runs instead, or accept the loss\ndeliberately. This is the top footgun when editing an uploaded document.\n\n### Styles must exist in the document\n\n`style=\"List Bullet\"` names a style **inside the document**. A missing name\nraises `KeyError: \"no style with name 'List Bullet'\"` at `add_paragraph` time.\nAn **uploaded** document carries only its own styles — one written by another\ntool may lack even `List Bullet`. When editing, guard once and fall back:\n\n```python\nnames = [s.name for s in doc.styles]\nbullet = \"List Bullet\" if \"List Bullet\" in names else None\ndoc.add_paragraph(\"point one\", style=bullet) # style=None → Normal\n```\n\n## Replacing Content In Place\n\nThis recipe is for whole *sections* (a heading plus its body). Changing some\nof the facts, points, bullets, or paragraphs is `references/replace.md` —\nnever hand-write a loop for that.\n\n`add_paragraph`, `add_heading`, and `add_picture` **always append at the end of\nthe document.** None of them takes a position. \"Change the first five points\",\n\"rewrite section 2\", \"swap these facts for those\" are all *replacements*, and\nreaching for `add_*` silently turns them into an append: the original content\nstays where it is, the new content lands after the closing line, and the\ndocument comes back longer than it started with both versions in it. That is a\nfailed turn, not a partial success — it is the most common way this skill goes\nwrong.\n\n**A section is a heading plus everything under it, up to the next heading of\nthe same or higher rank** — which may be one paragraph, or six bullets, or a\nwhole subsection, or nothing at all. Never assume it is exactly one paragraph:\nrewriting the heading and the single paragraph after it leaves the rest of the\nold section sitting under its new title, which is the same contradiction an\nappend produces and is just as invisible in the result. Work out where each\nsection ends before changing anything.\n\nRank matters as much as position. `Heading 2` under a `Heading 1` is a\nsubsection, not the next section, so \"replace the first two sections\" on a\ndocument with subheadings must not consume the parent's own subheading as\nsection two — the same rule the removal recipe below follows. `rank()` reads\nthe level off the style name, and only the shallowest rank counts as a section\nstart.\n\n**A heading shallower than every other heading is the document's title, not its\nfirst section.** A document headed `Heading 1` and sectioned `Heading 2` — the\nshape most attached documents have — would otherwise have exactly one\n\"section\": the title, spanning everything under it. Replacing that section\nreplaces the entire document, and nothing about the result says so. The `if`\ndrops such a heading before sections are picked, and the whole-document assert\nrefuses the span even if one is somehow selected.\n\n**One heading is dropped, never a chain of them.** It is an `if`, not a\n`while`: a document is titled once. Stripping repeatedly walks down the\noutline — on a `Heading 1` title over a `Heading 2` phase holding `Heading 3`\nweeks it drops the title, then the phase, and the weeks become the \"sections\",\nso replacing the first two rewrites the weeks and leaves the phase untouched.\nThat is the silent wrong target this section exists to prevent. Stopping after\none leaves the phase as the only section, and asking for a second raises an\nerror that says so.\n\nTake one snapshot of `doc.paragraphs` and index into it. **Every string in\n`NEW` is a placeholder** — the sample fills it with report sections so the\nshape is readable, and you replace all of it with the content this request asks\nfor. Shipping a sample string in the user's document is a failed turn:\n\n```python\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\nparas = doc.paragraphs # one snapshot — index into THIS list\nblocks = list(doc.element.body) # paragraphs AND tables, in document order\n\nNEW = [ # placeholders — you write every string here\n (\"Regional Performance\", \"Revenue grew in every region except EMEA, where the quarter closed flat.\"),\n (\"Cost Base\", \"Headcount costs fell as the contractor pool wound down, and the saving held.\"),\n]\nTARGET = range(len(NEW)) # which sections to replace — here the first len(NEW)\n\ndef rank(paragraph): # \"Heading 2\" -> 2; a bare \"Heading\" is rank 1\n tail = paragraph.style.name.split()[-1]\n return int(tail) if tail.isdigit() else 1\n\nheads = [i for i, p in enumerate(paras) if p.style.name.startswith(\"Heading\")]\nif len(heads) > 1 and all(rank(paras[heads[0]]) < rank(paras[i]) for i in heads[1:]):\n heads = heads[1:] # a lone heading above all the rest is the title\ntop = min((rank(paras[i]) for i in heads), default=1)\nstarts = [i for i in heads if rank(paras[i]) == top] # sections, never their subsections\nends = [next((j for j in heads if j > i and rank(paras[j]) <= top), len(paras)) for i in starts]\n\nassert NEW, \"NEW is empty — write the replacement content before running the edit\"\nassert len(TARGET) == len(NEW), f\"TARGET names {len(TARGET)} sections but NEW has {len(NEW)} items\"\nassert len(starts) > max(TARGET), f\"TARGET reaches section {max(TARGET) + 1}, but the document has {len(starts)}\"\nat = [blocks.index(p._element) for p in paras] # where each paragraph sits among the blocks\nfor k in TARGET:\n assert ends[k] > starts[k] + 1, f\"section {paras[starts[k]].text!r} has no body paragraph to replace\"\n assert (starts[k], ends[k]) != (heads[0], len(paras)), f\"section {paras[starts[k]].text!r} spans the whole document — that is a rewrite, not a section replacement\"\n span = blocks[at[starts[k]] : at[ends[k]] if ends[k] < len(paras) else len(blocks)]\n assert not any(el.tag.endswith(\"}tbl\") for el in span), f\"section {paras[starts[k]].text!r} holds a table — this recipe replaces paragraphs only\"\n\n# measured from the document, before anything changes — never from what the loop below does\nbefore = len(paras)\nold_body = [p.text for k in TARGET for p in paras[starts[k] + 1 : ends[k]]]\ndoomed = [(p.text, p._element) for k in TARGET for p in paras[starts[k] + 2 : ends[k]]]\nexpected = before - len(old_body) + len(NEW) # each replaced section keeps exactly one body paragraph\n\nfor k, (title, body) in zip(TARGET, NEW):\n paras[starts[k]].text = title # the heading keeps its own style\n paras[starts[k] + 1].text = body\n paras[starts[k] + 1].style = doc.styles[\"Normal\"] # the reused paragraph may have been a bullet\n for p in paras[starts[k] + 2 : ends[k]]: # whatever else the section held\n p._element.getparent().remove(p._element)\n\nassert len(doc.paragraphs) == expected, f\"expected {expected} paragraphs, got {len(doc.paragraphs)} — an old section was not fully replaced, or content was appended\"\nfor text, el in doomed:\n assert el.getparent() is None, f\"an old paragraph is still in the document: {text[:40]!r}\"\ndoc.save(\"existing_revised.docx\") # a NEW name — never the staged input\nprint(f\"{len(NEW)} of {len(starts)} sections replaced, {before} -> {len(doc.paragraphs)} paragraphs\")\n```\n\n`TARGET` names the sections to replace, once, and `zip` pairs each new item with\nthe section it overwrites. Replacing a different range is a change to that one\nline — `TARGET = range(2, 5)` for \"sections 3 through 5\", with three items in\n`NEW` to match. Keep it bound in a single place: a range written twice drifts\napart the moment one copy is edited, and every guard below reads `TARGET`\nrather than assuming the range starts at zero.\n\nAssigning `paras[head].text` keeps that paragraph's style, because the style\nlives on the paragraph and not on its runs: a `Heading 2` stays a `Heading 2`.\nOnly the run-level formatting inside it is lost, per the rule above. The body\nparagraph is the opposite case — it is reused, so it arrives carrying whatever\nstyle the old body had, which is why the sample sets it back to `Normal`. Set it\nto something else when the new body should be a bullet or a quote, and guard the\nname as shown under Styles.\n\n**Keep the document's own numbering.** The sample titles carry no `1.`, `2.`\nprefix because the document it edits does not number itself, and a typed prefix\non a `List Number` paragraph double-numbers. When the headings you are\noverwriting *do* carry manual numbers, take each number from the position being\noverwritten so the sequence continues — replacing sections 3 through 5 writes\n`3.`, `4.`, `5.`, never restarting at `1.`\n\n**Every assert, exactly as written — and measured before the loop runs.**\n`old_body` and `expected` come from the document's own structure, never from\nwhat the loop reports about itself. That is the whole point: a loop that\nrewrites only the paragraph after each heading, the mistake this recipe exists\nto prevent, would tally its own work as complete. Derived up front, the numbers\ncontradict it. None of these failures is distinguishable from success by\n`exitCode 0` plus an attachment:\n\n- **`assert NEW`** catches an empty content list. Without it `max(TARGET)`\n raises a bare `ValueError`, and were it not for that the run would save an\n untouched copy of the user's document at `exitCode 0`.\n- **`len(TARGET) == len(NEW)`** catches a target range and a content list that\n drifted apart. `zip` would silently pair only the shorter of the two.\n- **`len(starts) > max(TARGET)`** catches a range reaching past the last\n section. It reads `TARGET`, not `len(NEW)`, because the range need not start\n at zero — a `len(NEW)` check passes on `range(2, 5)` over four sections and\n the run then dies on an `IndexError` that names nothing.\n- **the whole-document assert** refuses a section running from the first\n heading to the last paragraph. That is not a replacement, it is a rewrite:\n every other check passes while the document is emptied down to one heading\n and one paragraph. It anchors on `heads[0]`, not paragraph 0 — a document\n whose only heading sits under a draft notice, a date, or a byline still has\n exactly one section, and anchoring on index 0 would wave it through.\n- **`ends[k] > starts[k] + 1`** catches a section with no body paragraph — a\n heading followed straight by a table, or the last heading in the document.\n There is nothing under it to rewrite. It runs before any mutation, so a bad\n target changes nothing.\n- **the `}tbl` assert** catches a table inside a section being replaced.\n `doc.paragraphs` does not see tables, so the loop below cannot remove one:\n without this the old table survives under the new heading with every other\n check passing. Say the table has to be rebuilt, or target a different section.\n- **the `expected` assert** catches an old section left partly in place *and*\n new content appended, because `expected` is what the paragraph count must be\n once each replaced section holds exactly one body paragraph.\n- **the `getparent() is None` assert** catches an old paragraph the loop was\n supposed to drop but left attached. Compare **elements, not text**: text\n comparison cannot tell a paragraph that survived from an identical one\n standing legitimately elsewhere, and a document that repeats a line — three\n status sections each reading `Nothing to report.` — would fail a correct edit\n with no way to satisfy the assert. Identity has no such collision, and it\n needs no special case for blank paragraphs.\n\nAn assert that fires is a failed turn to diagnose, never a document to deliver.\n\n### When the replacement needs more than one paragraph\n\nThe loop above reuses one paragraph per section and drops the rest. When a\nreplacement needs an **extra** paragraph, insert it before the paragraph that\nshould follow it. `insert_paragraph_before` is the only insert there is, and it\nis a method on the paragraph you want to push down:\n\n```python\nanchor = paras[ends[k]] # the next section's heading\nextra = anchor.insert_paragraph_before(\"A second body paragraph.\", style=\"Normal\")\n```\n\nIt takes the same style names as `add_paragraph` (`\"Heading 2\"`, `\"List\nBullet\"`, `None` for Normal) and returns the new paragraph, so runs can be\nformatted on it. Inserting a whole new section is this call once per paragraph,\neach against the heading it goes above. A section at the very end of the\ndocument has no next heading to anchor to — `ends[k]` is `len(paras)` — so\nappend there with `doc.add_paragraph`, the one case where appending is right.\n\nInserting does not disturb the `paras` snapshot: it is a plain Python list\nholding the paragraphs that already existed, so every index taken before the\ninsert still points at the same paragraph afterwards. Only a fresh\n`doc.paragraphs` shifts.\n\nCount what you insert and fold it into `expected` rather than dropping the\nguard — `expected = before - len(old_body) + len(NEW) + added` — so an\naccidental append is still caught.\n\n## Removing Content\n\npython-docx has **no delete API.** There is no `doc.remove_paragraph` and no\n`paragraph.delete`, and `doc.paragraphs` is rebuilt on every access, so\n`doc.paragraphs.remove(p)` edits a throwaway list and changes nothing in the file.\nRemoving anything means dropping its XML element from the parent — this one line\nis the whole technique, and there is no alternative to it:\n\n```python\np._element.getparent().remove(p._element)\n```\n\nCode that finds the paragraphs and never runs that line — a `for`/`if` that\nmatches the text and falls through, or a comment like\n`# Find and remove paragraphs containing \"Conclusion\"` standing in for the\nremoval — saves a document byte-identical to the input at `exitCode 0`, with an\nattachment that looks like a success. Nothing in the result says the edit was a\nno-op, which is why the sample below asserts the count changed before it saves.\n\nBecause `doc.paragraphs` is a fresh list each time, `for p in doc.paragraphs:`\nwalks a snapshot and removing inside the loop is safe.\n\n**A whole section** — a heading plus everything under it, up to the next heading\nof the same or higher rank — is that line plus a flag. Track the heading's level,\nor a sub-heading inside the section ends the removal early and orphans the\nparagraphs below it:\n\n```python\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\n\nTARGET = \"Conclusion\" # the heading text that opens the section\n\nbefore = len(doc.paragraphs)\ndepth = None # the target heading's level while removing\nfor p in doc.paragraphs:\n style = p.style.name # a style object — compare through .name\n if style.startswith(\"Heading\"):\n tail = style.split()[-1]\n level = int(tail) if tail.isdigit() else 1 # \"Heading 2\" -> 2\n if depth is not None and level <= depth:\n depth = None # a sibling heading closes the section\n if p.text.strip() == TARGET:\n depth = level\n if depth is not None:\n p._element.getparent().remove(p._element)\n\nafter = len(doc.paragraphs)\nassert after < before, f\"removed nothing ({before} -> {after}) — the match never fired\"\ndoc.save(\"existing_revised.docx\") # a NEW name — never the staged input\nprint(f\"{before} -> {after} paragraphs\")\n```\n\nFind the heading through `p.style.name`, never the text alone — a body paragraph\nthat mentions \"Conclusion\" is not the section heading. Removing individual\nparagraphs is the same loop without the flag: match them, and call the removal\nline on each one.\n\n**Assert the count changed, before you save.** Keep the\n`assert after < before` line exactly where the sample puts it — between the loop\nand `doc.save(...)` — and do not soften it to a `print` or an `if`. It is what\nmakes a no-op impossible to deliver: the assert raises, `save` never runs, so no\nfile is written and the result comes back with `missingOutputs` instead of an\nattachment. Without it a removal that never fired still saves the unchanged\ndocument, and the run is indistinguishable from a real edit — `exitCode 0`, an\nattachment, and nothing anywhere saying the document is a copy of the input.\n\nAn assert that fires is a **failed turn to diagnose**, never a result to report.\nIt means the match did not fire: wrong heading text, a heading style the document\ndoes not use, or text living in a table, header, or footer, which\n`doc.paragraphs` never walks. Fix the match and rerun — do not delete the assert\nto get a file out.\n\nThe printed `before -> after` line is then just the reply line (`16 -> 12\nparagraphs`), not the check. Both live inside the build, so this takes no extra\ncall: the assert and the `print` are in the same `exec` that does the removal.\n\nThis assert is also why \"Success = stop\" needs no second call on a destructive\nedit: `exitCode: 0` plus an attachment cannot on its own tell a real edit from\na copy of the input, because a removal that never fired produces both. The\nbuild itself closes that gap — it asserts the count changed before `save`, so\na no-op returns `missingOutputs` rather than a convincing attachment. A\ndelivered document is still never reopened to \"verify\" it; the fix for a\nfailed assert is a corrected build, never an `exec` opened to inspect what was\nalready delivered.\n\n**Table rows** have no delete API either, and take the same idiom on the row's own\nelement. `table.rows` iterates a snapshot just as `doc.paragraphs` does, so\nremoving inside the loop is safe — and the count gets the same assert, because a\nrow matched by its cell text can miss exactly the way a paragraph can:\n\n```python\ntable = doc.tables[0]\n\nbefore = len(table.rows)\nfor row in table.rows:\n if row.cells[0].text == \"Discontinued\":\n row._element.getparent().remove(row._element)\nassert len(table.rows) < before, f\"no row matched ({before} rows unchanged)\"\n```\n\nRemoving a row by position needs no assert — `table.rows[9]` on a 4-row table\nraises `IndexError` rather than quietly doing nothing:\n\n```python\nrow = table.rows[2]\nrow._element.getparent().remove(row._element)\n```\n\nRows only grow at the bottom: there is no insert-at, and no delete either\noutside this idiom. Address cells as `table.cell(row, col)` or\n`table.rows[r].cells[c]` — they are the same cell; for formatting inside a\ncell go through `cell.paragraphs[0]` and its runs like any other paragraph.\n\n**Table columns cannot be removed.** A column is not one element — it is an entry\nin the table grid plus one cell in every row — and a horizontally merged cell is a\nsingle `<w:tc>` shared across two grid positions, so removing \"the second cell\" of\nevery row deletes that merged cell whole and leaves its row a column short. The\ndocument opens visibly ragged and nothing raises. Rebuild the table with the\ncolumns you want instead, or say the column has to be dropped in Word.\n\n## Reading What Is Already in the Document\n\nA document exposes exactly two collections — `doc.paragraphs` and `doc.tables`.\nEverything else is derived by filtering them; there is no `doc.headings`, no\n`doc.sections_by_title`, no `doc.text`. A `Paragraph` has `.text`, `.style` and\n`.runs`, and no `.paragraphs` of its own.\n\n**`paragraph.style` is a style object, not a string** — compare through\n`.name`, or you get\n`AttributeError: 'ParagraphStyle' object has no attribute 'startswith'`:\n\n```python\nheadings = [p for p in doc.paragraphs if p.style.name.startswith(\"Heading\")]\nbody = [p for p in doc.paragraphs if p.style.name == \"Normal\"]\n```\n\nMost edits need no inspection at all — go straight to the change. When a look\nis genuinely needed first (an exact `.text` to match, a style name), that call\nonly prints: **an inspection never saves and declares no `outputs`** — a save\nwithout the change delivers a stale copy of the user's document. The new file\ncomes only from the one call that changes it. Reading for the _user_ — a\nsummary or an answer delivered as chat text — is its own flow with its own\ncall shape: load `references/read.md`.\n\n## Resizing Text: Set the Styles, Never Scale `run.font.size`\n\n`run.font.size` is `None` whenever the size comes from the paragraph's style,\nwhich is the normal case for a document you did not hand-size. **`None` does not\nmean zero.** Reading it as a number and scaling it writes a 0pt font, and 0pt\ntext is invisible in Word and Pages — the document opens looking blank, with no\nerror anywhere to tell you why:\n\n```python\nsize = run.font.size.pt if run.font.size else 0\nrun.font.size = Pt(size * 1.5) # WRONG: 0 * 1.5 = 0pt, invisible text\n```\n\n\"Make the font bigger\" is a change to the **styles**, because every run without\nits own size inherits from them. Set absolute point sizes on the styles the\ndocument actually uses, and the whole document — body, tables, headers — follows\nin four lines:\n\n```python\nfrom docx import Document\nfrom docx.shared import Pt\n\ndoc = Document(\"existing.docx\")\n\ndoc.styles[\"Normal\"].font.size = Pt(14) # body text; 11pt is the default\ndoc.styles[\"List Bullet\"].font.size = Pt(14)\ndoc.styles[\"Heading 1\"].font.size = Pt(20)\ndoc.styles[\"Title\"].font.size = Pt(32)\n\ndoc.save(\"larger.docx\")\nprint(f\"{len(doc.paragraphs)} paragraphs resized\")\n```\n\n**Keep the hierarchy.** Raise every style you touch, not one size for all of\nthem — a title and a heading set to the body size read as unstyled text. Body\naround 14pt pairs with roughly 20pt headings and a 32pt title, and the same\nratios hold at any size the user asks for.\n\nOnly touch a style the document has — guard with the `doc.styles` check above\nwhen unsure. If a specific run really must be sized on its own, assign an\nabsolute `Pt(...)` value; never one derived from the size you read back.\n\n## Errors\n\n- `PackageNotFoundError: Package not found at '…'` — the document was never\n staged, or an id-less entry staged an image under a `.docx` path. Add\n `inputs: [{ \"attachmentId\": \"<id from the exec result or the [Attached file …]\nline>\", \"path\": \"existing.docx\" }]` and open that exact path. If no id is\n available, ask the user to attach the file again rather than guessing a name.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_doc`). Re-copy the exact id\n from the `exec` result or the `[Attached file …]` line that names the file;\n if no id appears anywhere in the chat, ask the user to re-attach.\n- `ModuleNotFoundError: No module named 'docx'` means `packages` was missing or\n wrong — add `[\"python-docx==1.2.0\"]` and rerun. Never try to install it, and\n never \"fix\" it by importing `python_docx`; the import stays `docx`.\n- `KeyError: \"no style with name '…'\"` — the style is not in this document.\n Check `doc.styles` and fall back as shown under Styles.\n- `NameError: name 'Pt' is not defined` (or `Inches`, `RGBColor`) — the import\n line is missing that name; they all import from `docx.shared`.\n- `AssertionError: removed nothing (16 -> 16)` means the removal matched nothing:\n either the loop never fired or it never called\n `p._element.getparent().remove(p._element)`; python-docx has no delete method to\n reach for instead. Diagnose the match and rerun — never delete the assert to get\n a file out, since the file it would produce is a copy of the input.\n- `AssertionError: expected 7 paragraphs, got 9` means the document did not end\n up the shape a replacement makes. Two causes: the old sections were only\n partly replaced — the loop rewrote the paragraph after each heading and left\n the rest of the section standing — or new content was appended with\n `add_paragraph`/`add_heading`, which only ever append. `expected` is derived\n from the section bounds before anything changes, so it is right and the\n document is wrong: replace each section through to the next heading.\n- `AssertionError: an old paragraph is still in the document: '…'` means the\n loop was adapted and no longer drops everything past the paragraph it reuses.\n Every paragraph from `starts[k] + 2` to `ends[k]` has to go; the removal idiom\n below is the only thing that removes one. This compares elements, so it never\n fires because the document happens to repeat a line elsewhere.\n- `AssertionError: section '…' has no body paragraph to replace` means that\n heading is followed straight by a table, or is the last paragraph in the\n document. There is nothing under it to rewrite: target a different section, or\n insert the body with `insert_paragraph_before` before adding to it.\n- `IndexError: list index out of range` while walking sections means an index\n ran past the end of `starts` or of `paras`: a `TARGET` reaching past the last\n section, or `paras[i + 1]` on a document whose final paragraph is a heading.\n Guard the range with `len(starts) > max(TARGET)` — not against `len(NEW)`,\n which says nothing when the range does not start at zero — and take section\n ends from the next heading of the same or higher rank, with `len(paras)`\n closing the last one.\n- `AssertionError: section '…' holds a table` means the section being replaced\n contains a table. `doc.paragraphs` never sees tables, so the loop cannot\n remove one and it would survive under the new heading. Rebuild the table\n explicitly, or tell the user that section has to be replaced by hand.\n- `AssertionError: TARGET names 2 sections but NEW has 3 items` means the range\n and the content list drifted apart. Fix whichever is wrong; do not let `zip`\n quietly use the shorter.\n- `AssertionError: TARGET reaches section 2, but the document has 1` on a\n document that plainly has several usually means its sections are `Heading 2`\n under a `Heading 1` title. The title is dropped before sections are picked,\n so check `rank()` is reading the style names this document actually uses —\n print `[p.style.name for p in doc.paragraphs]` — rather than lowering\n `TARGET` until the assert passes. Section 1 of a title-only document is the\n whole document.\n- `AssertionError: section '…' spans the whole document` means the heading\n selected covers every paragraph, so replacing it would empty the document.\n It is a title being treated as a section, or a request to rewrite rather than\n edit — build a new document with `references/create.md` if that is what the\n user wants.\n- `AttributeError: 'Document' object has no attribute 'insert_paragraph'` means\n the code guessed an insert API on the document. There is none. The only insert\n is `paragraph.insert_paragraph_before(text, style)`, on the paragraph the new\n one goes above.\n- `TypeError: Document.add_paragraph() takes from 1 to 3 positional arguments\n but 4 were given` means a position was passed to `add_paragraph`. It has no\n position parameter and always appends; use `insert_paragraph_before`.\n- Identical old and new fingerprints on a run that saved anyway means the\n assert was softened into `print` lines and both hashes were taken before the\n change. It is not evidence the edit failed, and it is not grounds for another\n `exec`: restore the assert and take the second hash after the mutation.\n- A delivered document identical to the one you opened means an edit ran\n without the fingerprint assert — an edit that matched nothing, or an\n inspection that saved. Add the assert before `save` and rerun the actual\n change.\n- `AssertionError: nothing changed — the edit matched nothing or never ran`\n means exactly that: the paragraph match found no text, or no mutation\n happened before `save`. Print the real `.text` values, fix the match, rerun\n — never remove the assert.\n- `TypeError: 'Table' object is not subscriptable` — a table was indexed\n directly (`table[0]`). Cells are reached through `table.rows[r].cells[c]` or\n `table.cell(r, c)`; a whole row of cells is `table.add_row().cells`.\n- `AttributeError: 'Document' object has no attribute 'remove_paragraph'` (or\n `'Paragraph' object has no attribute 'delete'`) means the code guessed a delete\n API. There is none; drop the XML element instead.\n- A resize that \"worked\" but left the document blank means a 0pt font: something\n scaled `run.font.size` while it was `None`. Set absolute sizes on the styles\n instead — see Resizing Text.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`add_paragraph`, not `addparagraph`) must stay. Do not switch to\n `python -c` or change the package pin.\n- On an `AttributeError` from python-docx the API name is wrong; on a `TypeError`\n about positional arguments the call passes the wrong number of them — usually\n several strings where one is allowed. Fix either against this file's examples,\n reading the line number in the traceback. Do not retry the same call, and do\n not switch to a shell.\n- If the result has `missingOutputs`, the file was never written. Read stderr\n first: an `AssertionError` there means a guard stopped the save on purpose\n and its message names what to fix — rerunning the same code fails the same\n way. Only when stderr is clean is this a naming problem: check the `save()`\n name matches the declared output and rerun once.\n- Never print the document's bytes or base64 — stdout is capped and the file\n travels through `outputs`. A build call prints exactly one line (e.g. `9\nparagraphs, 1 table(s)`).\n- Never pass an absolute path to `save()`.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the `.docx`, the edit is done —\nthe `exec` result carries\n`attachments: [{ attachmentId, fileName, byteLength }]` and the file is already\nattached to the chat for the user to open or save. Stop tool use and reply\nwith a single line: file name + the count line from stdout. Exactly one\nsuccessful `exec` per request; never reopen a delivered document to \"verify\"\nit.\n",
|
|
80
|
+
"word/references/read.md": "# Reading a Word Document to Answer in Chat\n\nWhen the user asks what an attached `.docx` _says_ — a summary, a question\nanswered, specific content pulled out — the deliverable is your reply in the\nchat, not a file. This is a **read request**: exactly one `exec` call, staging\nthe document in `inputs` and declaring **no `outputs`**, whose whole job is to\nprint the document's text so you can read it in the result.\n\n## Staging the Document\n\nStage the document **by its `attachmentId`**. The id comes from wherever the\ndocument entered the chat:\n\n- **Produced earlier in this chat** — the `attachmentId` is in that `exec` result.\n- **Uploaded by the user** — the `[Attached file …]` line on their message names\n it, when the message carries one:\n\n ```\n [Attached file \"report.docx\" (application/vnd.openxmlformats-officedocument.wordprocessingml.document) — attachmentId: 4f9c2ab1]\n ```\n\nCopy the id verbatim — never invent one, never stage a document id-less: an\nid-less input resolves to an uploaded _image_, so it can never reach a\ndocument. If no `attachmentId` for the document appears anywhere in the chat,\nsay you cannot open that file and ask the user to attach it again — do not\nretry. An attachment from an earlier turn can be used when its attachment id\nis available in the conversation.\n\n## The exec call\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"attachmentId\": \"<id from the [Attached file …] line>\", \"path\": \"existing.docx\" }],\n \"maxOutputChars\": 24000,\n \"command\": \"...\"\n}\n```\n\n- `packages` — `[\"python-docx==1.2.0\"]` on every call. The PyPI package is\n `python-docx` but the import is `docx`; never list `docx` as the package —\n that resolves a different, abandoned library. This exact version ships with\n the app and installs with no network.\n- `inputs` — the staged document lands in the working directory under the bare\n `path` name; open `Document(\"existing.docx\")` by that name only. The working\n directory starts empty on every call.\n- No `outputs` — a read builds nothing.\n- `maxOutputChars` — stdout cap in characters (default 8192, max 65536). Set\n it only on a read call, where the document text must fit in one result —\n keep the sample's 24000. A build call prints one line and never needs it.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a\n semicolon is a `SyntaxError`.\n\n## The Read Program\n\nThe read program prints every paragraph under its style name — the style names\nare the document's structure — then every table:\n\n```python\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\nfor p in doc.paragraphs:\n text = p.text.replace(\"\\n\", \" \").strip()\n if text:\n print(f\"[{p.style.name}] {text}\")\nfor i, t in enumerate(doc.tables):\n print(f\"[Table {i + 1}]\")\n for row in t.rows:\n print(\" | \".join(c.text.replace(\"\\n\", \" \") for c in row.cells))\n```\n\nThe `replace` calls are load-bearing: a multi-paragraph cell and a soft line\nbreak both embed `\"\\n\"` in `.text`, and an embedded newline would split one\ntable row — or one paragraph — across two printed lines. Flattened, every line\nstarts with its `[...]` marker and every table row is exactly one line.\n\nTables print after the body text — python-docx does not expose their position\nbetween paragraphs. When that order matters to the answer, say the tables are\nlisted separately. Note `p.style.name` in the sample: `paragraph.style` is a\nstyle object, not a string — go through `.name` to print or compare it.\n\n**You cannot summarize in the call that reads.** The words in `command` are\nfixed before the program runs, so one call cannot inform itself: any summary\nwritten into it was written blind — recalled or invented, not read. Python only\n_transports_ the text; the summarizing happens in your reply, after the result\ncomes back.\n\n## Answering\n\n**A successful read ends tool use.** When the result prints the document, reply\nwith the summary or the answer as chat text. **Scale the reply to the\ndocument**: a summary is much shorter than what it summarizes — a page or two\nof source earns three to five sentences, and only a long document earns\nsections. Restating the document near its full length is not a summary. Do\n**not**:\n\n- call `exec` again to \"re-check\", \"read more\", or read the same document a\n second time;\n- build a summary `.docx` the user never asked for — an unrequested file is a\n failed turn, not a bonus.\n\nIf stdout ends with `… [truncated]`, the document is longer than the cap:\nanswer from what came back and say the answer covers the document up to that\npoint. Do not rerun the read — it prints the same beginning again.\n\nIf the user asks for the summary **as a file**, that is a read followed by a\nbuild: the read call above first, then one build call that writes the new\ndocument from the text you actually read (load `references/create.md` for the\nbuild). The read still declares no `outputs`, delivers nothing, and does not\ncount against the one successful build `exec` per document request — but it\nbelongs before the build, never after it.\n\n## Errors\n\n- `PackageNotFoundError: Package not found at '…'` — the document was never\n staged, or an id-less entry staged an image under a `.docx` path. Add\n `inputs: [{ \"attachmentId\": \"<id from the exec result or the [Attached file …]\nline>\", \"path\": \"existing.docx\" }]` and open that exact path. If no id is\n available, ask the user to attach the file again rather than guessing a name.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_doc`). Only stage real ids\n from prior tool results or `[Attached file …]` lines; if none exists, ask the\n user to re-attach.\n- `ModuleNotFoundError: No module named 'docx'` means `packages` was missing or\n wrong — add `[\"python-docx==1.2.0\"]` and rerun. Never try to install it, and\n never \"fix\" it by importing `python_docx`; the import stays `docx`.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Do not\n switch to `python -c` or change the package pin.\n- A read result ending in `… [truncated]` means the document outgrew the cap:\n answer from what came back and say the answer covers the document up to that\n point. Do not rerun the read — it prints the same beginning again.\n\n## Finish\n\nWhen the read result prints the document, stop tool use and answer the user in\nthe chat. Exactly one read `exec` per request; only a read call prints\ndocument text.\n",
|
|
81
|
+
"word/references/replace.md": "# Replacing Paragraphs by Position (bundled scripts)\n\n\"Replace the first 10 facts\", \"reword point 3\", \"swap these bullets for those\",\n\"change paragraph 7\": two `exec` calls, both running a script bundled with this\nskill. **Write no Python.** Never put source in `command` for this job — pass\n`skill`, `script`, and `scriptArgs` exactly as shown, with `inputs` staging the\ndocument by its `attachmentId` (from the earlier `exec` result or the\n`[Attached file …]` line — copy it verbatim, never invent one).\n\n## Step 1 — list the paragraphs (no `outputs`)\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.docx\" }],\n \"skill\": \"word\",\n \"script\": \"scripts/list_paragraphs.py\",\n \"scriptArgs\": [\"existing.docx\"]\n}\n```\n\nIt prints one line per paragraph — `index`, style, text — then a count line.\nPick the indexes to replace from that list:\n\n- Only body paragraphs (`Normal`, `List Bullet`, `List Number`) are facts,\n points, or bullets. `Title`, `Heading N`, and an intro sentence are never\n counted as one.\n- \"The first 10 facts\" = the first 10 body-paragraph indexes after the heading\n that introduces them — not indexes 0–9.\n- Fewer facts in the document than asked for: replace the ones that exist and\n say so in the reply.\n\n## Step 2 — replace exactly those paragraphs (one `outputs` entry)\n\n`scriptArgs` is: input name, output name, then `index, new text` pairs — one\npair per replaced paragraph, as many pairs as facts requested. The output name\nkeeps the input's stem plus `_revised`.\n\nCount the pairs before sending: \"the first 10 facts\" is 10 pairs — 20 strings\nafter the two file names, 10 different sentences, the last index being\nstart + 9.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.docx\" }],\n \"outputs\": [\"existing_revised.docx\"],\n \"skill\": \"word\",\n \"script\": \"scripts/replace_paragraphs.py\",\n \"scriptArgs\": [\n \"existing.docx\", \"existing_revised.docx\",\n \"3\", \"Dogs have about 1,700 taste buds.\",\n \"4\", \"A dog's nose print is unique, like a fingerprint.\"\n ]\n}\n```\n\nEach new text is one complete plain sentence, no markdown, each different. The\nscript keeps each paragraph's paragraph style, refuses a heading index,\nrefuses text that already reads the same, and prints\n`K of N paragraphs replaced`.\n\n## Finish\n\n`exitCode 0` plus an attachment = done. Reply with one line: the file name and\nthe printed count line. Do not call `exec` again for this request.\n\n## Errors\n\nThe script stops with a message that names the fix; correct the arguments and\nrerun the **same script** — never switch to writing Python.\n\n- `usage: replace_paragraphs.py …` — the pairs are incomplete: after the two\n file names, arguments alternate `index`, `text`.\n- `index N is the heading '…'` — that paragraph is a heading, not a fact. Pick\n body indexes from the Step 1 list.\n- `index N is outside the document's M paragraphs` — re-read the Step 1 list;\n indexes run from 0 to M-1.\n- `index N already reads exactly that` — the new text equals the old one; write\n a different sentence.\n- `output … must be a new name` — the output name equals the input's; use\n `existing_revised.docx`.\n- `PackageNotFoundError` / `attachment … not found` — `inputs` is missing or\n carries an invented id; stage the document by its real `attachmentId`.\n",
|
|
82
|
+
"word/scripts/list_paragraphs.py": "\"\"\"List every paragraph in a Word document, headings included.\n\nUsage: list_paragraphs.py <input.docx>\n\"\"\"\n\nimport sys\n\nfrom docx import Document\n\nif len(sys.argv) != 2:\n sys.exit(f\"usage: list_paragraphs.py <input.docx> — got {sys.argv[1:]}\")\n\nsource = sys.argv[1]\ndoc = Document(source)\n\nfor index, paragraph in enumerate(doc.paragraphs):\n text = paragraph.text.replace(\"\\n\", \" \")\n print(f\"{index}\\t{paragraph.style.name}\\t{text}\")\nprint(f\"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)\")\n",
|
|
83
|
+
"word/scripts/replace_paragraphs.py": "\"\"\"Replace selected body paragraphs in a Word document.\n\nUsage: replace_paragraphs.py <input.docx> <output.docx> <index> <new text> [<index> <new text> ...]\n\"\"\"\n\nimport sys\n\nfrom docx import Document\n\nusage = \"usage: replace_paragraphs.py <input.docx> <output.docx> <index> <new text> [<index> <new text> ...]\"\nif len(sys.argv) < 5 or (len(sys.argv) - 3) % 2 != 0:\n sys.exit(f\"{usage} — got {sys.argv[1:]}\")\n\nsource, target = sys.argv[1], sys.argv[2]\nif source == target:\n sys.exit(f\"output {target} must be a new name, not the staged input\")\n\ndoc = Document(source)\nparagraphs = doc.paragraphs\nreplacements = []\nseen = set()\n\nfor position in range(3, len(sys.argv), 2):\n index_raw, new_text = sys.argv[position], sys.argv[position + 1]\n try:\n index = int(index_raw)\n except ValueError:\n sys.exit(f\"index must be an integer, got: {index_raw}\")\n if index < 0 or index >= len(paragraphs):\n sys.exit(f\"index {index} is outside the document's {len(paragraphs)} paragraphs\")\n if index in seen:\n sys.exit(f\"index {index} was provided more than once\")\n if not new_text:\n sys.exit(f\"new text for index {index} must not be empty\")\n\n paragraph = paragraphs[index]\n style_name = paragraph.style.name\n if style_name.startswith(\"Heading\") or style_name == \"Title\":\n sys.exit(\n f\"index {index} is the heading {paragraph.text!r} — headings are not facts/points; \"\n \"pick body paragraph indexes from list_paragraphs.py\"\n )\n if new_text == paragraph.text:\n sys.exit(f\"index {index} already reads exactly that\")\n\n seen.add(index)\n replacements.append((index, new_text))\n\nbefore = len(paragraphs)\nfor index, new_text in replacements:\n paragraphs[index].text = new_text\n\nassert len(doc.paragraphs) == before\nfor index, new_text in replacements:\n assert doc.paragraphs[index].text == new_text\n\ndoc.save(target)\nprint(f\"{len(replacements)} of {before} paragraphs replaced\")\n"
|
|
84
|
+
}
|
package/hash.d.ts
ADDED
package/hash.js
ADDED