From b823fee350bbaed62e983f4a981decfa8d41febc Mon Sep 17 00:00:00 2001 From: Stefan Pandekh Fumani <31704651+Pandekh@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:01:37 +0200 Subject: [PATCH 1/3] feat: add Google Antigravity plugin Antigravity's plugin.json schema allows only $schema, name and description (additionalProperties: false), so it cannot share the root Agent Plugins manifest. The plugin lives in antigravity/ with an mcp_config.json that uses serverUrl (Antigravity rejects url/httpUrl) and relies on dynamic client registration for OAuth. antigravity/skills mirrors ./skills via scripts/build-antigravity-plugin.mjs; the nightly skills sync regenerates it and a PR check fails on drift. The Claude SessionStart hook is not ported: Antigravity hooks only fire around tool calls. Co-Authored-By: Claude Opus 5.5 --- .../workflows/check-antigravity-plugin.yml | 25 ++ .github/workflows/sync-skills.yml | 6 +- README.md | 15 + antigravity/mcp_config.json | 7 + antigravity/plugin.json | 5 + .../skills/pandadoc-approval-send/SKILL.md | 142 +++++++++ .../pandadoc-contract-register/SKILL.md | 161 ++++++++++ .../pandadoc-engagement-tracker/SKILL.md | 143 +++++++++ .../skills/pandadoc-followup-list/SKILL.md | 151 +++++++++ .../skills/pandadoc-invoice-handoff/SKILL.md | 191 +++++++++++ .../skills/pandadoc-nda-fast-send/SKILL.md | 205 ++++++++++++ .../pandadoc-onboarding-agreement/SKILL.md | 262 +++++++++++++++ .../skills/pandadoc-quote-builder/SKILL.md | 298 ++++++++++++++++++ .../skills/pandadoc-renewal-radar/SKILL.md | 154 +++++++++ .../skills/pandadoc-signing-velocity/SKILL.md | 139 ++++++++ scripts/build-antigravity-plugin.mjs | 105 ++++++ scripts/package-plugin.mjs | 39 ++- 17 files changed, 2037 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/check-antigravity-plugin.yml create mode 100644 antigravity/mcp_config.json create mode 100644 antigravity/plugin.json create mode 100644 antigravity/skills/pandadoc-approval-send/SKILL.md create mode 100644 antigravity/skills/pandadoc-contract-register/SKILL.md create mode 100644 antigravity/skills/pandadoc-engagement-tracker/SKILL.md create mode 100644 antigravity/skills/pandadoc-followup-list/SKILL.md create mode 100644 antigravity/skills/pandadoc-invoice-handoff/SKILL.md create mode 100644 antigravity/skills/pandadoc-nda-fast-send/SKILL.md create mode 100644 antigravity/skills/pandadoc-onboarding-agreement/SKILL.md create mode 100644 antigravity/skills/pandadoc-quote-builder/SKILL.md create mode 100644 antigravity/skills/pandadoc-renewal-radar/SKILL.md create mode 100644 antigravity/skills/pandadoc-signing-velocity/SKILL.md create mode 100644 scripts/build-antigravity-plugin.mjs diff --git a/.github/workflows/check-antigravity-plugin.yml b/.github/workflows/check-antigravity-plugin.yml new file mode 100644 index 0000000..dd59c39 --- /dev/null +++ b/.github/workflows/check-antigravity-plugin.yml @@ -0,0 +1,25 @@ +name: Check Antigravity plugin + +# antigravity/skills is a copy of ./skills (see scripts/build-antigravity-plugin.mjs). Fails a PR +# that changes one without the other, or that breaks the Antigravity manifest schema. + +on: + pull_request: + paths: + - "skills/**" + - "antigravity/**" + - "scripts/build-antigravity-plugin.mjs" + - "scripts/lib/**" + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "lts/*" + - run: node scripts/build-antigravity-plugin.mjs --check diff --git a/.github/workflows/sync-skills.yml b/.github/workflows/sync-skills.yml index e9c7462..f39bddd 100644 --- a/.github/workflows/sync-skills.yml +++ b/.github/workflows/sync-skills.yml @@ -64,6 +64,10 @@ jobs: SYNC_NOTES_FILE: ${{ runner.temp }}/notes.md run: node scripts/sync-skills.mjs + - name: Mirror skills into the Antigravity plugin + if: steps.sync.outputs.changed == 'true' + run: node scripts/build-antigravity-plugin.mjs + - name: Validate plugin if: steps.sync.outputs.changed == 'true' run: npx --yes @anthropic-ai/claude-code@latest plugin validate . --strict @@ -99,7 +103,7 @@ jobs: { echo "Automated skills sync from \`pandadoc-studio/agentic-hub-skills\` (\`${SUMMARY}\`), bumping the plugin to **v${VERSION}**." echo - echo "Skills are vendored verbatim — do not hand-edit \`skills/\`. Review and merge to publish." + echo "Skills are vendored verbatim — do not hand-edit \`skills/\` or \`antigravity/skills/\`. Review and merge to publish." echo cat "${NOTES_FILE}" 2>/dev/null || true } > "${RUNNER_TEMP}/pr-body.md" diff --git a/README.md b/README.md index 33b5c6b..895125f 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ PandaDoc hosts a remote MCP server at [https://mcp.pandadoc.com/v1/mcp](https:// - [VS Code](#vs-code) - [Gemini (powered by Gemini CLI)](#gemini-powered-by-gemini-cli) - [PandaDoc Claude Plugin](#pandadoc-claude-plugin) +- [PandaDoc Antigravity Plugin](#pandadoc-antigravity-plugin) - [Support and Privacy](#support-and-privacy) - [License](#license) @@ -253,6 +254,20 @@ The plugin works in **Claude Code** — the terminal CLI, the **Code** tab in th The skills are now available — type `/` to browse them, or just describe your task (e.g. _"send this NDA for signature"_). To pull newer skill versions later, run `/plugin marketplace update`. +## PandaDoc Antigravity Plugin + +The same skills and connector are packaged as a [Google Antigravity plugin](https://antigravity.google/docs/plugins) in [`antigravity/`](antigravity/). Antigravity's manifest schema differs from the one at the repo root, so the plugin lives in its own directory. + +To install it with the Antigravity CLI: + +1. Clone this repo. +2. Run `agy plugin install ./mcp-server-guide/antigravity`. +3. Open Antigravity's **Customizations** panel, click **Authenticate** on the PandaDoc MCP server, and authorize access to your account. Paste the authorization code back into the panel when prompted. + +The skills then appear as slash commands such as `/pandadoc-nda-fast-send`, or Antigravity picks them up when you describe a task. + +`antigravity/skills/` is a copy of `skills/`, regenerated by `node scripts/build-antigravity-plugin.mjs`. Do not edit it by hand. + ## Support and Privacy - For help, visit the [PandaDoc Help Center](https://support.pandadoc.com/en/). diff --git a/antigravity/mcp_config.json b/antigravity/mcp_config.json new file mode 100644 index 0000000..6f8d593 --- /dev/null +++ b/antigravity/mcp_config.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "pandadoc": { + "serverUrl": "https://mcp.pandadoc.com/v1/mcp" + } + } +} diff --git a/antigravity/plugin.json b/antigravity/plugin.json new file mode 100644 index 0000000..75b9f09 --- /dev/null +++ b/antigravity/plugin.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://antigravity.google/schemas/v1/plugin.json", + "name": "pandadoc", + "description": "Plugin that includes the PandaDoc MCP server and Skills for common workflows" +} diff --git a/antigravity/skills/pandadoc-approval-send/SKILL.md b/antigravity/skills/pandadoc-approval-send/SKILL.md new file mode 100644 index 0000000..26eef76 --- /dev/null +++ b/antigravity/skills/pandadoc-approval-send/SKILL.md @@ -0,0 +1,142 @@ +--- +name: pandadoc-approval-send +category: bizops +roles: [sales, bizops, ops] +complexity: advanced +prerequisites: [PandaDoc] +description: Create a document from a template and send it through its internal approval chain — read the approval steps, pick approvers for each, then send. Use when a document needs internal sign-off before it reaches the counterparty. +tags: [pandadoc, approvals, workflow, e-signature, bizops] +version: "0.1.0" +--- + +# PandaDoc Approval-Routed Send + +Send a document through internal approval instead of straight to the counterparty. + +**This skill is unverified.** The account it was written against has no approval workflow configured, so +every step below is derived from the tool schemas rather than observed behaviour. Treat the details as +provisional and correct them on first real use. + +--- + +## Prerequisites + +The PandaDoc MCP Server must be connected. Required tools: `templates_list`, `templates_details_get`, +`documents_create`, `documents_status_get`, `documents_details_get`, `documents_send`. Useful: +`documents_update` to fix an unassigned signature field before retrying a send. If they are missing, +say so and point at . + +An **approval workflow must be configured on the template or document** in PandaDoc. This skill cannot +create one. + +--- + +## How approvals appear in the API + +- `documents_details_get` returns **`approval_execution`**. On every document in the reference account this + was `null`, meaning no approval workflow applies. +- `documents_send` accepts **`selected_approvers`**, shaped as: + + ```json + { "steps": [ { "id": "", + "group": { "id": "", "type": "", + "assignees": [ { "user": "", "is_selected": true } ] } } ] } + ``` + + The tool description says to copy `steps` from the `approval_execution` section of document details and + set `is_selected: true` for the desired approver. +- `Waiting for Approval` and `Approved` are **system-set** statuses — `documents_status_change` cannot set + them. Only `Completed`, `Paid`, `Expired`, `Declined` can be set manually. + +--- + +## Workflow + +### Step 1: Create the document + +Find and inspect the template as normal — `templates_list` (remember `q` is fuzzy, so confirm the match), +then `templates_details_get` for `roles[]` and `tokens[]`. Create with `documents_create`, using the exact +role names, and `delivery_methods: {email: true, sms: false}`. + +Poll `documents_status_get` until `Draft`. + +### Step 2: Read the approval chain + +`documents_details_get` and look at `approval_execution`. + +**If it is `null`, there is no approval workflow on this document.** Say so plainly and ask whether to send +it directly. Do not pretend to route something that has no chain — and do not fabricate a `selected_approvers` +payload, which would either error or be ignored. + +If it is populated, extract the steps in order. For each step, list the group and the assignees available. + +### Step 3: Choose approvers + +Present the chain to the user before sending — step by step, with the candidates for each: + +> This document has 2 approval steps. Step 1: Finance — Anna or Piotr. Step 2: Legal — Katarzyna. Who should +> approve each? + +Only fill `is_selected: true` for people the user actually named. Picking a default approver silently is not +acceptable: an approval chain exists precisely so a named human signs off, and choosing on their behalf +defeats the control. + +If a step has exactly one candidate, you may select it, but say that you did. + +### Step 4: Send through the chain + +`documents_send` with `selected_approvers` built from the steps you read, plus `subject` and `message`. + +- `409 The document isn't ready for a status transition yet` — retry once. Known race even after `Draft`. +- `400 Not all signature, initials fields were assigned` — a signing role has no recipient; fix with + `documents_update` before retrying. + +After sending, poll `documents_status_get`. Expect `Waiting for Approval` rather than `Sent` while the chain +runs. Report the status you actually observed rather than assuming. + +### Step 5: Report + +State the document, the chain as configured, who was selected for each step, and the current status. Make +clear the document has **not** reached the counterparty yet if it is waiting on approval — that distinction +is the whole point of this skill. + +--- + +## Usage examples + +```txt +@pandadoc-approval-send — create the MSA for Acme from our standard template and route it for approval +``` + +```txt +@pandadoc-approval-send — this discount needs finance sign-off before it goes out +``` + +--- + +## Troubleshooting + +### `approval_execution` is `null` + +No approval workflow is attached to this document or its template. Say so and ask whether to send directly. +Workflows are configured in PandaDoc, not through the API. + +### The document went straight to `Sent` + +Either no chain applied, or `selected_approvers` was omitted or rejected. Check `approval_execution` again +and report what actually happened rather than what was intended. + +### `documents_status_change` will not set `Approved` + +Correct — `Sent`, `Viewed`, `Approved` are system-set. Only `Completed`, `Paid`, `Expired`, `Declined` can be +set manually. + +### I do not know which approver to pick + +Ask. Do not choose. If the chain offers several people, selecting one on the user's behalf silently +undermines the approval. + +### Everything in this file disagrees with what I observe + +Likely, on first real use — this skill was written against schemas rather than a live workflow. Record what +actually happened and correct it here. diff --git a/antigravity/skills/pandadoc-contract-register/SKILL.md b/antigravity/skills/pandadoc-contract-register/SKILL.md new file mode 100644 index 0000000..120b4f8 --- /dev/null +++ b/antigravity/skills/pandadoc-contract-register/SKILL.md @@ -0,0 +1,161 @@ +--- +name: pandadoc-contract-register +category: legal +roles: [legal, finance, bizops] +complexity: advanced +prerequisites: [PandaDoc] +description: Build a register of signed contracts with their key terms — counterparty, dates, value and negotiated provisions — from PandaDoc's extracted metadata, labelled with how confident each value is. Use when someone wants a contract register, key terms pulled out, or all agreements for a customer. +tags: [pandadoc, contracts, metadata, legal, register] +version: "0.1.0" +--- + +# PandaDoc Signed-Contract Register & Key-Terms Extractor + +A table of what has been signed, with whom, when, for how much, and on what terms — assembled from +PandaDoc's own AI extraction. + +Two things shape everything below: **the extracted values are unconfirmed**, and **you cannot search by +them**. + +--- + +## Prerequisites + +The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_search`, +`documents_details_get`, `documents_metadata_get`. Strongly recommended: +`documents_metadata_batch_get`. If they are missing, say so and point at . + +--- + +## What extraction gives you, and what it costs + +`documents_metadata_get` returns AI-extracted fields. Observed on a completed contract: + +`Counterparty name`, `Counterparty address`, `Counterparty signer name`, `Internal party legal name`, +`Internal signer name`, `Agreement date`, plus term checkboxes — `Audit rights`, `Exclusivity`, +`HIPAA / BAA`, `Include SLA`, `Most favored customer`, `Data processing addendum incorporated`, +`Includes logo rights`, `Include special terms`, `Has referenced documents`. + +Three constraints that must reach the user: + +1. **Every value carries `acceptance_status`, and it was `pending` on every field of every sample.** That + means AI-extracted and not human-confirmed. A register presented as fact when nothing in it has been + reviewed is a liability in a legal context. Label it. +2. **Extraction runs only on completed documents, asynchronously.** A draft returns + `{code: "not_started", description: "Likely, your document is not yet completed."}`. In-progress + extraction returns `{code: "extraction_pending", retry_after: N}`. Failure returns `{code: "failed"}`. +3. **There is no monetary field.** Contract value is not in metadata — it comes from + `documents_details_get` → `pricing.total`. + +--- + +## The search limitation — say it out loud + +**Metadata is not queryable.** Neither `documents_metadata_get` nor `documents_metadata_batch_get` accepts a +filter; both read by document. There is no way to ask "which contracts have `Counterparty name = Acme`". + +So building a register for one customer means: + +1. narrow with `documents_search`, which is **full text and requires a non-empty `query`** +2. read metadata for each hit +3. keep the ones whose `Counterparty name` actually matches + +Step 1 is where the answer goes wrong. A search term that does not literally appear in a document will miss +it: `query: "contract"` returned **zero** hits in a workspace where the same documents matched +`query: "Agreement"` with four. And a customer whose name never appears in a document title or body will not +be found at all. + +**Therefore: always state what you searched on, and never claim the register is complete.** "Every contract +for Acme" is a promise you cannot keep. "Every contract matching a search for *Acme Corp*, checked against +the extracted counterparty" is one you can. + +--- + +## Workflow + +### Step 1: Establish the candidate set + +- **Whole register:** `documents_list` with `status: "Completed"`, then `status: "Paid"`, `count: 100`. No + search term needed, so no false negatives. +- **One customer:** `documents_search` with the customer name as `query`, `status: ["Completed", "Paid"]`. + Try the obvious variants — full legal name, short name, domain — and say which ones you tried. + +Do **not** use a `signature_date` window to establish "signed": that filter returns documents nobody signed. +Confirm signature per recipient instead (below). + +### Step 2: Confirm each one is genuinely signed + +For each candidate, `documents_details_get` and check `recipients[]`: at least one entry with +`recipient_type: "signer"` and a non-null `signature_date`. + +A document flipped with `documents_status_change` reads `Completed` with `date_completed` set, but every +`signature_date` is `null` and signature fields are `is_filled: false`. It is not a signed contract. Either +exclude it or list it separately as manually completed. + +### Step 3: Pull metadata + +Use `documents_metadata_batch_get` for 1–40 documents at a time rather than a call each. + +Handle the envelopes: retry once on `extraction_pending` after `retry_after`; on `not_started` or `failed`, +record the contract with its terms marked unavailable rather than dropping it silently. + +### Step 4: Add value + +`documents_details_get` → `pricing.total`, currency from `pricing.tables[].currency` — or from +`pricing.quotes[].currency` when `tables` is empty, which is how a Quote-block document reports it. +Reading only `tables` drops the currency on those, and an unlabelled amount is worse than none. + +**Never `grand_total`** — observed as `0 PLN` on a document worth 200,000 USD. Currency varies per document, +so do not total across currencies; subtotal per currency instead. + +### Step 5: Build the register + +One row per contract: counterparty, document name, agreement date, signature date, value with currency, and +the term flags that are set. Omit flags that are false rather than listing nine "no" columns. + +Then, in plain words: + +- how the candidate set was built, and the exact search terms if you used any +- that extracted values are **AI-derived with `acceptance_status: pending`**, not human-reviewed +- how many contracts had no extraction available +- that the register is **best-effort, not exhaustive**, if a search was involved + +--- + +## Usage examples + +```txt +@pandadoc-contract-register — build me a register of everything we've signed this year with values and key +terms +``` + +```txt +@pandadoc-contract-register — find every contract with Acme Corp and show each one's status and terms +``` + +--- + +## Troubleshooting + +### A customer I know we have contracts with returns nothing + +The search term did not appear in those documents. Metadata cannot be filtered, so the only entry point is +full text. Try the legal name, the trading name and the email domain, and say what you tried. + +### Metadata comes back `not_started` + +The document is not completed. Extraction only runs on completed documents. Nothing to do but wait. + +### Every field says `pending` + +Expected — that is the default `acceptance_status`. It means nobody has reviewed the extraction. Report the +values with that caveat rather than suppressing them. + +### Value is missing or 0 + +Contracts without a pricing table genuinely have no value in PandaDoc. If you see 0 on a contract you know +is worth something, you read `grand_total` instead of `pricing.total`. + +### A contract in the register turns out not to be signed + +Check `recipients[].signature_date`. `Completed` status alone does not mean signed — it can be set manually. diff --git a/antigravity/skills/pandadoc-engagement-tracker/SKILL.md b/antigravity/skills/pandadoc-engagement-tracker/SKILL.md new file mode 100644 index 0000000..6dfb8f6 --- /dev/null +++ b/antigravity/skills/pandadoc-engagement-tracker/SKILL.md @@ -0,0 +1,143 @@ +--- +name: pandadoc-engagement-tracker +category: sales +roles: [sales, cs, owner] +complexity: intermediate +prerequisites: [PandaDoc] +description: Track per-recipient signing progress across documents that are out — who has signed, who has not, in what order, and which documents have stalled. Use when someone asks who is holding up a deal or what the state of play is across sent documents. +tags: [pandadoc, engagement, tracking, e-signature, sales] +version: "0.1.0" +--- + +# PandaDoc Deal-Room Engagement Tracker + +Who has signed, who has not, and what has gone quiet — across everything currently out. + +**Read the limitation below before using this.** In its current form this skill reports *signing* progress +only. It cannot tell you who opened a document. + +--- + +## Prerequisites + +The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_details_get`. +Optional and currently unavailable: `documents_audit_trail_get`. If the required tools are missing, say so +and point at . + +--- + +## Known limitation — no view events + +The intended version of this skill reports opens as well as signatures. That needs +`documents_audit_trail_get`, which currently returns: + +```txt +403 "Access to this resource was forbidden in PandaDoc." code: forbidden +``` + +on a real completed document, with a connection that reads every other document tool. It is unresolved +whether that is a plan entitlement, an OAuth scope or a workspace permission. Tracked against PD-111238. + +So: **attempt the audit trail once, and degrade cleanly when it fails.** Report signing progress from +`documents_details_get`, and state explicitly that open and view events are unavailable on this account. +Never present "has not signed" as "has not opened" — they are different facts and the second one is a +stronger signal a salesperson will act on. + +If the call ever starts succeeding, add the events to the timeline and drop the caveat. + +--- + +## Workflow + +### Step 1: Collect the documents that are out + +`documents_list` with `status: "Sent"`, then again with `status: "Viewed"`. One call per status — the +parameter takes a single string. Use `count: 100`. + +Include `Waiting for Approval` and `External Review` only if the user asks about internal hold-ups too, and +say which statuses you covered. + +If the user named one document or one counterparty, narrow to that instead of sweeping. + +### Step 2: Read per-recipient progress + +`documents_details_get` per document. From `recipients[]` take, for each person: + +- `first_name`, `last_name`, `email` +- `recipient_type` — **`signer` or `CC`.** A CC recipient is not expected to sign and always has + `signature_date: null`. Reporting a CC as "not signed" is noise; exclude them from the outstanding list + and mention them separately if at all. +- `has_completed` — whether that person is done +- `signature_date` — when they signed, `null` until they do +- `signing_order` — if set, signatures are sequential, so a later signer is not late, they are simply not up + yet. Say that rather than flagging them. + +Also check `fields[]`: a `signature` field with `is_filled: false` confirms an outstanding signature. + +### Step 3: Try the audit trail, once per document + +Call `documents_audit_trail_get`. If it returns 403, stop trying for the rest of the run — it is an account +level gate, not per document. Record once that view events are unavailable. + +If it succeeds, merge its events into the per-document timeline in chronological order and note the +timezone you are reporting in. + +### Step 4: Work out what has stalled + +Age each document from `date_sent` in `documents_details_get` — you already have the detail call. Do not use +`date_created`; a document that sat in draft would look stale when it is not. + +Flag as stalled when: + +- it has been out **more than 7 days** with no signature at all, or +- **some** signers have completed and others have not for more than 7 days — a partially signed document is + a stronger stall signal than an untouched one, because momentum existed and stopped + +With `signing_order` set, only flag the person whose turn it actually is. + +### Step 5: Report + +Per document: name, status, age since sent, and a line per signer — signed with a date, or outstanding. +Group or sort so stalls are at the top. + +Then state plainly: + +- which statuses you covered +- that age is measured from send +- **that open and view events are unavailable on this account**, if the audit trail returned 403 + +--- + +## Usage examples + +```txt +@pandadoc-engagement-tracker — who's holding up the Acme deal? +``` + +```txt +@pandadoc-engagement-tracker — anything gone quiet in the last couple of weeks? +``` + +--- + +## Troubleshooting + +### `403 forbidden` on the audit trail + +Expected on this account. Degrade to signing progress and say view events are unavailable. Do not retry per +document — the gate is account level. + +### Someone shows as not signed but says they signed + +Check `signing_order`. With sequential signing, a later signer cannot act until earlier ones finish. Also +check `recipient_type` — a CC recipient has no signature to give. + +### Everyone shows as outstanding on a completed document + +Confirm you read `recipients[]` from `documents_details_get` and not the recipient list echoed by +`documents_send`, which carries no `has_completed`. + +### A document was force-completed and shows no signatures + +`documents_status_change` to `Completed` sets `date_completed` but leaves every `signature_date` as `null` +and `is_filled: false`. Nobody signed it. Report it as manually completed rather than signed. diff --git a/antigravity/skills/pandadoc-followup-list/SKILL.md b/antigravity/skills/pandadoc-followup-list/SKILL.md new file mode 100644 index 0000000..9248064 --- /dev/null +++ b/antigravity/skills/pandadoc-followup-list/SKILL.md @@ -0,0 +1,151 @@ +--- +name: pandadoc-followup-list +category: sales +roles: [sales, bizops, owner] +complexity: intermediate +prerequisites: [PandaDoc] +description: Sweep documents that are still out for signature, rank them by how long they have been waiting and what they are worth, and draft a follow-up message for each. Use when someone asks what needs chasing, which documents are stuck, or wants nudges drafted. +tags: [pandadoc, follow-up, pipeline, e-signature, sales] +version: "0.1.0" +--- + +# PandaDoc Unsigned-Document Follow-up List + +Find what is still out, work out what deserves attention first, and write the nudge. Run it on a schedule +or on demand. + +**You cannot send the reminder.** Be clear about that from the start — see the boundary below. + +--- + +## Prerequisites + +The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_details_get`. +Optional: `documents_search`. If they are missing, say so and point at . + +--- + +## The boundary — what this skill cannot do + +There is **no reminder endpoint** in the PandaDoc MCP, and `documents_send` only accepts documents in +`Draft` status. A document that is already `Sent` therefore **cannot be re-sent or nudged through the API**. + +So this skill surfaces what is stale, ranks it, and **drafts the text**. The user sends it, or PandaDoc's +built-in auto-reminders do. Never say or imply that a reminder was sent. Never call `documents_send` on a +`Sent` document — it will fail, and trying suggests you misunderstood what you are doing. + +--- + +## Workflow + +### Step 1: Decide what "waiting" means, and say so + +Document status is a closed set of 15. The ones that plausibly mean "out and not finished": + +- **`Sent`** — delivered, not opened +- **`Viewed`** — opened, not signed +- **`Waiting for Approval`** — stuck in internal approval, not with the counterparty +- **`External Review`** — with the other side's reviewers +- **`Waiting for Payment`** — signed but unpaid, a different kind of chase + +Default to **`Sent` and `Viewed`** — those are unambiguously "waiting on the counterparty to sign". Include +the others only if the user's request implies them ("what's stuck anywhere", "including approvals"), and +**state in your output which statuses you counted.** A list whose scope is invisible cannot be trusted. + +Never include `Draft` (never sent), `Completed`, `Paid`, `Declined`, `Expired`. + +### Step 2: Collect them + +`documents_list` takes a **single** `status` string, so this is one call per status. `documents_search` +accepts `status[]` but requires a non-empty `query`, which would silently narrow the sweep — so prefer +several `documents_list` calls with `count: 100`. + +Note there is no sort parameter; default order is `date_created` descending. + +### Step 3: Age each document + +`documents_list` gives you `date_created` and `date_modified` for free. It does **not** give you +`date_sent` — that exists only in `documents_details_get`, one call per document. + +Choose deliberately: + +- **`date_sent`** is the honest measure of "how long it has been out", but costs one call per document. +- **`date_created`** is free but overstates the wait for anything that sat in draft first. + +Default to `date_sent` when the set is small (say under 30 documents) and to `date_created` above that, +and **say which you used.** If you used `date_created`, say the figure is time-since-created, not +time-since-sent — otherwise the user reads it as the latter. + +Bucket by age: **0–3 days / 4–7 / 8–14 / 15+**. Oldest first inside each bucket. + +### Step 4: Add value and recipient, if the user wants ranking + +Value and recipients live only in `documents_details_get`. If you are already calling it for `date_sent`, +take them in the same pass. + +- value → `pricing.total`, currency → `pricing.tables[].currency`, falling back to + `pricing.quotes[].currency` when `tables` is empty. A document built on a Quote block has its pricing + under `quotes[]` and an empty `tables[]`, so reading only `tables` silently loses the currency. +- **never `grand_total`** — it has been observed reading `0 PLN` on a document worth 200,000 USD +- rows with `options.optional_selected: false` are excluded from the total, so do not sum line items + yourself +- recipient → `recipients[]`, and `has_completed` per recipient tells you who is holding it up + +Rank by age first, value second. A 20-day-old $5k document usually needs the nudge more than a 2-day-old +$500k one, and the user can re-sort if they disagree. + +If you skipped the detail calls, say the list is unranked by value rather than reporting zeros. + +### Step 5: Draft a nudge per document + +One short message each, addressed to the recipient who has not completed. Reference the document by name, +say how long it has been waiting, and give them the signing link from `recipients[].shared_link` if +present. + +Keep them plain and short — the user will edit before sending. Do not invent urgency, deadlines or +commercial terms that were not in the document. + +### Step 6: Report + +Group by bucket, oldest first. For each: name, status, age, who has not signed, value if you have it, and +the drafted nudge. Then state plainly: + +- which statuses you counted +- whether age is time-since-sent or time-since-created +- that **nothing has been sent** and these are drafts for the user to send + +--- + +## Usage examples + +```txt +@pandadoc-followup-list — what needs chasing? +``` + +```txt +@pandadoc-followup-list — show everything stuck for more than a week, including anything waiting on +internal approval, and draft nudges +``` + +--- + +## Troubleshooting + +### The list is empty but I know documents are out + +Check which statuses you queried. A document sitting in `Waiting for Approval` or `External Review` is out +but not in the default `Sent`/`Viewed` set. + +### Ages look far too long + +You aged from `date_created` on documents that sat in draft. Re-run with `date_sent` from +`documents_details_get` for the affected ones. + +### Value shows as 0 + +You read `grand_total`. Use `pricing.total`. + +### `documents_send` fails on one of these + +Expected, and it should not have been attempted. `documents_send` requires `Draft`; a `Sent` document +cannot be nudged through the API at all. Draft the text and hand it to the user. diff --git a/antigravity/skills/pandadoc-invoice-handoff/SKILL.md b/antigravity/skills/pandadoc-invoice-handoff/SKILL.md new file mode 100644 index 0000000..b3412d6 --- /dev/null +++ b/antigravity/skills/pandadoc-invoice-handoff/SKILL.md @@ -0,0 +1,191 @@ +--- +name: pandadoc-invoice-handoff +category: finance +roles: [finance, owner, sales] +complexity: advanced +prerequisites: [PandaDoc] +description: Turn a signed quote or proposal into an invoice — find quotes that completed and have not been invoiced yet, reuse their pricing, create the invoice from a template, and send it on confirmation. Use when someone asks to invoice a closed deal or wants signed quotes swept for invoicing. +tags: [pandadoc, invoicing, finance, quotes, e-signature] +version: "0.1.0" +--- + +# PandaDoc Quote → Invoice Handoff + +Find quotes that have been signed, build the matching invoice, send it. + +Two structural facts shape this: **there are no webhooks**, so this is a sweep rather than a trigger; and +**nothing marks a quote as invoiced**, so you have to establish that yourself or you will double-invoice. + +--- + +## Prerequisites + +The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_details_get`, +`templates_list`, `templates_details_get`, `documents_create`, `documents_status_get`, `documents_update`, +`documents_send`. If they are missing, say so and point at . + +**An invoice template must exist in the workspace.** The reference account has none, so this skill is +untested end to end. + +--- + +## No trigger, no marker + +**No webhooks.** "On completion, generate the invoice" cannot be event-driven through the MCP. Run this on a +schedule or on demand, and treat it as a sweep over recently completed quotes. + +**No invoiced flag.** PandaDoc does not record that a quote has been invoiced. Before creating anything you +must decide how you will know, and use the same method every run. Options, best first: + +1. **Tag the source quote** after invoicing — documents carry `tags`, settable via `documents_update`. A tag + like `invoiced` is durable, visible in the UI, and survives across runs. This is the recommended approach. +2. **Name convention** on the invoice, e.g. `Invoice - `, then search for it before creating. + Works, but depends on `documents_search` full text, which is fuzzy and eventually consistent. +3. **Ask the user each time.** Safe but does not scale to a scheduled sweep. + +Whichever you use, **say so in your output**, and if you cannot establish invoiced-or-not for a quote, ask +rather than assume. Sending a customer a second invoice for the same work is worse than sending none. + +--- + +## Workflow + +### Step 1: Find signed quotes + +`documents_list` with `status: "Completed"`, then `status: "Paid"`, `count: 100`. + +Narrow to actual quotes — those with a populated `pricing` block. A signed NDA has no value to invoice. + +**Confirm each was genuinely signed.** `documents_details_get` → `recipients[]` must contain a +`recipient_type: "signer"` with a non-null `signature_date`. A document flipped with +`documents_status_change` reads `Completed` but nobody signed it, and invoicing against it would bill a +customer for a deal that was never closed. + +### Step 2: Exclude anything already invoiced + +Apply the method from above. If tagging, skip quotes whose `tags` include your marker. + +### Step 3: Read the pricing to carry over + +From `documents_details_get` on the quote: + +- `pricing.tables[].items[]` — `name`, `qty`, `price`, `subtotal` per line +- `pricing.tables[].summary` — `subtotal`, `discount`, `tax`, `fee`, `total` +- `pricing.tables[].currency` +- `pricing.total` for the headline figure — **never `grand_total`**, which has been observed as `0 PLN` on a + document worth 200,000 USD + +**If `pricing.tables` is empty, look in `pricing.quotes` before concluding the quote has no line items.** +A document built on a Quote block keeps everything under `quotes[]`: `quotes[].sections[].items[]`, +`quotes[].summary`, `quotes[].currency`. Same data, different place. Reading only `tables` on such a +document yields no lines and no currency, and an invoice built from that is empty. + +**Skip rows with `options.optional_selected: false`.** They were offered and not taken, so they are excluded +from the quote's total and must not appear on the invoice. Carrying them over is the most likely way to +invoice more than the customer agreed to. + +### Step 4: Find the invoice template + +`templates_list` with `q: "invoice"`. Remember `q` is fuzzy — confirm the match rather than trusting a single +hit. Read `templates_details_get` for `roles[]`, `tokens[]` and the pricing table name. + +If there is no invoice template, **stop and say so.** Do not improvise one: markdown-created documents have +no signature fields and their recipient is silently downgraded to CC, so what you would produce is unusable. + +Two properties of that template decide whether you can proceed at all. Check both before Step 5: + +**Its pricing block must be a table, not a Quote.** If `pricing.tables` is empty and `pricing.quotes` is +populated, stop. `pricing_tables` is accepted and then silently ignored on such a template, so you would +create an invoice showing `total: "0"` with no error anywhere. A zero invoice that looks finished is worse +than no invoice. Say the template uses a Quote block and ask for one built on a pricing table. + +**Its currency must match the quote's.** `pricing.tables[].currency` is fixed by the template and cannot be +set through the API by any route. `documents_create` rejects a different one with +`400 Wrong request currency … mismatch template currency` and leaves a broken document behind. +`documents_update` is worse: it returns `{"updated": true}`, applies your row data, and **silently keeps the +template's currency** — so a skill that "fixes" the currency after the fact gets a success response and +reports a denomination the document does not have. If you ever write pricing, re-read +`pricing.tables[].currency` afterwards rather than trusting the call. Worse is the failure that does *not* error: omit the currency, and the +quote's numbers are written into the template's denomination unchanged, turning a 200,000 USD agreement +into a 200,000 PLN invoice that looks entirely normal. If the currencies differ, **stop and tell the +user** — name both currencies and ask for an invoice template in the quote's. Never convert the amounts +yourself; you have no rate and no authority to pick one. + +### Step 5: Create the invoice + +`documents_create` from the invoice template, passing the carried-over line items in `pricing_tables` under +the **exact** table name from Step 4. + +On the discount: `discount.name` decides whether your value replaces the template's or is added to it — +matching the template's name replaces, any other name appends. To reproduce the quote's discount exactly, +pass the quote's effective percentage under `"Discount"`, then **verify** (Step 6). Never pass a computed +delta. + +`options` is required on every row even though the schema marks it optional. + +Poll `documents_status_get` until `Draft`. + +### Step 6: Verify the invoice matches the quote + +`documents_details_get` on the invoice and compare against the quote, before showing anything: + +- same line items, same quantities, same unit prices +- `summary.subtotal` matches +- effective discount matches — and `summary.discount` is **not negative**, which would mean a surcharge +- `pricing.total` matches the quote's total +- currency matches + +If anything differs, correct it with `documents_update` and re-read. Reporting an invoice total you have not +compared against the quote is how a customer gets billed the wrong amount. + +### Step 7: Confirm, send, mark + +Show the comparison — quote total against invoice total — and ask before sending. Only on an explicit yes, +`documents_send`. Retry once on `409`. + +**After a successful send, apply your invoiced marker to the source quote** with `documents_update`. If you +skip this, the next sweep invoices it again. + +Report: the quote, the invoice, both totals, that they match, the recipient, and that the source quote is now +marked. + +--- + +## Usage examples + +```txt +@pandadoc-invoice-handoff — any signed quotes waiting to be invoiced? +``` + +```txt +@pandadoc-invoice-handoff — invoice the Acme Corp deal that just closed +``` + +--- + +## Troubleshooting + +### There is no invoice template + +Stop and say so. Generating one is not viable — markdown documents cannot be signed and `templates_create` +needs a PDF at a public HTTPS URL. + +### The invoice total is higher than the quote + +Most likely you carried over an optional row that was not selected. Those are excluded from the quote total. +Check `options.optional_selected` on every line. + +### The discount came out wrong + +`discount.name` did not behave as expected. Pass the target percentage under `"Discount"` and verify; correct +with `documents_update` using the direct value if it still differs. + +### A quote got invoiced twice + +The invoiced marker was not applied, or a different method was used between runs. Pick one method, apply it +after every send, and check it before every create. + +### I invoiced a deal that was not actually closed + +`Completed` status alone does not mean signed — it can be set manually with `documents_status_change`, which +leaves every `recipients[].signature_date` as `null`. Always check signature dates before invoicing. diff --git a/antigravity/skills/pandadoc-nda-fast-send/SKILL.md b/antigravity/skills/pandadoc-nda-fast-send/SKILL.md new file mode 100644 index 0000000..8b622a3 --- /dev/null +++ b/antigravity/skills/pandadoc-nda-fast-send/SKILL.md @@ -0,0 +1,205 @@ +--- +name: pandadoc-nda-fast-send +category: legal +roles: [sales, bizops, legal, owner] +complexity: intermediate +prerequisites: [PandaDoc] +description: Send a standard NDA to a counterparty in one motion — find the right NDA template, prefill the counterparty, make sure every signature is assigned, confirm, then send. Use when someone wants an NDA, mutual NDA or confidentiality agreement issued. +tags: [pandadoc, nda, confidentiality, e-signature, legal] +version: "0.1.0" +--- + +# PandaDoc NDA Fast-Send + +Turn "NDA for Acme, send to `legal@acme.com`" into a sent NDA, without the two failures that make a naive +attempt fail: picking the wrong template, and leaving a signature unassigned. + +--- + +## Prerequisites + +The PandaDoc MCP Server must be connected. Required tools: `templates_list`, `templates_details_get`, +`documents_create`, `documents_status_get`, `documents_details_get`, `documents_update`, +`documents_fields_assign`, `documents_send`. If they are missing, say the MCP Server is not connected +and point at . + +**An NDA template must exist in the workspace.** There is no way around this — see Step 1. + +--- + +## What the user provides + +- **Counterparty name** — required, for the document name and `Client.*` tokens. +- **Recipient email** — required. If missing, ask. +- **Who counter-signs on our side** — often unstated. Whether you need it depends on the template; see + Step 3. +- **Deadline or context** — optional, used in the send message. + +--- + +## Workflow + +### Step 1: Find the NDA template — and stop if there is none + +Call `templates_list` with `q: "NDA"` and `count: 10`. Then, because NDAs are also called other things, +try `q: "non-disclosure"` and `q: "confidentiality"` if the first returns nothing useful. + +**`q` is not a name substring match.** A search for "NDA" has returned a template called +`Declaration H&S standards and regulations`. So: + +- **One plausible match:** name it and its ID, and say you are using it. Do not assume it is right just + because it was the only hit. +- **Several:** list them and ask. Sending the wrong agreement to a counterparty is not a recoverable + mistake. +- **Nothing plausible:** say plainly that the workspace has no NDA template and that one needs creating in + PandaDoc first. **Do not improvise one.** A generated document cannot be signed — `documents_create` with + `source: "markdown"` produces `fields: []` and silently downgrades the recipient to `recipient_type: + "CC"`, so what you would hand over is an NDA-shaped document nobody can sign. That is worse than saying + no. + +Also refuse to proceed on a template that reads back as unusable: a public gallery template can answer +`templates_details_get` in full and still fail `documents_create` with `400 Template is not available.` + +### Step 2: Read the template + +Call `templates_details_get`. You need: + +- **`roles[]`** — every signing role and its exact name. There is no guaranteed "Signer"; the counterparty + role may be `Client`, `Recipient`, `Counterparty` or anything the author chose. Guessing drops the + recipient silently. +- **`preassigned_person` per role** — a role with this set fills itself. A role with `null` needs an + explicit recipient. +- **`tokens[]`** — usually `Client.Company`, `Client.FirstName`, `Client.LastName`, sometimes + `Sender.Company`. + +### Step 3: Work out the recipients + +Count the signing roles that have no `preassigned_person`: + +- **One** — the counterparty email is enough. Proceed. +- **Two or more** — you need a distinct email for each. The same address cannot occupy two roles; the API + returns `already in recipients list`. So ask: *"This template also has a `` signature block — + who signs on our side?"* Do not silently assign both roles to the counterparty, and do not collapse them + onto one person unless the user explicitly says this is a test or a self-sign. + + **Do not stop the turn on this question.** A missing counter-signer blocks *sending*, not *drafting* — + the two are not the same gate, and asking is never a reason to skip Step 4. In the same response, + proceed straight to Step 4 and create the document with the recipients you already have, then ask the + role question **alongside** the draft link, not instead of it. This applies whenever the user asked for + a draft, said "don't send yet", or "let me look first" — they want a document in front of them, and + replying with only a question leaves them with nothing to look at, which is the one thing they asked for. + The sole exception is a request to send outright: there the counter-signer answer has to come back before + anything else happens, because the send cannot proceed either way. + +Note that a template converted from a document in the PandaDoc UI **loses `preassigned_person`**, so a +template that looks like it should self-fill our side may not. + +### Step 4: Create + +```json +{ + "source": "template", + "template_uuid": "", + "name": "NDA - - ", + "recipients": [ + { "email": "", "first_name": "", "last_name": "", + "role": "", + "delivery_methods": { "email": true, "sms": false } } + ], + "tokens": [{ "name": "Client.Company", "value": "" }] +} +``` + +Use the **exact** role names read in Step 2, never a guessed "Signer". Always set `delivery_methods` to +email-only unless a phone number was given, or send fails with `A phone number is required`. + +### Step 5: Wait for draft + +Poll `documents_status_get` until it reads `Draft`. `documents_create` returns `Uploaded` first. + +Be aware `Draft` does not guarantee sendability — see Step 7. + +### Step 6: Check every signature is assigned — before sending + +Call `documents_details_get` and verify: + +1. **Every `signature` and `initials` field has a non-empty `assigned_to.id`.** An empty one belongs to a + role with no recipient, and `documents_send` will reject the whole document. If you find one, resolve it + per Step 3 — add the missing recipient with `documents_update`, or for a confirmed self-sign reassign + those field UUIDs with `documents_fields_assign`. +2. **No recipient has `sms: true` without a phone number.** Fix with `documents_update` if so. +3. The counterparty name landed in the tokens you set. + +This step is what turns three failed send attempts into one clean send. Do not skip it because the create +call succeeded — creation succeeding says nothing about sendability. + +### Step 7: Confirm, then send + +Always confirm first. Show the template name, the document name, and every recipient with their role: + +> Ready to send **"\"** to **\** as ``? *(and `` as ``)* + +Only on an explicit yes, call `documents_send` with a `subject` and short `message` built from the user's +context. + +**A yes has to come from the user.** Not from your own reasoning that sending is obviously what they +wanted, not from restating the question and answering it yourself. If no reply arrives, the answer is not +yes — leave the document as a draft, hand over the link +`https://app.pandadoc.com/a/#/documents/`, and say it is waiting on their go-ahead. That is a +complete, correct outcome, not a failure to finish: an NDA reaching a counterparty's inbox cannot be +recalled, and a draft costs nothing. The same applies whenever the user said not to send — draft, link, +stop. + +- `409 The document isn't ready for a status transition yet` — a known race even after `Draft`. Wait a + moment and retry **once**. Do not loop. +- `400 Not all signature, initials fields were assigned` — Step 6 was skipped or a role is still empty. Go + back rather than retrying blindly. + +Then report what happened, with the link +`https://app.pandadoc.com/a/#/documents/`. + +--- + +## Usage examples + +```txt +@pandadoc-nda-fast-send — send our standard NDA to legal@acme.com, they need it before Thursday's call +``` + +```txt +@pandadoc-nda-fast-send — mutual NDA for Globex to procurement@globex.com, I'll counter-sign as +serhii@ourco.com +``` + +--- + +## Troubleshooting + +### `400 Not all signature, initials fields were assigned` + +A signing role has no recipient. Read `roles[]` again, find the role whose signature field has an empty +`assigned_to`, add that recipient with `documents_update`, then retry. + +### `already in recipients list` + +The same email was put on two roles. Use distinct addresses. For a self-sign test, keep one recipient and +reassign the second role's field UUIDs with `documents_fields_assign` instead. + +### `400 Template is not available.` + +The template is readable but not usable — typically a public gallery template rather than one in the +workspace. Pick a workspace template. + +### `A phone number is required` + +A recipient defaulted to SMS delivery. `documents_update` with +`delivery_methods: {email: true, sms: false}`. + +### `409` on send + +The document is still settling after creation. Retry once. + +### The workspace has no NDA template + +Say so and stop. Generating one is not an option: markdown-created documents have no signature fields and +cannot be signed, and `templates_create` needs a PDF at a public HTTPS URL. diff --git a/antigravity/skills/pandadoc-onboarding-agreement/SKILL.md b/antigravity/skills/pandadoc-onboarding-agreement/SKILL.md new file mode 100644 index 0000000..527bd94 --- /dev/null +++ b/antigravity/skills/pandadoc-onboarding-agreement/SKILL.md @@ -0,0 +1,262 @@ +--- +name: pandadoc-onboarding-agreement +category: hr +roles: [hr, ops, owner] +complexity: intermediate +prerequisites: [PandaDoc] +description: Generate an onboarding or employment document from a template with the new joiner's details prefilled, make sure every field is assigned to them, then send for signature. Use when someone is onboarding a hire and needs paperwork issued. +tags: [pandadoc, hr, onboarding, employment, e-signature] +version: "0.1.0" +--- + +# PandaDoc Onboarding Agreement Generator + +Issue a new joiner's paperwork with their details already filled in, and nothing left unassigned. + +The difference from a plain send is **prefill**: HR templates carry many fields, and a document sent with +half of them blank puts the work back on the new hire. + +--- + +## Prerequisites + +The PandaDoc MCP Server must be connected. Required tools: `templates_list`, `templates_details_get`, +`documents_create`, `documents_status_get`, `documents_details_get`, `documents_fields_assign`, +`documents_update`, `documents_send`. If they are missing, say so and point at . + +--- + +## Handling personal data + +These documents carry personal information — full names, national ID numbers, addresses, sometimes +compensation. Two habits: + +- **Never reprint a national ID, PESEL, passport number, home address or salary figure in your replies.** + Refer to it, do not restate it: "national ID — filled from the value you gave", "PESEL — received, will + go in `Text2`". The user typed the number; they do not need it read back, and this transcript gets + logged, pasted into tickets and shared. One mention in their message is unavoidable, a second one is + yours to prevent. + + The easy place to slip is the field inventory in Step 2 and the summary in Step 6, where listing + field-and-value feels like diligence. `Text2 — can use 85******345 (ID)` is the mistake; write + `Text2 — national ID, from your message` instead. This holds even when you are only asking about + something else, such as a missing date. + + Masking is fine when a value genuinely has to be identified — `85******345` — but naming the field is + usually enough. +- **Never invent a value for a personal field.** If a national ID, address or start date is missing, ask. + A plausible-looking placeholder in an employment document is far worse than an unfilled field, because it + can be signed. + +--- + +## Workflow + +### Step 1: Find the template + +`templates_list` with `q` set to the document type — "onboarding", "employment", "offer", or whatever the +user said. `q` is **not** a name substring match, so a single hit is not proof of a correct match. HR +libraries in particular tend to hold near-duplicates: per contract type, per signer, per country. + +- **One plausible match:** name it and its ID, say you are using it. +- **Several:** list them and ask. The wrong variant means the wrong legal terms for that contract type. +- **Nothing plausible:** say the workspace has no such template. **Do not generate one** — a + markdown-created document has `fields: []` and its recipient is silently downgraded to + `recipient_type: "CC"`, so nobody can sign it. + +### Step 2: Read the template's fields and roles + +`templates_details_get`. You need: + +- **`roles[]`** — usually one signing role for HR paperwork (`Employee` is common), but check. Note any + `preassigned_person`. +- **`fields[]`** — every field, its `type` (`text`, `date`, `signature`, …), which role it is `assigned_to`, + and above all its **`merge_field`**. That last one decides everything below. +- **`tokens[]`** — template variables on the `Client.*` / `Sender.*` convention, separate from fields. + +**Only a field with a non-null `merge_field` can be prefilled through the API.** A field object carries four +identifiers — `uuid`, `name`, `field_id`, `merge_field` — and only the last one is a valid key for `fields`. +The other three are rejected: `field_id` and `name` are dropped silently by `documents_create` and produce +`400 There are no such fields in the document` from `documents_update`. Verified against +`8q5FqeodqheYCQBCBBBqCW` on 2026-08-06. + +So split `fields[]` in two before you promise anything: + +- **`merge_field` non-null** — prefillable. The merge name is the key. +- **`merge_field: null`** — not prefillable by any means. Nothing you pass will land. The recipient fills it + by hand after they open the document, or the template gets merge fields added in the PandaDoc editor. + +If every field you needed falls in the second group, **say so before creating anything**. Several HR +templates in this workspace are built that way (`7gocasE6Tr2GdSqae2HtwT`, +`riFxJ8qcGRpGJiukZVVBPc` — all fields `merge_field: null`, all named `Text`), and on those the prefill this +skill exists to do is simply not available. Offer the two real options — add merge fields to the template, +or send it blank for the joiner to complete — and let the user choose. Do not create an empty document and +present it as prefilled paperwork. + +Watch for a second trap: **one merge name fills every field that shares it, with the same value.** If name, +ID and city all sit behind one merge field, they cannot hold three different values, and the template needs +three distinct merge names before this skill can do its job. + +**Do not ask the user for values the signer supplies.** Use this to decide what to ask for upfront. It is a +hint for the question, not the send gate — the gate is the `is_filled` check in Step 5. + +- `signature` and `initials` fields are always the signer's. Never prefill them, never ask for them. +- Other fields (`text`, `date`, dropdowns) are usually the employer's to fill: name, national ID, address, + start date, department. A bare `date` on the signing role is often the sign date, but do not assume — an + empty one is surfaced in Step 5 and the user decides then. + +Ask the user for the employer-owned values you do not have. Do not block on a field whose owner is unclear: +prefill what you can and let Step 5 catch whatever is still empty. + +List for the user what you can prefill and what you need from them, and ask for the missing values in one +go rather than one at a time. When you write that list, name the field and its source — "national ID, from +the value you gave" — rather than reprinting the value itself. See *Handling personal data*. + +If something you genuinely need is missing, ask — but weigh it against what the user asked for. When they +wanted a draft, or said not to send yet, create the document with what you have and put the question next +to the draft link. A question on its own, with no document, is not what they asked for. + +### Step 3: Create with everything you can prefill + +`documents_create` from the template, passing: + +- `recipients` — the joiner, with `email`, `first_name`, `last_name`, the **exact** role name from Step 2, + and `delivery_methods: {email: true, sms: false}` +- `fields` — a map of **`merge_field`** to `{value: …}`, for the prefillable fields identified in Step 2 +- `tokens` — any template variables, as `[{name, value}]` + +Keys are merge names, not `field_id`, not `uuid`, not the `name` attribute. A key that matches nothing is +silently ignored rather than rejected, which is why Step 5 exists. + +Recipient tokens fill themselves: passing `recipients` populates `Client.FirstName`, `Client.LastName`, +`Sender.*` and `Document.CreatedDate` without your listing them. Pass explicitly only the ones that carry +values the recipient record does not, such as `Client.Company`. + +Poll `documents_status_get` until `Draft`. + +### Step 4: Assign anything left unassigned + +`documents_details_get` and check `fields[]`. Any field whose `assigned_to.id` is empty belongs to a role +with no recipient and will block sending. + +Fix with `documents_fields_assign`, passing the field UUIDs and the recipient. Note this tool **assigns +existing fields only** — it cannot create fields, so if the template lacks a field you need, the template has +to change in PandaDoc. + +### Step 5: Verify the prefill, and list every empty data field + +Still in `documents_details_get`, walk `fields[]` and confirm: + +1. **Every field you intended to prefill has `is_filled: true` and the value you passed.** A key that + matched no merge field is dropped silently — the create call succeeds and the field stays empty. This is + the most common failure and nothing surfaces it but a read-back. + + If a field came back empty, do not assume a typo and retry with a different key. Check `merge_field` on + that field first: when it is `null`, no key exists and retrying cannot work. Say the field is not + API-fillable and leave it for the empty-fields list below. +2. **Every `signature` and `initials` field has a non-empty `assigned_to.id`**, or `documents_send` rejects + the document with `Not all signature, initials fields were assigned`. +3. **No recipient has `sms: true` without a phone number.** + +Then build the send gate. This is a hard rule, not a judgement call, and it fails safe — treat a field as +empty unless `is_filled` is explicitly `true`: + +> Collect every field where `type` is not `signature` or `initials` **and** `is_filled` is not `true`. + +That is the list of empty data fields. Some may be fine to leave for the signer, some may be values the +employer should have supplied — MCP does not mark which, so do not guess. Every field on this list must be +resolved in Step 6 before the document can be sent: the user supplies a value or explicitly waives it. An +empty data field never leaves silently. + +### Step 6: Resolve empty fields, confirm, then send + +If the empty-data-field list from Step 5 is not empty, do not offer to send yet. Show it and ask the user +to clear it. Name each field; do not reprint values (see *Handling personal data*): + +> Before I can send, these fields are empty: `Salary`, `Start Date`. For each, give me a value or tell me to +> leave it for the signer to fill. + +Apply supplied values with `documents_update` (merge names as keys), re-read `documents_details_get`, and +rebuild the list. One exception: a field with `merge_field: null` cannot be filled through the API at all +(`documents_update` returns `400 There are no such fields in the document`), so a value the user gives for +it cannot land — it can only be waived, and the joiner fills it by hand after opening the document. A field +the user fills must now read `is_filled: true`; a waived field comes off the list. Only when every field on +the list is filled or waived do you offer to send. + +Then show the document name, the recipient and role, the count prefilled, and the fields being left blank on +purpose. Ask: + +> Ready to send **"\"** to **\**? \ fields prefilled, \ left blank by your choice +> (\), plus the signer's own fields (signature, and any others assigned to them). + +Only on an explicit yes, `documents_send` with a `subject` and short `message`. Retry once on `409 The +document isn't ready for a status transition yet`. + +**A yes has to come from the user.** Not from your own reasoning that sending is obviously what they +wanted, not from restating the question and answering it yourself. If no reply arrives, the answer is not +yes — leave the document as a draft, hand over the link, and say it is waiting on their go-ahead. That is +a complete outcome, not a failure to finish. Employment paperwork landing in a new hire's inbox before +anyone approved it is the worse error by a wide margin, and a draft costs nothing. The same applies +whenever the user said not to send. + +Report the outcome with the link `https://app.pandadoc.com/a/#/documents/`. + +--- + +## Usage examples + +```txt +@pandadoc-onboarding-agreement — onboarding paperwork for Anna Kowalska starting 1 September, send to +anna.kowalska@example.com +``` + +```txt +@pandadoc-onboarding-agreement — remote work application for Dzmitry, he's on an indefinite contract +``` + +--- + +## Troubleshooting + +### A field I supplied is empty on the document + +Read `merge_field` on that field before doing anything else. It decides which of two different problems you +have, and they have different fixes. + +- **`merge_field` is null.** The field is not addressable by the API at all. No key works — `field_id`, + `uuid` and `name` are all rejected, and `documents_update` answers + `400 There are no such fields in the document: `. Retrying with another key wastes the + user's time. Tell them the template needs a merge field added in the PandaDoc editor, or that the joiner + fills this one by hand. +- **`merge_field` is set but the value did not land.** Then the key really was wrong. Use the merge name + verbatim, including spaces and punctuation — merge names like `MM / DD / YYYY` are common — and set it + with `documents_update`. + +### Two fields need different values but share one merge field + +Not possible. A merge name addresses every field carrying it and writes the same value to all of them. The +template has to give those fields distinct merge names. Say so rather than filling one and leaving the other +wrong. + +### `400 Not all signature, initials fields were assigned` + +A signature field has an empty `assigned_to`. Assign it with `documents_fields_assign`, or add the missing +recipient with `documents_update` if a whole role is unrepresented. + +### `documents_fields_assign` will not create the field I need + +It only assigns existing fields. Add the field to the template in PandaDoc. + +### Several near-identical templates + +Normal for HR libraries — variants per contract type, per signer, per country. Ask rather than guessing; the +distinction is usually legal, not cosmetic. + +### `A phone number is required` + +The recipient defaulted to SMS delivery. `documents_update` with +`delivery_methods: {email: true, sms: false}`. + +### `409` on send + +Known race even after `Draft`. Retry once, do not loop. diff --git a/antigravity/skills/pandadoc-quote-builder/SKILL.md b/antigravity/skills/pandadoc-quote-builder/SKILL.md new file mode 100644 index 0000000..0cc2b54 --- /dev/null +++ b/antigravity/skills/pandadoc-quote-builder/SKILL.md @@ -0,0 +1,298 @@ +--- +name: pandadoc-quote-builder +category: sales +roles: [sales, bizops, owner] +complexity: advanced +prerequisites: [PandaDoc] +description: Build a priced PandaDoc quote or proposal from a template — fill the pricing table and variables, verify the totals and discount actually landed as asked, confirm, then send for signature. Use when someone wants a quote, proposal or priced offer created for a client. +tags: [pandadoc, quotes, proposals, pricing, e-signature, sales] +version: "0.1.1" +--- + +# PandaDoc Quote & Proposal Builder + +Turn "quote Acme for 200 licences with 10% off" into a real, correctly priced PandaDoc document, checked +before anyone sees it, and sent only on confirmation. + +The hard part is not creating the document. It is that **the API will accept your pricing and silently +produce different numbers than you asked for.** Most of this skill is about catching that. + +--- + +## Prerequisites + +The PandaDoc MCP Server must be connected. Required tools: `templates_list`, `templates_details_get`, +`documents_create`, `documents_status_get`, `documents_details_get`, `documents_update`, `documents_send`. +If they are missing, tell the user the MCP Server is not connected and point them at +. + +--- + +## What the user provides + +Extract from their message: + +- **Client name** — required. Used for the document name and the `Client.*` tokens. +- **Template** — a name or description ("our standard proposal"). If missing, ask; do not pick one. +- **Line items** — description, unit price, quantity for each. If prices are missing, ask. +- **Discount** — optional. Read carefully: "10% discount" almost always means *the client pays 10% less*, + not *add 10% to whatever the template already discounts*. See Step 3. +- **Recipient email** — needed only if sending. If the user only wants a draft, skip it. + +--- + +## Why this is not just create-with-pricing + +Four behaviours, all verified against the live MCP, that a naive flow gets wrong: + +1. **`discount.name` decides whether your discount replaces the template's or is added to it.** It is a + key, not a label. Verified on a template carrying 15%: passing `10` under the name `"Discount"` — the + name the template already uses — produced exactly **10%**, replacing it. Passing `10` under a new name + like `"Special promo"` produced **25%**, appending a second discount. Get the name wrong and the price + is wrong, with no error and a document that looks normal. +2. **`grand_total` is not the document's value.** It has been observed reading `{amount: "0", currency: + "PLN"}` on a document whose `pricing.total` was `200000` USD. Never quote `grand_total` to a user. +3. **Optional rows that are not selected are excluded from the total.** A row with + `options.optional_selected: false` contributes nothing, so a total computed by summing line items + yourself will disagree with the document. +4. **Some templates cannot be read at all.** `templates_details_get` can fail with + `PANDADOC_API_RESPONSE_INVALID` on a specific template, and every document created from it fails the + same way — while creation itself succeeds. So a successful create does not mean you can verify the + result. + +--- + +## Workflow + +### Step 1: Find the template + +Call `templates_list` with `q` set to the user's wording and `count: 10`. + +`q` is **not** a name substring match despite what the parameter description says — a search for "NDA" +has returned a template called `Declaration H&S standards and regulations`. So: + +- **One match:** report it by name and ID, and continue. Say which template you picked. +- **Several matches:** list name + ID and ask. Never guess — the wrong template means the wrong prices. +- **No matches:** broaden the term once, then list all templates and ask. + +**If the account has no suitable template, say so plainly and stop.** A priced document cannot be built +without one, and the workarounds that look available all fail. Verified 2026-08-06: + +- **`source: "markdown"` cannot carry pricing.** The parameter does not exist on that source — the schema + rejects `pricing_tables` outright — and writing a price table into the markdown body does not help: it + lands as an ordinary content table, `pricing` comes back `{}`, and `grand_total` is `0`. The numbers are + inert text. There is nothing to verify, no discount to apply, no currency. The document also has + `fields: []` and its recipient is silently downgraded to `recipient_type: "CC"`, so nobody can sign it. +- **`source: "file"` cannot either.** It takes no `pricing_tables`. `parse_form_fields` handles fields, not + pricing. +- **`templates_create` is not a way round it.** It builds a template from a PDF at a public HTTPS URL — + which a chat cannot produce — and a PDF-derived template does not carry a structured pricing table + anyway. + +So when the user says "just make me a quote" and there is no template, the honest answer is that one has to +exist first. Producing a markdown document with prices typed into it is the worst available outcome: it +looks like a quote, the totals are unverifiable, the discount was never applied, and it cannot be signed. +Do not do it, and do not offer it as a fallback. + +Two further requirements the template itself must meet — check them in Step 2 before promising anything: +its pricing block must be a **classic pricing table** rather than a Quote block, and it must already be in +the **currency** the user wants, because currency cannot be set through the API. + +### Step 2: Inspect the template — and handle it being unreadable + +Call `templates_details_get` with the chosen ID. You need three things from it: + +- **`pricing.tables[]`** — the name of the pricing table you will populate. You must pass this name + exactly; pricing is matched by name, not position. +- **`pricing.tables[].summary.discount` and `subtotal`** — the template's **existing** discount. Compute + it as a percentage: `summary.discount / summary.subtotal`. You need this number in Step 3. +- **`roles[]`** — who signs. Note whether any role has `preassigned_person` set; roles without it need an + explicit recipient. + +**If this call fails** with `PANDADOC_API_RESPONSE_INVALID`, stop and tell the user that template is +unreadable through the API and suggest another. Do not proceed — you cannot compute the discount delta +without the template's current discount, and you will not be able to verify the result afterwards either. + +If `pricing.tables` is empty, check `pricing.quotes` before saying anything — the two cases look alike and +are not: + +- **`tables` and `quotes` both empty** — the template genuinely has no pricing block. Say so and ask for + one that has. +- **`tables` empty but `quotes` populated** — the template uses a Quote block. It *has* pricing, but the + API cannot write to it: there is no `quotes` parameter on `documents_create`, and `pricing_tables` is + accepted and then **silently ignored**, leaving a document with the template's original empty rows and + `total: "0"`. Do not attempt it and do not report a total from such a document. Tell the user this + template's pricing block is a Quote and cannot be filled through the API, and ask for one built on a + classic pricing table. + +The second case is the dangerous one: nothing errors, so a quote that was never priced comes back looking +like a finished document worth nothing. + +Note the currency too. `pricing.tables[].currency` is fixed by the template and **cannot be overridden** +at creation — passing a different one fails with `Wrong request currency … mismatch template currency`. +If the user's amounts are in another currency, say so and ask for a template in theirs rather than +writing their numbers into the wrong denomination. + +### Step 3: Decide what the discount means, then pick the name accordingly + +This is the step that makes the difference — and it needs no arithmetic. + +Let `T` be the discount the user named and `D` the template's existing discount from Step 2. If `D` is +zero, pass `T` and move on. If `D` is non-zero, the request is ambiguous and only the user can settle it, +so ask: + +> This template already discounts `D`%. You asked for `T`% — should the client end up at `T`% overall, or +> `T`% on top of the existing `D`%? + +Then pick the discount **name** to match the answer, because the name is what controls composition: + +| The user wants | Pass | Under `discount.name` | +| --- | --- | --- | +| `T`% in total | `T` | `"Discount"` — the template's own name, which **replaces** it | +| `T`% on top of `D`% | `T` | any new name, e.g. `"Special promo"` — which **appends** | + +Do **not** compute a delta like `T − D`. That was an earlier misreading of this API and it produces a +surcharge when the name happens to match: passing `−5` under `"Discount"` on a 15% template yields a −5% +surcharge, not 10% off. + +One caveat that makes Step 6 mandatory: **the template's discount name cannot be read from +`templates_details_get`.** The template response carries `summary.discount` but leaves `items[].discounts` +empty — names only appear on created documents. `"Discount"` is the observed default and a sound first +guess, but it is a guess. Verify, then correct. + +Whichever reading you applied, **say so in your output.** + +### Step 4: Create the document + +Call `documents_create`: + +```json +{ + "source": "template", + "template_uuid": "", + "name": " - - ", + "recipients": [ + { "email": "", "first_name": "", "last_name": "", + "role": "", + "delivery_methods": { "email": true, "sms": false } } + ], + "tokens": [{ "name": "Client.Company", "value": "" }], + "pricing_tables": [{ + "name": "", + "options": { "currency": "USD", "discount": { "name": "Discount", "type": "percent", "value": "" } }, + "sections": [{ + "title": "Products", "default": true, + "rows": [{ + "data": { "name": "", "price": , "qty": }, + "options": { "optional": false, "optional_selected": false, "qty_editable": false } + }] + }] + }] +} +``` + +Two things that will otherwise cost you a round trip: + +- **`options` is required on every row**, even though the schema marks it optional. Omitting it returns + `400 validation_error` with `{"rows": [{"options": ["This field is required."]}]}`. +- Always set `delivery_methods` to email-only unless a phone number was supplied, or sending later fails + with `A phone number is required`. + +### Step 5: Wait for draft + +`documents_create` returns status `Uploaded`. Poll `documents_status_get` until it reads `Draft`. Usually +one or two calls. + +Be aware that `Draft` does **not** guarantee the document is ready for a status transition — see Step 7. + +### Step 6: Verify the numbers — do not skip this + +Call `documents_details_get` and check, before showing the user anything: + +1. **The effective discount matches what the user asked for.** Compute + `summary.discount / summary.subtotal` and compare against `T`. A **negative** `summary.discount` means + you produced a surcharge — the name did not behave as expected. Either way, if it does not match, + correct it with `documents_update` passing the target value directly, then re-read and check again. + Never report a figure you have not verified. +2. **Every line item is present** with the right `qty` and `price`, and `subtotal` per row is what you + expect. +3. **No row you intended to count has `options.optional_selected: false`.** If one does, its subtotal is + excluded from the total. +4. **Read the value from `pricing.total`**, never `grand_total`. +5. **Currency** comes from `pricing.tables[].currency`. + +Verifying the result rather than trusting the request is deliberate, and it has already earned its place: +the first version of this skill computed a delta on the assumption that discounts always add, and a live +run produced a 5% **surcharge** instead of a 10% discount. The verification step caught it and the +correction landed on the right number. Keep this step even if the composition rules change — it is correct +under any of them. + +### Step 7: Confirm, then send + +Show the user the numbers you verified — subtotal, discount as a percentage and an amount, total, +currency — plus which template and which discount reading you used. Then ask: + +> Ready to send **"\"** to **\**? Subtotal \, \% discount, total \ \. + +Only on an explicit yes, call `documents_send` with a `subject` and short `message`. + +If it returns `409 The document isn't ready for a status transition yet`, wait a moment and retry once. +This happens even when `documents_status_get` already reported `Draft` — it is a known race, not an error +in your input. + +If it returns `400 Not all signature, initials fields were assigned`, a signing role has no recipient. +Read `roles[]` again, add the missing recipient with `documents_update`, then retry. + +If the user does not want it sent, leave it as a draft and give them the link: +`https://app.pandadoc.com/a/#/documents/` + +--- + +## Usage examples + +```txt +@pandadoc-quote-builder — quote Acme Corp from our standard proposal: 200 Enterprise licences at $1000, +12 months support at $1500/mo, 10% discount. Send to procurement@acme.com +``` + +```txt +@pandadoc-quote-builder — build me a draft quote for Globex, 50 seats at $99, no discount. Don't send it. +``` + +--- + +## Troubleshooting + +### The total does not match what I calculated + +Check for optional rows. A row with `options.optional_selected: false` is excluded from +`summary.subtotal`. Also confirm you are reading `pricing.total` and not `grand_total`. + +### The discount came out higher than requested + +Your `discount.name` did not match the template's, so yours was appended rather than replacing it. +Re-issue with `name: "Discount"`, or correct the document with `documents_update` passing the target value +directly. + +### The discount came out negative — the total is higher than the subtotal + +You passed a negative value under a name that replaced the template's discount, so it became a surcharge. +Pass the target percentage as a positive number instead. Never pass a computed delta. + +### `PANDADOC_API_RESPONSE_INVALID` on the template or the document + +That template's data cannot be serialised by the API. It affects both the template and every document +created from it, though creation itself succeeds. Use a different template and report the broken one. + +### `400 validation_error` mentioning `options` + +Every pricing row needs an `options` object, even an effectively empty one. The schema says it is +optional; the API disagrees. + +### `grand_total` shows 0 + +Expected. It does not reflect the pricing table. Use `pricing.total`. + +### `409` on send + +The document is still settling after creation. Retry once. Do not loop. diff --git a/antigravity/skills/pandadoc-renewal-radar/SKILL.md b/antigravity/skills/pandadoc-renewal-radar/SKILL.md new file mode 100644 index 0000000..5a7e9d4 --- /dev/null +++ b/antigravity/skills/pandadoc-renewal-radar/SKILL.md @@ -0,0 +1,154 @@ +--- +name: pandadoc-renewal-radar +category: legal +roles: [sales, legal, finance, owner] +complexity: advanced +prerequisites: [PandaDoc] +description: Digest of signed contracts approaching renewal or expiry, with counterparty, value and days remaining. Distinguishes a contract's own end date from the signing-link expiry, which are not the same thing. Use when someone asks what is coming up for renewal. +tags: [pandadoc, renewals, contracts, legal, reporting] +version: "0.1.0" +--- + +# PandaDoc Contract Renewal Radar + +Which signed contracts are coming up for renewal, what they are worth, and how long is left. + +**The trap in this skill is which date you use.** PandaDoc has three date concepts that look +interchangeable and are not. Getting it wrong produces a list that is confidently about the wrong thing. + +--- + +## Prerequisites + +The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_search`, +`documents_details_get`, `documents_metadata_get`. Useful: `documents_metadata_batch_get` to batch +metadata lookups instead of one call per document, and `documents_content_get` or +`documents_summary_get` for reading a term out of the text. If they are missing, say so and point at +. + +--- + +## Three dates, only one of which is a renewal date + +| Concept | Where | What it actually is | +| --- | --- | --- | +| `expiration_date` / `date_expiration` / `date_expired` | `documents_list`, `documents_details_get`, `documents_search` | **The signing link's expiry.** Set at send time — observed as `date_sent` + ~60 days. Meaningless once signed. | +| `effective_date` | `documents_search` filter column only | When the agreement takes effect. Closer to what you want, but it is a start date, not an end date. | +| `Agreement date` | `documents_metadata_get`, AI-extracted | The date on the face of the contract. Also a start, not an end. | + +**None of them is a renewal date.** PandaDoc does not store a contract end date as a field — the term +("two years from the effective date", "renews annually unless cancelled") lives in the contract text. + +So a renewal radar has to derive it. Be explicit with the user about how you did, because a derived date +presented as a stored one is misleading. + +**Never build this list from `expiration_date`.** That is the signing link, and on a signed contract it +tells you nothing. A list of "contracts expiring soon" built from it is simply wrong — and it will look +plausible, which is the problem. + +--- + +## Workflow + +### Step 1: Get the signed contracts + +Only `Completed` and `Paid` documents can be up for renewal. A `Draft` or `Sent` document is not a contract +yet. + +`documents_list` with `status: "Completed"`, then `status: "Paid"`, `count: 100`. + +Do **not** use `documents_search` with a `signature_date` window to establish "signed": that filter returns +documents nobody signed. It matches force-completed documents whose `recipients[].signature_date` is `null`. +If you need to confirm a document was genuinely signed, check `signature_date` per recipient in +`documents_details_get`. + +### Step 2: Establish a start date per contract + +In order of preference: + +1. `documents_metadata_get` → **`Agreement date`**. Only present on completed documents and extracted + asynchronously. Handle `{code: "extraction_pending", retry_after: N}` by retrying once, and + `{code: "not_started"}` or `{code: "failed"}` by falling back. +2. `documents_search` with `date_filter_column: "effective_date"` if you are narrowing rather than reading + per document. +3. `date_completed` as a last resort — the day it was signed. Say you used it. + +Every extracted value carries `acceptance_status`, which was `pending` on every sample — AI-extracted, not +human-confirmed. **Say so when you report a date that came from metadata.** + +### Step 3: Establish a term, and be honest about it + +The term is in the text. Read it with `documents_summary_get` (`summary_type: "detailed"`) or +`documents_content_get` and look for the duration and any auto-renewal language. + +Two warnings: + +- `documents_summary_get` returns a **markdown re-rendering of the document**, not a structured summary, and + on a document with empty fields it renders **placeholders as if they were terms** — `Effective Date: May + 12`, `Governing Law … State of (State)`. Do not read a term off a placeholder. If the value looks like a + template default, treat the term as unknown. +- If you cannot find a term, say the renewal date is unknown for that contract rather than assuming twelve + months. An invented renewal date is worse than a gap, because nobody will check it. + +Renewal date = start date + term. State the arithmetic you used per contract. + +### Step 4: Add counterparty and value + +- counterparty → `documents_metadata_get` → `Counterparty name`, with the same `acceptance_status` caveat +- value → `documents_details_get` → **`pricing.total`**, currency from `pricing.tables[].currency`, or + `pricing.quotes[].currency` when `tables` is empty — a Quote-block document keeps its pricing there +- **never `grand_total`** — observed as `0 PLN` on a document worth 200,000 USD + +`documents_metadata_batch_get` covers 1–40 documents per request and is worth using instead of a call each. + +### Step 5: Report + +Sort by days remaining, soonest first. Per contract: counterparty, name, start date and where it came from, +term and where it came from, computed renewal date, days left, value with currency. + +Then state: + +- that renewal dates are **derived**, not stored, and how +- which came from AI-extracted metadata with `acceptance_status: pending` +- how many contracts you could not date at all, so a short list is not read as a quiet pipeline + +--- + +## Usage examples + +```txt +@pandadoc-renewal-radar — what's up for renewal in the next 90 days? +``` + +```txt +@pandadoc-renewal-radar — weekly renewal digest with values +``` + +--- + +## Troubleshooting + +### The list is full of recently sent documents + +You used `expiration_date`. That is the signing link expiry, set at send time to roughly 60 days out, and it +has nothing to do with renewals. Rebuild from `Completed`/`Paid` and a derived contract end date. + +### A contract shows a term that matches the template default + +You read a placeholder rather than a filled value. `documents_summary_get` renders unfilled template fields +as though they were content. Treat the term as unknown. + +### Metadata returns `not_started` + +Extraction only runs on completed documents and is asynchronous. If the document is completed, retry after +the `retry_after` interval; otherwise fall back to `date_completed` and say so. + +### Value shows 0 + +You read `grand_total`. Use `pricing.total`. + +### A "signed" contract turns out to be unsigned + +It was flipped with `documents_status_change`, which sets `date_completed` but leaves every +`recipients[].signature_date` as `null`. Check per-recipient signature dates before treating a document as a +contract. diff --git a/antigravity/skills/pandadoc-signing-velocity/SKILL.md b/antigravity/skills/pandadoc-signing-velocity/SKILL.md new file mode 100644 index 0000000..2442740 --- /dev/null +++ b/antigravity/skills/pandadoc-signing-velocity/SKILL.md @@ -0,0 +1,139 @@ +--- +name: pandadoc-signing-velocity +category: reporting +roles: [sales, owner, bizops] +complexity: advanced +prerequisites: [PandaDoc] +description: Build a signing pipeline report — status counts, average time to sign, value in flight, and documents that are stuck. Computed client-side with the sample size and method stated. Use when someone wants a weekly signing report or pipeline overview. +tags: [pandadoc, reporting, metrics, pipeline, e-signature] +version: "0.1.0" +--- + +# PandaDoc Signing Velocity & Pipeline Report + +Status counts, time to sign, value in flight, and what is stuck — as one report. + +Every number here is computed by you over a bounded set. **There is no aggregation endpoint**, so the +honesty of this report depends entirely on stating what went into it. + +--- + +## Prerequisites + +The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_details_get`. +If they are missing, say so and point at . + +--- + +## The rule for this skill + +**Every figure ships with its sample size and its method.** A single average with no denominator is worse +than no average, because it reads as authoritative. If you computed time-to-sign over four documents, say +"over 4 documents". If you capped the set, say where you stopped. + +--- + +## Workflow + +### Step 1: Count by status + +`documents_list` per status, `count: 100`. Statuses worth counting for a pipeline view: + +- **In flight:** `Sent`, `Viewed`, `Waiting for Approval`, `External Review` +- **Finished:** `Completed`, `Paid` +- **Dead or stalled:** `Declined`, `Expired`, `Rejected` +- **Not started:** `Draft` + +One call per status — the parameter takes a single string. If a status returns 100 results, more may exist; +say the count is a floor rather than a total. + +### Step 2: Average time to sign + +Only `Completed` and `Paid` documents have a duration. For each, you need: + +- `date_sent` — **only in `documents_details_get`**, one call per document +- `date_completed` — in both `documents_list` and details + +So the interval is `date_sent → date_completed`. Cap the number of detail calls (30 is reasonable) and say +if you capped. + +Three things to disclose, not bury: + +1. **The sample size.** "3.2 days over 7 documents", never just "3.2 days". +2. **In-flight documents are excluded**, because they have no completion date. That biases the average + **downward** — fast signers are over-represented and anything still dragging is invisible. Say so. +3. **Force-completed documents have no real signature.** A document flipped via `documents_status_change` + has `date_completed` set but every `recipients[].signature_date` as `null` and signature fields + `is_filled: false`. Its "time to sign" is meaningless. Check `signature_date` and exclude those, or + report them separately. + +If nothing qualifies, say the sample is empty. Do not produce a number. + +### Step 3: Value in flight + +Sum `pricing.total` across the in-flight statuses. + +- **Never `grand_total`** — it has been observed as `0 PLN` on a document worth 200,000 USD. +- Currency lives per table in `pricing.tables[].currency` and **varies per document**. When `tables` is + empty, read `pricing.quotes[].currency` instead — Quote-block documents keep their pricing there, and + looking only at `tables` leaves their amounts unlabelled. Do not add across currencies. Report per + currency, or report the dominant one and say what was excluded. +- Rows with `options.optional_selected: false` are excluded from the table total, so use `pricing.total` + rather than summing line items. +- Documents with no pricing table contribute nothing. Say how many had no value, so a small total is not + mistaken for a small pipeline. + +### Step 4: Stuck documents + +Age in-flight documents from `date_sent`. Flag anything out more than 14 days with no completion, and +anything partially signed — some `recipients[].has_completed: true`, others false — for more than 7 days. + +Respect `signing_order`: a later signer in a sequential flow is not late. + +### Step 5: Report + +Lead with the counts, then time to sign with its denominator, then value in flight per currency, then the +stuck list oldest first. Close with a short method note: which statuses, what was capped, what age is +measured from, and what was excluded and why. + +--- + +## Usage examples + +```txt +@pandadoc-signing-velocity — weekly signing report +``` + +```txt +@pandadoc-signing-velocity — how's the pipeline looking, and what's stuck? +``` + +--- + +## Troubleshooting + +### The average time to sign looks implausibly fast + +Check for force-completed documents. `documents_status_change` sets `date_completed` immediately, so a +document created and flipped in the same minute reports a near-zero duration. Exclude anything whose +`recipients[].signature_date` is `null`. + +### Value in flight looks far too low + +Either you read `grand_total` instead of `pricing.total`, or most in-flight documents genuinely have no +pricing table. Report the count of documents with no value so the two cases are distinguishable. + +### Totals across currencies do not add up + +They should not be added. `pricing.tables[].currency` varies per document — USD and PLN have both been seen +in the same account. Report per currency. + +### A status count seems capped + +`documents_list` maxes at `count: 100`. If a status returned exactly 100, treat it as a floor and page or +say so. + +### I cannot get `date_sent` without a call per document + +Correct — it exists only in `documents_details_get`. Cap the number of documents you expand and disclose the +cap rather than silently truncating the sample. diff --git a/scripts/build-antigravity-plugin.mjs b/scripts/build-antigravity-plugin.mjs new file mode 100644 index 0000000..3721c88 --- /dev/null +++ b/scripts/build-antigravity-plugin.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * build-antigravity-plugin.mjs — mirror ./skills into the Antigravity plugin at ./antigravity. + * + * The repo root is the Claude / Agent Plugins plugin; Antigravity's plugin.json schema allows only + * `$schema`, `name` and `description` (additionalProperties: false), so it cannot share the root + * manifest and lives in its own directory. Skills are copied, not symlinked, so the directory stays + * self-contained when Antigravity stages or packages it on its own. + * + * Usage: + * node scripts/build-antigravity-plugin.mjs # rewrite antigravity/skills from ./skills + * node scripts/build-antigravity-plugin.mjs --check # exit 1 if antigravity/ is out of date + */ +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { hashDirectory } from "./lib/hash.mjs"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const SKILLS_SRC = join(REPO_ROOT, "skills"); +const PLUGIN_DIR = join(REPO_ROOT, "antigravity"); +const SKILLS_DEST = join(PLUGIN_DIR, "skills"); +const MANIFEST_KEYS = new Set(["$schema", "name", "description"]); +const NAME_PATTERN = /^[a-zA-Z0-9-_]+$/; + +const check = process.argv.includes("--check"); + +main(); + +function main() { + const errors = validateManifests(); + const srcSlugs = listSkills(SKILLS_SRC); + if (srcSlugs.length === 0) errors.push(`No skills found under ${SKILLS_SRC}`); + + if (check) { + const destSlugs = existsSync(SKILLS_DEST) ? listSkills(SKILLS_DEST) : []; + const missing = srcSlugs.filter((s) => !destSlugs.includes(s)); + const extra = destSlugs.filter((s) => !srcSlugs.includes(s)); + const stale = srcSlugs.filter( + (s) => destSlugs.includes(s) && hashDirectory(join(SKILLS_SRC, s)) !== hashDirectory(join(SKILLS_DEST, s)), + ); + if (missing.length) errors.push(`Missing from antigravity/skills: ${missing.join(", ")}`); + if (extra.length) errors.push(`Not in ./skills: ${extra.join(", ")}`); + if (stale.length) errors.push(`Out of date in antigravity/skills: ${stale.join(", ")}`); + if (missing.length + extra.length + stale.length) { + errors.push("Run `node scripts/build-antigravity-plugin.mjs` and commit the result."); + } + finish(errors, `antigravity/ is in sync (${srcSlugs.length} skills).`); + return; + } + + if (errors.length) finish(errors); + rmSync(SKILLS_DEST, { recursive: true, force: true }); + mkdirSync(SKILLS_DEST, { recursive: true }); + for (const slug of srcSlugs) { + cpSync(join(SKILLS_SRC, slug), join(SKILLS_DEST, slug), { recursive: true }); + } + finish([], `Mirrored ${srcSlugs.length} skills into antigravity/skills.`); +} + +function validateManifests() { + const errors = []; + const manifest = readJson(join(PLUGIN_DIR, "plugin.json"), errors); + if (manifest) { + const unknown = Object.keys(manifest).filter((k) => !MANIFEST_KEYS.has(k)); + if (unknown.length) errors.push(`antigravity/plugin.json: keys not allowed by the schema: ${unknown.join(", ")}`); + if (typeof manifest.name !== "string" || !NAME_PATTERN.test(manifest.name)) { + errors.push("antigravity/plugin.json: `name` is required and must match ^[a-zA-Z0-9-_]+$"); + } + } + const mcp = readJson(join(PLUGIN_DIR, "mcp_config.json"), errors); + if (mcp) { + for (const [id, server] of Object.entries(mcp.mcpServers || {})) { + if (!server.serverUrl) errors.push(`antigravity/mcp_config.json: server "${id}" needs \`serverUrl\``); + for (const legacy of ["url", "httpUrl", "type"]) { + if (legacy in server) errors.push(`antigravity/mcp_config.json: server "${id}" uses unsupported key \`${legacy}\``); + } + } + } + return errors; +} + +function listSkills(dir) { + return readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, "SKILL.md"))) + .map((d) => d.name) + .sort(); +} + +function readJson(path, errors) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (e) { + errors.push(`${path}: ${e.message}`); + return null; + } +} + +function finish(errors, okMessage) { + if (errors.length) { + for (const e of errors) console.error(`ERROR: ${e}`); + process.exit(1); + } + console.log(okMessage); +} diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs index 5009a93..80a41f1 100644 --- a/scripts/package-plugin.mjs +++ b/scripts/package-plugin.mjs @@ -1,15 +1,20 @@ #!/usr/bin/env node /** - * package-plugin.mjs — build a distributable zip of the Claude plugin. + * package-plugin.mjs — build a distributable zip of the Claude or Antigravity plugin. * - * This repo's root is the plugin, so the zip includes only the plugin's own paths - * (.claude-plugin/, skills/, hooks/, .mcp.json) at the zip root — not the repo's other - * platform manifests or tooling. Uses `git archive`, so only committed files are included. + * Claude (default): this repo's root is the plugin, so the zip includes only the plugin's own + * paths (.claude-plugin/, skills/, hooks/, .mcp.json) at the zip root — not the repo's other + * platform manifests or tooling. + * Antigravity: the contents of antigravity/ at the zip root. + * Uses `git archive`, so only committed files are included. * * Usage: - * node scripts/package-plugin.mjs [ref] # ref defaults to HEAD; pass a tag e.g. v1.0.0 + * node scripts/package-plugin.mjs [ref] [--target claude|antigravity] + * ref defaults to HEAD; pass a tag e.g. v1.0.0 * - * Output: dist/pandadoc-plugin-v.zip (+ prints sha256 for archive-source pinning) + * Output: dist/pandadoc--plugin-v.zip (Claude keeps dist/pandadoc-plugin-v.zip) + * (+ prints sha256 for archive-source pinning). The Antigravity manifest carries no version, so + * both targets take it from .claude-plugin/plugin.json. */ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; @@ -18,8 +23,21 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const PLUGIN_PATHS = [".claude-plugin/plugin.json", "skills", "hooks", ".mcp.json"]; -const ref = process.argv[2] || process.env.REF || "HEAD"; +const TARGETS = { + claude: { treeish: (ref) => ref, paths: [".claude-plugin/plugin.json", "skills", "hooks", ".mcp.json"], prefix: "pandadoc-plugin" }, + antigravity: { treeish: (ref) => `${ref}:antigravity`, paths: [], prefix: "pandadoc-antigravity-plugin" }, +}; + +const argv = process.argv.slice(2); +const targetIdx = argv.indexOf("--target"); +const targetName = targetIdx === -1 ? "claude" : argv[targetIdx + 1]; +const target = TARGETS[targetName]; +if (!target) { + console.error(`ERROR: unknown --target "${targetName}" (expected: ${Object.keys(TARGETS).join(", ")})`); + process.exit(1); +} +const positional = argv.filter((_, i) => targetIdx === -1 || (i !== targetIdx && i !== targetIdx + 1)); +const ref = positional[0] || process.env.REF || "HEAD"; // Read the version from the ref itself, so the filename matches the archived content. const manifest = execFileSync( @@ -31,16 +49,17 @@ const version = JSON.parse(manifest).version; const outDir = join(REPO_ROOT, "dist"); mkdirSync(outDir, { recursive: true }); -const outFile = join(outDir, `pandadoc-plugin-v${version}.zip`); +const outFile = join(outDir, `${target.prefix}-v${version}.zip`); execFileSync( "git", - ["archive", "--format=zip", "-o", outFile, ref, ...PLUGIN_PATHS], + ["archive", "--format=zip", "-o", outFile, target.treeish(ref), ...target.paths], { cwd: REPO_ROOT, stdio: "inherit" }, ); const sha256 = createHash("sha256").update(readFileSync(outFile)).digest("hex"); console.log(`\nPackaged: ${outFile}`); +console.log(`Target: ${targetName}`); console.log(`Ref: ${ref}`); console.log(`Version: ${version}`); console.log(`sha256: ${sha256}`); From 15e109f82ae7b9c6433d68cc0a0e3439e8ffee8d Mon Sep 17 00:00:00 2001 From: Stefan Pandekh Fumani <31704651+Pandekh@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:08:10 +0200 Subject: [PATCH 2/3] refactor: build the Antigravity plugin instead of vendoring skills Keep only the Antigravity manifest and MCP config in antigravity/ and assemble the plugin with the shared skills/ into dist/ at build time. This drops the second copy of every skill, the drift check workflow and the mirror step in the nightly skills sync. Rewrites the plugin description for the marketplace listing. Co-Authored-By: Claude Opus 5.5 --- .../workflows/check-antigravity-plugin.yml | 25 -- .github/workflows/sync-skills.yml | 6 +- README.md | 12 +- antigravity/plugin.json | 2 +- .../skills/pandadoc-approval-send/SKILL.md | 142 --------- .../pandadoc-contract-register/SKILL.md | 161 ---------- .../pandadoc-engagement-tracker/SKILL.md | 143 --------- .../skills/pandadoc-followup-list/SKILL.md | 151 --------- .../skills/pandadoc-invoice-handoff/SKILL.md | 191 ----------- .../skills/pandadoc-nda-fast-send/SKILL.md | 205 ------------ .../pandadoc-onboarding-agreement/SKILL.md | 262 --------------- .../skills/pandadoc-quote-builder/SKILL.md | 298 ------------------ .../skills/pandadoc-renewal-radar/SKILL.md | 154 --------- .../skills/pandadoc-signing-velocity/SKILL.md | 139 -------- scripts/build-antigravity-plugin.mjs | 109 +++---- scripts/package-plugin.mjs | 39 +-- 16 files changed, 64 insertions(+), 1975 deletions(-) delete mode 100644 .github/workflows/check-antigravity-plugin.yml delete mode 100644 antigravity/skills/pandadoc-approval-send/SKILL.md delete mode 100644 antigravity/skills/pandadoc-contract-register/SKILL.md delete mode 100644 antigravity/skills/pandadoc-engagement-tracker/SKILL.md delete mode 100644 antigravity/skills/pandadoc-followup-list/SKILL.md delete mode 100644 antigravity/skills/pandadoc-invoice-handoff/SKILL.md delete mode 100644 antigravity/skills/pandadoc-nda-fast-send/SKILL.md delete mode 100644 antigravity/skills/pandadoc-onboarding-agreement/SKILL.md delete mode 100644 antigravity/skills/pandadoc-quote-builder/SKILL.md delete mode 100644 antigravity/skills/pandadoc-renewal-radar/SKILL.md delete mode 100644 antigravity/skills/pandadoc-signing-velocity/SKILL.md diff --git a/.github/workflows/check-antigravity-plugin.yml b/.github/workflows/check-antigravity-plugin.yml deleted file mode 100644 index dd59c39..0000000 --- a/.github/workflows/check-antigravity-plugin.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Check Antigravity plugin - -# antigravity/skills is a copy of ./skills (see scripts/build-antigravity-plugin.mjs). Fails a PR -# that changes one without the other, or that breaks the Antigravity manifest schema. - -on: - pull_request: - paths: - - "skills/**" - - "antigravity/**" - - "scripts/build-antigravity-plugin.mjs" - - "scripts/lib/**" - -permissions: - contents: read - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: "lts/*" - - run: node scripts/build-antigravity-plugin.mjs --check diff --git a/.github/workflows/sync-skills.yml b/.github/workflows/sync-skills.yml index f39bddd..e9c7462 100644 --- a/.github/workflows/sync-skills.yml +++ b/.github/workflows/sync-skills.yml @@ -64,10 +64,6 @@ jobs: SYNC_NOTES_FILE: ${{ runner.temp }}/notes.md run: node scripts/sync-skills.mjs - - name: Mirror skills into the Antigravity plugin - if: steps.sync.outputs.changed == 'true' - run: node scripts/build-antigravity-plugin.mjs - - name: Validate plugin if: steps.sync.outputs.changed == 'true' run: npx --yes @anthropic-ai/claude-code@latest plugin validate . --strict @@ -103,7 +99,7 @@ jobs: { echo "Automated skills sync from \`pandadoc-studio/agentic-hub-skills\` (\`${SUMMARY}\`), bumping the plugin to **v${VERSION}**." echo - echo "Skills are vendored verbatim — do not hand-edit \`skills/\` or \`antigravity/skills/\`. Review and merge to publish." + echo "Skills are vendored verbatim — do not hand-edit \`skills/\`. Review and merge to publish." echo cat "${NOTES_FILE}" 2>/dev/null || true } > "${RUNNER_TEMP}/pr-body.md" diff --git a/README.md b/README.md index 895125f..534cc6b 100644 --- a/README.md +++ b/README.md @@ -256,17 +256,15 @@ The skills are now available — type `/` to browse them, or just describe your ## PandaDoc Antigravity Plugin -The same skills and connector are packaged as a [Google Antigravity plugin](https://antigravity.google/docs/plugins) in [`antigravity/`](antigravity/). Antigravity's manifest schema differs from the one at the repo root, so the plugin lives in its own directory. +The same skills and connector are packaged as a [Google Antigravity plugin](https://antigravity.google/docs/plugins). Antigravity's manifest schema differs from the one at the repo root, so its manifest and MCP config live in [`antigravity/`](antigravity/) and the plugin is assembled from them and the shared `skills/`. To install it with the Antigravity CLI: -1. Clone this repo. -2. Run `agy plugin install ./mcp-server-guide/antigravity`. -3. Open Antigravity's **Customizations** panel, click **Authenticate** on the PandaDoc MCP server, and authorize access to your account. Paste the authorization code back into the panel when prompted. +1. Clone this repo and run `node scripts/build-antigravity-plugin.mjs`. +2. Run `agy plugin install ./dist/antigravity/pandadoc`. +3. Open Antigravity's **Customizations** panel, click **Authenticate** next to the PandaDoc MCP server, and authorize access to your account. Paste the authorization code back into the panel when prompted. -The skills then appear as slash commands such as `/pandadoc-nda-fast-send`, or Antigravity picks them up when you describe a task. - -`antigravity/skills/` is a copy of `skills/`, regenerated by `node scripts/build-antigravity-plugin.mjs`. Do not edit it by hand. +The skills then appear as slash commands such as `/pandadoc-nda-fast-send`, or Antigravity picks them up when you describe a task. Add `--zip` to the build command to get a distributable archive in `dist/`. ## Support and Privacy diff --git a/antigravity/plugin.json b/antigravity/plugin.json index 75b9f09..971c3cd 100644 --- a/antigravity/plugin.json +++ b/antigravity/plugin.json @@ -1,5 +1,5 @@ { "$schema": "https://antigravity.google/schemas/v1/plugin.json", "name": "pandadoc", - "description": "Plugin that includes the PandaDoc MCP server and Skills for common workflows" + "description": "Create, send and track PandaDoc documents from your agent. Connects the PandaDoc MCP server and adds guided workflows for quotes, NDAs, approvals, onboarding paperwork, invoices, renewals and signing reports." } diff --git a/antigravity/skills/pandadoc-approval-send/SKILL.md b/antigravity/skills/pandadoc-approval-send/SKILL.md deleted file mode 100644 index 26eef76..0000000 --- a/antigravity/skills/pandadoc-approval-send/SKILL.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -name: pandadoc-approval-send -category: bizops -roles: [sales, bizops, ops] -complexity: advanced -prerequisites: [PandaDoc] -description: Create a document from a template and send it through its internal approval chain — read the approval steps, pick approvers for each, then send. Use when a document needs internal sign-off before it reaches the counterparty. -tags: [pandadoc, approvals, workflow, e-signature, bizops] -version: "0.1.0" ---- - -# PandaDoc Approval-Routed Send - -Send a document through internal approval instead of straight to the counterparty. - -**This skill is unverified.** The account it was written against has no approval workflow configured, so -every step below is derived from the tool schemas rather than observed behaviour. Treat the details as -provisional and correct them on first real use. - ---- - -## Prerequisites - -The PandaDoc MCP Server must be connected. Required tools: `templates_list`, `templates_details_get`, -`documents_create`, `documents_status_get`, `documents_details_get`, `documents_send`. Useful: -`documents_update` to fix an unassigned signature field before retrying a send. If they are missing, -say so and point at . - -An **approval workflow must be configured on the template or document** in PandaDoc. This skill cannot -create one. - ---- - -## How approvals appear in the API - -- `documents_details_get` returns **`approval_execution`**. On every document in the reference account this - was `null`, meaning no approval workflow applies. -- `documents_send` accepts **`selected_approvers`**, shaped as: - - ```json - { "steps": [ { "id": "", - "group": { "id": "", "type": "", - "assignees": [ { "user": "", "is_selected": true } ] } } ] } - ``` - - The tool description says to copy `steps` from the `approval_execution` section of document details and - set `is_selected: true` for the desired approver. -- `Waiting for Approval` and `Approved` are **system-set** statuses — `documents_status_change` cannot set - them. Only `Completed`, `Paid`, `Expired`, `Declined` can be set manually. - ---- - -## Workflow - -### Step 1: Create the document - -Find and inspect the template as normal — `templates_list` (remember `q` is fuzzy, so confirm the match), -then `templates_details_get` for `roles[]` and `tokens[]`. Create with `documents_create`, using the exact -role names, and `delivery_methods: {email: true, sms: false}`. - -Poll `documents_status_get` until `Draft`. - -### Step 2: Read the approval chain - -`documents_details_get` and look at `approval_execution`. - -**If it is `null`, there is no approval workflow on this document.** Say so plainly and ask whether to send -it directly. Do not pretend to route something that has no chain — and do not fabricate a `selected_approvers` -payload, which would either error or be ignored. - -If it is populated, extract the steps in order. For each step, list the group and the assignees available. - -### Step 3: Choose approvers - -Present the chain to the user before sending — step by step, with the candidates for each: - -> This document has 2 approval steps. Step 1: Finance — Anna or Piotr. Step 2: Legal — Katarzyna. Who should -> approve each? - -Only fill `is_selected: true` for people the user actually named. Picking a default approver silently is not -acceptable: an approval chain exists precisely so a named human signs off, and choosing on their behalf -defeats the control. - -If a step has exactly one candidate, you may select it, but say that you did. - -### Step 4: Send through the chain - -`documents_send` with `selected_approvers` built from the steps you read, plus `subject` and `message`. - -- `409 The document isn't ready for a status transition yet` — retry once. Known race even after `Draft`. -- `400 Not all signature, initials fields were assigned` — a signing role has no recipient; fix with - `documents_update` before retrying. - -After sending, poll `documents_status_get`. Expect `Waiting for Approval` rather than `Sent` while the chain -runs. Report the status you actually observed rather than assuming. - -### Step 5: Report - -State the document, the chain as configured, who was selected for each step, and the current status. Make -clear the document has **not** reached the counterparty yet if it is waiting on approval — that distinction -is the whole point of this skill. - ---- - -## Usage examples - -```txt -@pandadoc-approval-send — create the MSA for Acme from our standard template and route it for approval -``` - -```txt -@pandadoc-approval-send — this discount needs finance sign-off before it goes out -``` - ---- - -## Troubleshooting - -### `approval_execution` is `null` - -No approval workflow is attached to this document or its template. Say so and ask whether to send directly. -Workflows are configured in PandaDoc, not through the API. - -### The document went straight to `Sent` - -Either no chain applied, or `selected_approvers` was omitted or rejected. Check `approval_execution` again -and report what actually happened rather than what was intended. - -### `documents_status_change` will not set `Approved` - -Correct — `Sent`, `Viewed`, `Approved` are system-set. Only `Completed`, `Paid`, `Expired`, `Declined` can be -set manually. - -### I do not know which approver to pick - -Ask. Do not choose. If the chain offers several people, selecting one on the user's behalf silently -undermines the approval. - -### Everything in this file disagrees with what I observe - -Likely, on first real use — this skill was written against schemas rather than a live workflow. Record what -actually happened and correct it here. diff --git a/antigravity/skills/pandadoc-contract-register/SKILL.md b/antigravity/skills/pandadoc-contract-register/SKILL.md deleted file mode 100644 index 120b4f8..0000000 --- a/antigravity/skills/pandadoc-contract-register/SKILL.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -name: pandadoc-contract-register -category: legal -roles: [legal, finance, bizops] -complexity: advanced -prerequisites: [PandaDoc] -description: Build a register of signed contracts with their key terms — counterparty, dates, value and negotiated provisions — from PandaDoc's extracted metadata, labelled with how confident each value is. Use when someone wants a contract register, key terms pulled out, or all agreements for a customer. -tags: [pandadoc, contracts, metadata, legal, register] -version: "0.1.0" ---- - -# PandaDoc Signed-Contract Register & Key-Terms Extractor - -A table of what has been signed, with whom, when, for how much, and on what terms — assembled from -PandaDoc's own AI extraction. - -Two things shape everything below: **the extracted values are unconfirmed**, and **you cannot search by -them**. - ---- - -## Prerequisites - -The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_search`, -`documents_details_get`, `documents_metadata_get`. Strongly recommended: -`documents_metadata_batch_get`. If they are missing, say so and point at . - ---- - -## What extraction gives you, and what it costs - -`documents_metadata_get` returns AI-extracted fields. Observed on a completed contract: - -`Counterparty name`, `Counterparty address`, `Counterparty signer name`, `Internal party legal name`, -`Internal signer name`, `Agreement date`, plus term checkboxes — `Audit rights`, `Exclusivity`, -`HIPAA / BAA`, `Include SLA`, `Most favored customer`, `Data processing addendum incorporated`, -`Includes logo rights`, `Include special terms`, `Has referenced documents`. - -Three constraints that must reach the user: - -1. **Every value carries `acceptance_status`, and it was `pending` on every field of every sample.** That - means AI-extracted and not human-confirmed. A register presented as fact when nothing in it has been - reviewed is a liability in a legal context. Label it. -2. **Extraction runs only on completed documents, asynchronously.** A draft returns - `{code: "not_started", description: "Likely, your document is not yet completed."}`. In-progress - extraction returns `{code: "extraction_pending", retry_after: N}`. Failure returns `{code: "failed"}`. -3. **There is no monetary field.** Contract value is not in metadata — it comes from - `documents_details_get` → `pricing.total`. - ---- - -## The search limitation — say it out loud - -**Metadata is not queryable.** Neither `documents_metadata_get` nor `documents_metadata_batch_get` accepts a -filter; both read by document. There is no way to ask "which contracts have `Counterparty name = Acme`". - -So building a register for one customer means: - -1. narrow with `documents_search`, which is **full text and requires a non-empty `query`** -2. read metadata for each hit -3. keep the ones whose `Counterparty name` actually matches - -Step 1 is where the answer goes wrong. A search term that does not literally appear in a document will miss -it: `query: "contract"` returned **zero** hits in a workspace where the same documents matched -`query: "Agreement"` with four. And a customer whose name never appears in a document title or body will not -be found at all. - -**Therefore: always state what you searched on, and never claim the register is complete.** "Every contract -for Acme" is a promise you cannot keep. "Every contract matching a search for *Acme Corp*, checked against -the extracted counterparty" is one you can. - ---- - -## Workflow - -### Step 1: Establish the candidate set - -- **Whole register:** `documents_list` with `status: "Completed"`, then `status: "Paid"`, `count: 100`. No - search term needed, so no false negatives. -- **One customer:** `documents_search` with the customer name as `query`, `status: ["Completed", "Paid"]`. - Try the obvious variants — full legal name, short name, domain — and say which ones you tried. - -Do **not** use a `signature_date` window to establish "signed": that filter returns documents nobody signed. -Confirm signature per recipient instead (below). - -### Step 2: Confirm each one is genuinely signed - -For each candidate, `documents_details_get` and check `recipients[]`: at least one entry with -`recipient_type: "signer"` and a non-null `signature_date`. - -A document flipped with `documents_status_change` reads `Completed` with `date_completed` set, but every -`signature_date` is `null` and signature fields are `is_filled: false`. It is not a signed contract. Either -exclude it or list it separately as manually completed. - -### Step 3: Pull metadata - -Use `documents_metadata_batch_get` for 1–40 documents at a time rather than a call each. - -Handle the envelopes: retry once on `extraction_pending` after `retry_after`; on `not_started` or `failed`, -record the contract with its terms marked unavailable rather than dropping it silently. - -### Step 4: Add value - -`documents_details_get` → `pricing.total`, currency from `pricing.tables[].currency` — or from -`pricing.quotes[].currency` when `tables` is empty, which is how a Quote-block document reports it. -Reading only `tables` drops the currency on those, and an unlabelled amount is worse than none. - -**Never `grand_total`** — observed as `0 PLN` on a document worth 200,000 USD. Currency varies per document, -so do not total across currencies; subtotal per currency instead. - -### Step 5: Build the register - -One row per contract: counterparty, document name, agreement date, signature date, value with currency, and -the term flags that are set. Omit flags that are false rather than listing nine "no" columns. - -Then, in plain words: - -- how the candidate set was built, and the exact search terms if you used any -- that extracted values are **AI-derived with `acceptance_status: pending`**, not human-reviewed -- how many contracts had no extraction available -- that the register is **best-effort, not exhaustive**, if a search was involved - ---- - -## Usage examples - -```txt -@pandadoc-contract-register — build me a register of everything we've signed this year with values and key -terms -``` - -```txt -@pandadoc-contract-register — find every contract with Acme Corp and show each one's status and terms -``` - ---- - -## Troubleshooting - -### A customer I know we have contracts with returns nothing - -The search term did not appear in those documents. Metadata cannot be filtered, so the only entry point is -full text. Try the legal name, the trading name and the email domain, and say what you tried. - -### Metadata comes back `not_started` - -The document is not completed. Extraction only runs on completed documents. Nothing to do but wait. - -### Every field says `pending` - -Expected — that is the default `acceptance_status`. It means nobody has reviewed the extraction. Report the -values with that caveat rather than suppressing them. - -### Value is missing or 0 - -Contracts without a pricing table genuinely have no value in PandaDoc. If you see 0 on a contract you know -is worth something, you read `grand_total` instead of `pricing.total`. - -### A contract in the register turns out not to be signed - -Check `recipients[].signature_date`. `Completed` status alone does not mean signed — it can be set manually. diff --git a/antigravity/skills/pandadoc-engagement-tracker/SKILL.md b/antigravity/skills/pandadoc-engagement-tracker/SKILL.md deleted file mode 100644 index 6dfb8f6..0000000 --- a/antigravity/skills/pandadoc-engagement-tracker/SKILL.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: pandadoc-engagement-tracker -category: sales -roles: [sales, cs, owner] -complexity: intermediate -prerequisites: [PandaDoc] -description: Track per-recipient signing progress across documents that are out — who has signed, who has not, in what order, and which documents have stalled. Use when someone asks who is holding up a deal or what the state of play is across sent documents. -tags: [pandadoc, engagement, tracking, e-signature, sales] -version: "0.1.0" ---- - -# PandaDoc Deal-Room Engagement Tracker - -Who has signed, who has not, and what has gone quiet — across everything currently out. - -**Read the limitation below before using this.** In its current form this skill reports *signing* progress -only. It cannot tell you who opened a document. - ---- - -## Prerequisites - -The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_details_get`. -Optional and currently unavailable: `documents_audit_trail_get`. If the required tools are missing, say so -and point at . - ---- - -## Known limitation — no view events - -The intended version of this skill reports opens as well as signatures. That needs -`documents_audit_trail_get`, which currently returns: - -```txt -403 "Access to this resource was forbidden in PandaDoc." code: forbidden -``` - -on a real completed document, with a connection that reads every other document tool. It is unresolved -whether that is a plan entitlement, an OAuth scope or a workspace permission. Tracked against PD-111238. - -So: **attempt the audit trail once, and degrade cleanly when it fails.** Report signing progress from -`documents_details_get`, and state explicitly that open and view events are unavailable on this account. -Never present "has not signed" as "has not opened" — they are different facts and the second one is a -stronger signal a salesperson will act on. - -If the call ever starts succeeding, add the events to the timeline and drop the caveat. - ---- - -## Workflow - -### Step 1: Collect the documents that are out - -`documents_list` with `status: "Sent"`, then again with `status: "Viewed"`. One call per status — the -parameter takes a single string. Use `count: 100`. - -Include `Waiting for Approval` and `External Review` only if the user asks about internal hold-ups too, and -say which statuses you covered. - -If the user named one document or one counterparty, narrow to that instead of sweeping. - -### Step 2: Read per-recipient progress - -`documents_details_get` per document. From `recipients[]` take, for each person: - -- `first_name`, `last_name`, `email` -- `recipient_type` — **`signer` or `CC`.** A CC recipient is not expected to sign and always has - `signature_date: null`. Reporting a CC as "not signed" is noise; exclude them from the outstanding list - and mention them separately if at all. -- `has_completed` — whether that person is done -- `signature_date` — when they signed, `null` until they do -- `signing_order` — if set, signatures are sequential, so a later signer is not late, they are simply not up - yet. Say that rather than flagging them. - -Also check `fields[]`: a `signature` field with `is_filled: false` confirms an outstanding signature. - -### Step 3: Try the audit trail, once per document - -Call `documents_audit_trail_get`. If it returns 403, stop trying for the rest of the run — it is an account -level gate, not per document. Record once that view events are unavailable. - -If it succeeds, merge its events into the per-document timeline in chronological order and note the -timezone you are reporting in. - -### Step 4: Work out what has stalled - -Age each document from `date_sent` in `documents_details_get` — you already have the detail call. Do not use -`date_created`; a document that sat in draft would look stale when it is not. - -Flag as stalled when: - -- it has been out **more than 7 days** with no signature at all, or -- **some** signers have completed and others have not for more than 7 days — a partially signed document is - a stronger stall signal than an untouched one, because momentum existed and stopped - -With `signing_order` set, only flag the person whose turn it actually is. - -### Step 5: Report - -Per document: name, status, age since sent, and a line per signer — signed with a date, or outstanding. -Group or sort so stalls are at the top. - -Then state plainly: - -- which statuses you covered -- that age is measured from send -- **that open and view events are unavailable on this account**, if the audit trail returned 403 - ---- - -## Usage examples - -```txt -@pandadoc-engagement-tracker — who's holding up the Acme deal? -``` - -```txt -@pandadoc-engagement-tracker — anything gone quiet in the last couple of weeks? -``` - ---- - -## Troubleshooting - -### `403 forbidden` on the audit trail - -Expected on this account. Degrade to signing progress and say view events are unavailable. Do not retry per -document — the gate is account level. - -### Someone shows as not signed but says they signed - -Check `signing_order`. With sequential signing, a later signer cannot act until earlier ones finish. Also -check `recipient_type` — a CC recipient has no signature to give. - -### Everyone shows as outstanding on a completed document - -Confirm you read `recipients[]` from `documents_details_get` and not the recipient list echoed by -`documents_send`, which carries no `has_completed`. - -### A document was force-completed and shows no signatures - -`documents_status_change` to `Completed` sets `date_completed` but leaves every `signature_date` as `null` -and `is_filled: false`. Nobody signed it. Report it as manually completed rather than signed. diff --git a/antigravity/skills/pandadoc-followup-list/SKILL.md b/antigravity/skills/pandadoc-followup-list/SKILL.md deleted file mode 100644 index 9248064..0000000 --- a/antigravity/skills/pandadoc-followup-list/SKILL.md +++ /dev/null @@ -1,151 +0,0 @@ ---- -name: pandadoc-followup-list -category: sales -roles: [sales, bizops, owner] -complexity: intermediate -prerequisites: [PandaDoc] -description: Sweep documents that are still out for signature, rank them by how long they have been waiting and what they are worth, and draft a follow-up message for each. Use when someone asks what needs chasing, which documents are stuck, or wants nudges drafted. -tags: [pandadoc, follow-up, pipeline, e-signature, sales] -version: "0.1.0" ---- - -# PandaDoc Unsigned-Document Follow-up List - -Find what is still out, work out what deserves attention first, and write the nudge. Run it on a schedule -or on demand. - -**You cannot send the reminder.** Be clear about that from the start — see the boundary below. - ---- - -## Prerequisites - -The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_details_get`. -Optional: `documents_search`. If they are missing, say so and point at . - ---- - -## The boundary — what this skill cannot do - -There is **no reminder endpoint** in the PandaDoc MCP, and `documents_send` only accepts documents in -`Draft` status. A document that is already `Sent` therefore **cannot be re-sent or nudged through the API**. - -So this skill surfaces what is stale, ranks it, and **drafts the text**. The user sends it, or PandaDoc's -built-in auto-reminders do. Never say or imply that a reminder was sent. Never call `documents_send` on a -`Sent` document — it will fail, and trying suggests you misunderstood what you are doing. - ---- - -## Workflow - -### Step 1: Decide what "waiting" means, and say so - -Document status is a closed set of 15. The ones that plausibly mean "out and not finished": - -- **`Sent`** — delivered, not opened -- **`Viewed`** — opened, not signed -- **`Waiting for Approval`** — stuck in internal approval, not with the counterparty -- **`External Review`** — with the other side's reviewers -- **`Waiting for Payment`** — signed but unpaid, a different kind of chase - -Default to **`Sent` and `Viewed`** — those are unambiguously "waiting on the counterparty to sign". Include -the others only if the user's request implies them ("what's stuck anywhere", "including approvals"), and -**state in your output which statuses you counted.** A list whose scope is invisible cannot be trusted. - -Never include `Draft` (never sent), `Completed`, `Paid`, `Declined`, `Expired`. - -### Step 2: Collect them - -`documents_list` takes a **single** `status` string, so this is one call per status. `documents_search` -accepts `status[]` but requires a non-empty `query`, which would silently narrow the sweep — so prefer -several `documents_list` calls with `count: 100`. - -Note there is no sort parameter; default order is `date_created` descending. - -### Step 3: Age each document - -`documents_list` gives you `date_created` and `date_modified` for free. It does **not** give you -`date_sent` — that exists only in `documents_details_get`, one call per document. - -Choose deliberately: - -- **`date_sent`** is the honest measure of "how long it has been out", but costs one call per document. -- **`date_created`** is free but overstates the wait for anything that sat in draft first. - -Default to `date_sent` when the set is small (say under 30 documents) and to `date_created` above that, -and **say which you used.** If you used `date_created`, say the figure is time-since-created, not -time-since-sent — otherwise the user reads it as the latter. - -Bucket by age: **0–3 days / 4–7 / 8–14 / 15+**. Oldest first inside each bucket. - -### Step 4: Add value and recipient, if the user wants ranking - -Value and recipients live only in `documents_details_get`. If you are already calling it for `date_sent`, -take them in the same pass. - -- value → `pricing.total`, currency → `pricing.tables[].currency`, falling back to - `pricing.quotes[].currency` when `tables` is empty. A document built on a Quote block has its pricing - under `quotes[]` and an empty `tables[]`, so reading only `tables` silently loses the currency. -- **never `grand_total`** — it has been observed reading `0 PLN` on a document worth 200,000 USD -- rows with `options.optional_selected: false` are excluded from the total, so do not sum line items - yourself -- recipient → `recipients[]`, and `has_completed` per recipient tells you who is holding it up - -Rank by age first, value second. A 20-day-old $5k document usually needs the nudge more than a 2-day-old -$500k one, and the user can re-sort if they disagree. - -If you skipped the detail calls, say the list is unranked by value rather than reporting zeros. - -### Step 5: Draft a nudge per document - -One short message each, addressed to the recipient who has not completed. Reference the document by name, -say how long it has been waiting, and give them the signing link from `recipients[].shared_link` if -present. - -Keep them plain and short — the user will edit before sending. Do not invent urgency, deadlines or -commercial terms that were not in the document. - -### Step 6: Report - -Group by bucket, oldest first. For each: name, status, age, who has not signed, value if you have it, and -the drafted nudge. Then state plainly: - -- which statuses you counted -- whether age is time-since-sent or time-since-created -- that **nothing has been sent** and these are drafts for the user to send - ---- - -## Usage examples - -```txt -@pandadoc-followup-list — what needs chasing? -``` - -```txt -@pandadoc-followup-list — show everything stuck for more than a week, including anything waiting on -internal approval, and draft nudges -``` - ---- - -## Troubleshooting - -### The list is empty but I know documents are out - -Check which statuses you queried. A document sitting in `Waiting for Approval` or `External Review` is out -but not in the default `Sent`/`Viewed` set. - -### Ages look far too long - -You aged from `date_created` on documents that sat in draft. Re-run with `date_sent` from -`documents_details_get` for the affected ones. - -### Value shows as 0 - -You read `grand_total`. Use `pricing.total`. - -### `documents_send` fails on one of these - -Expected, and it should not have been attempted. `documents_send` requires `Draft`; a `Sent` document -cannot be nudged through the API at all. Draft the text and hand it to the user. diff --git a/antigravity/skills/pandadoc-invoice-handoff/SKILL.md b/antigravity/skills/pandadoc-invoice-handoff/SKILL.md deleted file mode 100644 index b3412d6..0000000 --- a/antigravity/skills/pandadoc-invoice-handoff/SKILL.md +++ /dev/null @@ -1,191 +0,0 @@ ---- -name: pandadoc-invoice-handoff -category: finance -roles: [finance, owner, sales] -complexity: advanced -prerequisites: [PandaDoc] -description: Turn a signed quote or proposal into an invoice — find quotes that completed and have not been invoiced yet, reuse their pricing, create the invoice from a template, and send it on confirmation. Use when someone asks to invoice a closed deal or wants signed quotes swept for invoicing. -tags: [pandadoc, invoicing, finance, quotes, e-signature] -version: "0.1.0" ---- - -# PandaDoc Quote → Invoice Handoff - -Find quotes that have been signed, build the matching invoice, send it. - -Two structural facts shape this: **there are no webhooks**, so this is a sweep rather than a trigger; and -**nothing marks a quote as invoiced**, so you have to establish that yourself or you will double-invoice. - ---- - -## Prerequisites - -The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_details_get`, -`templates_list`, `templates_details_get`, `documents_create`, `documents_status_get`, `documents_update`, -`documents_send`. If they are missing, say so and point at . - -**An invoice template must exist in the workspace.** The reference account has none, so this skill is -untested end to end. - ---- - -## No trigger, no marker - -**No webhooks.** "On completion, generate the invoice" cannot be event-driven through the MCP. Run this on a -schedule or on demand, and treat it as a sweep over recently completed quotes. - -**No invoiced flag.** PandaDoc does not record that a quote has been invoiced. Before creating anything you -must decide how you will know, and use the same method every run. Options, best first: - -1. **Tag the source quote** after invoicing — documents carry `tags`, settable via `documents_update`. A tag - like `invoiced` is durable, visible in the UI, and survives across runs. This is the recommended approach. -2. **Name convention** on the invoice, e.g. `Invoice - `, then search for it before creating. - Works, but depends on `documents_search` full text, which is fuzzy and eventually consistent. -3. **Ask the user each time.** Safe but does not scale to a scheduled sweep. - -Whichever you use, **say so in your output**, and if you cannot establish invoiced-or-not for a quote, ask -rather than assume. Sending a customer a second invoice for the same work is worse than sending none. - ---- - -## Workflow - -### Step 1: Find signed quotes - -`documents_list` with `status: "Completed"`, then `status: "Paid"`, `count: 100`. - -Narrow to actual quotes — those with a populated `pricing` block. A signed NDA has no value to invoice. - -**Confirm each was genuinely signed.** `documents_details_get` → `recipients[]` must contain a -`recipient_type: "signer"` with a non-null `signature_date`. A document flipped with -`documents_status_change` reads `Completed` but nobody signed it, and invoicing against it would bill a -customer for a deal that was never closed. - -### Step 2: Exclude anything already invoiced - -Apply the method from above. If tagging, skip quotes whose `tags` include your marker. - -### Step 3: Read the pricing to carry over - -From `documents_details_get` on the quote: - -- `pricing.tables[].items[]` — `name`, `qty`, `price`, `subtotal` per line -- `pricing.tables[].summary` — `subtotal`, `discount`, `tax`, `fee`, `total` -- `pricing.tables[].currency` -- `pricing.total` for the headline figure — **never `grand_total`**, which has been observed as `0 PLN` on a - document worth 200,000 USD - -**If `pricing.tables` is empty, look in `pricing.quotes` before concluding the quote has no line items.** -A document built on a Quote block keeps everything under `quotes[]`: `quotes[].sections[].items[]`, -`quotes[].summary`, `quotes[].currency`. Same data, different place. Reading only `tables` on such a -document yields no lines and no currency, and an invoice built from that is empty. - -**Skip rows with `options.optional_selected: false`.** They were offered and not taken, so they are excluded -from the quote's total and must not appear on the invoice. Carrying them over is the most likely way to -invoice more than the customer agreed to. - -### Step 4: Find the invoice template - -`templates_list` with `q: "invoice"`. Remember `q` is fuzzy — confirm the match rather than trusting a single -hit. Read `templates_details_get` for `roles[]`, `tokens[]` and the pricing table name. - -If there is no invoice template, **stop and say so.** Do not improvise one: markdown-created documents have -no signature fields and their recipient is silently downgraded to CC, so what you would produce is unusable. - -Two properties of that template decide whether you can proceed at all. Check both before Step 5: - -**Its pricing block must be a table, not a Quote.** If `pricing.tables` is empty and `pricing.quotes` is -populated, stop. `pricing_tables` is accepted and then silently ignored on such a template, so you would -create an invoice showing `total: "0"` with no error anywhere. A zero invoice that looks finished is worse -than no invoice. Say the template uses a Quote block and ask for one built on a pricing table. - -**Its currency must match the quote's.** `pricing.tables[].currency` is fixed by the template and cannot be -set through the API by any route. `documents_create` rejects a different one with -`400 Wrong request currency … mismatch template currency` and leaves a broken document behind. -`documents_update` is worse: it returns `{"updated": true}`, applies your row data, and **silently keeps the -template's currency** — so a skill that "fixes" the currency after the fact gets a success response and -reports a denomination the document does not have. If you ever write pricing, re-read -`pricing.tables[].currency` afterwards rather than trusting the call. Worse is the failure that does *not* error: omit the currency, and the -quote's numbers are written into the template's denomination unchanged, turning a 200,000 USD agreement -into a 200,000 PLN invoice that looks entirely normal. If the currencies differ, **stop and tell the -user** — name both currencies and ask for an invoice template in the quote's. Never convert the amounts -yourself; you have no rate and no authority to pick one. - -### Step 5: Create the invoice - -`documents_create` from the invoice template, passing the carried-over line items in `pricing_tables` under -the **exact** table name from Step 4. - -On the discount: `discount.name` decides whether your value replaces the template's or is added to it — -matching the template's name replaces, any other name appends. To reproduce the quote's discount exactly, -pass the quote's effective percentage under `"Discount"`, then **verify** (Step 6). Never pass a computed -delta. - -`options` is required on every row even though the schema marks it optional. - -Poll `documents_status_get` until `Draft`. - -### Step 6: Verify the invoice matches the quote - -`documents_details_get` on the invoice and compare against the quote, before showing anything: - -- same line items, same quantities, same unit prices -- `summary.subtotal` matches -- effective discount matches — and `summary.discount` is **not negative**, which would mean a surcharge -- `pricing.total` matches the quote's total -- currency matches - -If anything differs, correct it with `documents_update` and re-read. Reporting an invoice total you have not -compared against the quote is how a customer gets billed the wrong amount. - -### Step 7: Confirm, send, mark - -Show the comparison — quote total against invoice total — and ask before sending. Only on an explicit yes, -`documents_send`. Retry once on `409`. - -**After a successful send, apply your invoiced marker to the source quote** with `documents_update`. If you -skip this, the next sweep invoices it again. - -Report: the quote, the invoice, both totals, that they match, the recipient, and that the source quote is now -marked. - ---- - -## Usage examples - -```txt -@pandadoc-invoice-handoff — any signed quotes waiting to be invoiced? -``` - -```txt -@pandadoc-invoice-handoff — invoice the Acme Corp deal that just closed -``` - ---- - -## Troubleshooting - -### There is no invoice template - -Stop and say so. Generating one is not viable — markdown documents cannot be signed and `templates_create` -needs a PDF at a public HTTPS URL. - -### The invoice total is higher than the quote - -Most likely you carried over an optional row that was not selected. Those are excluded from the quote total. -Check `options.optional_selected` on every line. - -### The discount came out wrong - -`discount.name` did not behave as expected. Pass the target percentage under `"Discount"` and verify; correct -with `documents_update` using the direct value if it still differs. - -### A quote got invoiced twice - -The invoiced marker was not applied, or a different method was used between runs. Pick one method, apply it -after every send, and check it before every create. - -### I invoiced a deal that was not actually closed - -`Completed` status alone does not mean signed — it can be set manually with `documents_status_change`, which -leaves every `recipients[].signature_date` as `null`. Always check signature dates before invoicing. diff --git a/antigravity/skills/pandadoc-nda-fast-send/SKILL.md b/antigravity/skills/pandadoc-nda-fast-send/SKILL.md deleted file mode 100644 index 8b622a3..0000000 --- a/antigravity/skills/pandadoc-nda-fast-send/SKILL.md +++ /dev/null @@ -1,205 +0,0 @@ ---- -name: pandadoc-nda-fast-send -category: legal -roles: [sales, bizops, legal, owner] -complexity: intermediate -prerequisites: [PandaDoc] -description: Send a standard NDA to a counterparty in one motion — find the right NDA template, prefill the counterparty, make sure every signature is assigned, confirm, then send. Use when someone wants an NDA, mutual NDA or confidentiality agreement issued. -tags: [pandadoc, nda, confidentiality, e-signature, legal] -version: "0.1.0" ---- - -# PandaDoc NDA Fast-Send - -Turn "NDA for Acme, send to `legal@acme.com`" into a sent NDA, without the two failures that make a naive -attempt fail: picking the wrong template, and leaving a signature unassigned. - ---- - -## Prerequisites - -The PandaDoc MCP Server must be connected. Required tools: `templates_list`, `templates_details_get`, -`documents_create`, `documents_status_get`, `documents_details_get`, `documents_update`, -`documents_fields_assign`, `documents_send`. If they are missing, say the MCP Server is not connected -and point at . - -**An NDA template must exist in the workspace.** There is no way around this — see Step 1. - ---- - -## What the user provides - -- **Counterparty name** — required, for the document name and `Client.*` tokens. -- **Recipient email** — required. If missing, ask. -- **Who counter-signs on our side** — often unstated. Whether you need it depends on the template; see - Step 3. -- **Deadline or context** — optional, used in the send message. - ---- - -## Workflow - -### Step 1: Find the NDA template — and stop if there is none - -Call `templates_list` with `q: "NDA"` and `count: 10`. Then, because NDAs are also called other things, -try `q: "non-disclosure"` and `q: "confidentiality"` if the first returns nothing useful. - -**`q` is not a name substring match.** A search for "NDA" has returned a template called -`Declaration H&S standards and regulations`. So: - -- **One plausible match:** name it and its ID, and say you are using it. Do not assume it is right just - because it was the only hit. -- **Several:** list them and ask. Sending the wrong agreement to a counterparty is not a recoverable - mistake. -- **Nothing plausible:** say plainly that the workspace has no NDA template and that one needs creating in - PandaDoc first. **Do not improvise one.** A generated document cannot be signed — `documents_create` with - `source: "markdown"` produces `fields: []` and silently downgrades the recipient to `recipient_type: - "CC"`, so what you would hand over is an NDA-shaped document nobody can sign. That is worse than saying - no. - -Also refuse to proceed on a template that reads back as unusable: a public gallery template can answer -`templates_details_get` in full and still fail `documents_create` with `400 Template is not available.` - -### Step 2: Read the template - -Call `templates_details_get`. You need: - -- **`roles[]`** — every signing role and its exact name. There is no guaranteed "Signer"; the counterparty - role may be `Client`, `Recipient`, `Counterparty` or anything the author chose. Guessing drops the - recipient silently. -- **`preassigned_person` per role** — a role with this set fills itself. A role with `null` needs an - explicit recipient. -- **`tokens[]`** — usually `Client.Company`, `Client.FirstName`, `Client.LastName`, sometimes - `Sender.Company`. - -### Step 3: Work out the recipients - -Count the signing roles that have no `preassigned_person`: - -- **One** — the counterparty email is enough. Proceed. -- **Two or more** — you need a distinct email for each. The same address cannot occupy two roles; the API - returns `already in recipients list`. So ask: *"This template also has a `` signature block — - who signs on our side?"* Do not silently assign both roles to the counterparty, and do not collapse them - onto one person unless the user explicitly says this is a test or a self-sign. - - **Do not stop the turn on this question.** A missing counter-signer blocks *sending*, not *drafting* — - the two are not the same gate, and asking is never a reason to skip Step 4. In the same response, - proceed straight to Step 4 and create the document with the recipients you already have, then ask the - role question **alongside** the draft link, not instead of it. This applies whenever the user asked for - a draft, said "don't send yet", or "let me look first" — they want a document in front of them, and - replying with only a question leaves them with nothing to look at, which is the one thing they asked for. - The sole exception is a request to send outright: there the counter-signer answer has to come back before - anything else happens, because the send cannot proceed either way. - -Note that a template converted from a document in the PandaDoc UI **loses `preassigned_person`**, so a -template that looks like it should self-fill our side may not. - -### Step 4: Create - -```json -{ - "source": "template", - "template_uuid": "", - "name": "NDA - - ", - "recipients": [ - { "email": "", "first_name": "", "last_name": "", - "role": "", - "delivery_methods": { "email": true, "sms": false } } - ], - "tokens": [{ "name": "Client.Company", "value": "" }] -} -``` - -Use the **exact** role names read in Step 2, never a guessed "Signer". Always set `delivery_methods` to -email-only unless a phone number was given, or send fails with `A phone number is required`. - -### Step 5: Wait for draft - -Poll `documents_status_get` until it reads `Draft`. `documents_create` returns `Uploaded` first. - -Be aware `Draft` does not guarantee sendability — see Step 7. - -### Step 6: Check every signature is assigned — before sending - -Call `documents_details_get` and verify: - -1. **Every `signature` and `initials` field has a non-empty `assigned_to.id`.** An empty one belongs to a - role with no recipient, and `documents_send` will reject the whole document. If you find one, resolve it - per Step 3 — add the missing recipient with `documents_update`, or for a confirmed self-sign reassign - those field UUIDs with `documents_fields_assign`. -2. **No recipient has `sms: true` without a phone number.** Fix with `documents_update` if so. -3. The counterparty name landed in the tokens you set. - -This step is what turns three failed send attempts into one clean send. Do not skip it because the create -call succeeded — creation succeeding says nothing about sendability. - -### Step 7: Confirm, then send - -Always confirm first. Show the template name, the document name, and every recipient with their role: - -> Ready to send **"\"** to **\** as ``? *(and `` as ``)* - -Only on an explicit yes, call `documents_send` with a `subject` and short `message` built from the user's -context. - -**A yes has to come from the user.** Not from your own reasoning that sending is obviously what they -wanted, not from restating the question and answering it yourself. If no reply arrives, the answer is not -yes — leave the document as a draft, hand over the link -`https://app.pandadoc.com/a/#/documents/`, and say it is waiting on their go-ahead. That is a -complete, correct outcome, not a failure to finish: an NDA reaching a counterparty's inbox cannot be -recalled, and a draft costs nothing. The same applies whenever the user said not to send — draft, link, -stop. - -- `409 The document isn't ready for a status transition yet` — a known race even after `Draft`. Wait a - moment and retry **once**. Do not loop. -- `400 Not all signature, initials fields were assigned` — Step 6 was skipped or a role is still empty. Go - back rather than retrying blindly. - -Then report what happened, with the link -`https://app.pandadoc.com/a/#/documents/`. - ---- - -## Usage examples - -```txt -@pandadoc-nda-fast-send — send our standard NDA to legal@acme.com, they need it before Thursday's call -``` - -```txt -@pandadoc-nda-fast-send — mutual NDA for Globex to procurement@globex.com, I'll counter-sign as -serhii@ourco.com -``` - ---- - -## Troubleshooting - -### `400 Not all signature, initials fields were assigned` - -A signing role has no recipient. Read `roles[]` again, find the role whose signature field has an empty -`assigned_to`, add that recipient with `documents_update`, then retry. - -### `already in recipients list` - -The same email was put on two roles. Use distinct addresses. For a self-sign test, keep one recipient and -reassign the second role's field UUIDs with `documents_fields_assign` instead. - -### `400 Template is not available.` - -The template is readable but not usable — typically a public gallery template rather than one in the -workspace. Pick a workspace template. - -### `A phone number is required` - -A recipient defaulted to SMS delivery. `documents_update` with -`delivery_methods: {email: true, sms: false}`. - -### `409` on send - -The document is still settling after creation. Retry once. - -### The workspace has no NDA template - -Say so and stop. Generating one is not an option: markdown-created documents have no signature fields and -cannot be signed, and `templates_create` needs a PDF at a public HTTPS URL. diff --git a/antigravity/skills/pandadoc-onboarding-agreement/SKILL.md b/antigravity/skills/pandadoc-onboarding-agreement/SKILL.md deleted file mode 100644 index 527bd94..0000000 --- a/antigravity/skills/pandadoc-onboarding-agreement/SKILL.md +++ /dev/null @@ -1,262 +0,0 @@ ---- -name: pandadoc-onboarding-agreement -category: hr -roles: [hr, ops, owner] -complexity: intermediate -prerequisites: [PandaDoc] -description: Generate an onboarding or employment document from a template with the new joiner's details prefilled, make sure every field is assigned to them, then send for signature. Use when someone is onboarding a hire and needs paperwork issued. -tags: [pandadoc, hr, onboarding, employment, e-signature] -version: "0.1.0" ---- - -# PandaDoc Onboarding Agreement Generator - -Issue a new joiner's paperwork with their details already filled in, and nothing left unassigned. - -The difference from a plain send is **prefill**: HR templates carry many fields, and a document sent with -half of them blank puts the work back on the new hire. - ---- - -## Prerequisites - -The PandaDoc MCP Server must be connected. Required tools: `templates_list`, `templates_details_get`, -`documents_create`, `documents_status_get`, `documents_details_get`, `documents_fields_assign`, -`documents_update`, `documents_send`. If they are missing, say so and point at . - ---- - -## Handling personal data - -These documents carry personal information — full names, national ID numbers, addresses, sometimes -compensation. Two habits: - -- **Never reprint a national ID, PESEL, passport number, home address or salary figure in your replies.** - Refer to it, do not restate it: "national ID — filled from the value you gave", "PESEL — received, will - go in `Text2`". The user typed the number; they do not need it read back, and this transcript gets - logged, pasted into tickets and shared. One mention in their message is unavoidable, a second one is - yours to prevent. - - The easy place to slip is the field inventory in Step 2 and the summary in Step 6, where listing - field-and-value feels like diligence. `Text2 — can use 85******345 (ID)` is the mistake; write - `Text2 — national ID, from your message` instead. This holds even when you are only asking about - something else, such as a missing date. - - Masking is fine when a value genuinely has to be identified — `85******345` — but naming the field is - usually enough. -- **Never invent a value for a personal field.** If a national ID, address or start date is missing, ask. - A plausible-looking placeholder in an employment document is far worse than an unfilled field, because it - can be signed. - ---- - -## Workflow - -### Step 1: Find the template - -`templates_list` with `q` set to the document type — "onboarding", "employment", "offer", or whatever the -user said. `q` is **not** a name substring match, so a single hit is not proof of a correct match. HR -libraries in particular tend to hold near-duplicates: per contract type, per signer, per country. - -- **One plausible match:** name it and its ID, say you are using it. -- **Several:** list them and ask. The wrong variant means the wrong legal terms for that contract type. -- **Nothing plausible:** say the workspace has no such template. **Do not generate one** — a - markdown-created document has `fields: []` and its recipient is silently downgraded to - `recipient_type: "CC"`, so nobody can sign it. - -### Step 2: Read the template's fields and roles - -`templates_details_get`. You need: - -- **`roles[]`** — usually one signing role for HR paperwork (`Employee` is common), but check. Note any - `preassigned_person`. -- **`fields[]`** — every field, its `type` (`text`, `date`, `signature`, …), which role it is `assigned_to`, - and above all its **`merge_field`**. That last one decides everything below. -- **`tokens[]`** — template variables on the `Client.*` / `Sender.*` convention, separate from fields. - -**Only a field with a non-null `merge_field` can be prefilled through the API.** A field object carries four -identifiers — `uuid`, `name`, `field_id`, `merge_field` — and only the last one is a valid key for `fields`. -The other three are rejected: `field_id` and `name` are dropped silently by `documents_create` and produce -`400 There are no such fields in the document` from `documents_update`. Verified against -`8q5FqeodqheYCQBCBBBqCW` on 2026-08-06. - -So split `fields[]` in two before you promise anything: - -- **`merge_field` non-null** — prefillable. The merge name is the key. -- **`merge_field: null`** — not prefillable by any means. Nothing you pass will land. The recipient fills it - by hand after they open the document, or the template gets merge fields added in the PandaDoc editor. - -If every field you needed falls in the second group, **say so before creating anything**. Several HR -templates in this workspace are built that way (`7gocasE6Tr2GdSqae2HtwT`, -`riFxJ8qcGRpGJiukZVVBPc` — all fields `merge_field: null`, all named `Text`), and on those the prefill this -skill exists to do is simply not available. Offer the two real options — add merge fields to the template, -or send it blank for the joiner to complete — and let the user choose. Do not create an empty document and -present it as prefilled paperwork. - -Watch for a second trap: **one merge name fills every field that shares it, with the same value.** If name, -ID and city all sit behind one merge field, they cannot hold three different values, and the template needs -three distinct merge names before this skill can do its job. - -**Do not ask the user for values the signer supplies.** Use this to decide what to ask for upfront. It is a -hint for the question, not the send gate — the gate is the `is_filled` check in Step 5. - -- `signature` and `initials` fields are always the signer's. Never prefill them, never ask for them. -- Other fields (`text`, `date`, dropdowns) are usually the employer's to fill: name, national ID, address, - start date, department. A bare `date` on the signing role is often the sign date, but do not assume — an - empty one is surfaced in Step 5 and the user decides then. - -Ask the user for the employer-owned values you do not have. Do not block on a field whose owner is unclear: -prefill what you can and let Step 5 catch whatever is still empty. - -List for the user what you can prefill and what you need from them, and ask for the missing values in one -go rather than one at a time. When you write that list, name the field and its source — "national ID, from -the value you gave" — rather than reprinting the value itself. See *Handling personal data*. - -If something you genuinely need is missing, ask — but weigh it against what the user asked for. When they -wanted a draft, or said not to send yet, create the document with what you have and put the question next -to the draft link. A question on its own, with no document, is not what they asked for. - -### Step 3: Create with everything you can prefill - -`documents_create` from the template, passing: - -- `recipients` — the joiner, with `email`, `first_name`, `last_name`, the **exact** role name from Step 2, - and `delivery_methods: {email: true, sms: false}` -- `fields` — a map of **`merge_field`** to `{value: …}`, for the prefillable fields identified in Step 2 -- `tokens` — any template variables, as `[{name, value}]` - -Keys are merge names, not `field_id`, not `uuid`, not the `name` attribute. A key that matches nothing is -silently ignored rather than rejected, which is why Step 5 exists. - -Recipient tokens fill themselves: passing `recipients` populates `Client.FirstName`, `Client.LastName`, -`Sender.*` and `Document.CreatedDate` without your listing them. Pass explicitly only the ones that carry -values the recipient record does not, such as `Client.Company`. - -Poll `documents_status_get` until `Draft`. - -### Step 4: Assign anything left unassigned - -`documents_details_get` and check `fields[]`. Any field whose `assigned_to.id` is empty belongs to a role -with no recipient and will block sending. - -Fix with `documents_fields_assign`, passing the field UUIDs and the recipient. Note this tool **assigns -existing fields only** — it cannot create fields, so if the template lacks a field you need, the template has -to change in PandaDoc. - -### Step 5: Verify the prefill, and list every empty data field - -Still in `documents_details_get`, walk `fields[]` and confirm: - -1. **Every field you intended to prefill has `is_filled: true` and the value you passed.** A key that - matched no merge field is dropped silently — the create call succeeds and the field stays empty. This is - the most common failure and nothing surfaces it but a read-back. - - If a field came back empty, do not assume a typo and retry with a different key. Check `merge_field` on - that field first: when it is `null`, no key exists and retrying cannot work. Say the field is not - API-fillable and leave it for the empty-fields list below. -2. **Every `signature` and `initials` field has a non-empty `assigned_to.id`**, or `documents_send` rejects - the document with `Not all signature, initials fields were assigned`. -3. **No recipient has `sms: true` without a phone number.** - -Then build the send gate. This is a hard rule, not a judgement call, and it fails safe — treat a field as -empty unless `is_filled` is explicitly `true`: - -> Collect every field where `type` is not `signature` or `initials` **and** `is_filled` is not `true`. - -That is the list of empty data fields. Some may be fine to leave for the signer, some may be values the -employer should have supplied — MCP does not mark which, so do not guess. Every field on this list must be -resolved in Step 6 before the document can be sent: the user supplies a value or explicitly waives it. An -empty data field never leaves silently. - -### Step 6: Resolve empty fields, confirm, then send - -If the empty-data-field list from Step 5 is not empty, do not offer to send yet. Show it and ask the user -to clear it. Name each field; do not reprint values (see *Handling personal data*): - -> Before I can send, these fields are empty: `Salary`, `Start Date`. For each, give me a value or tell me to -> leave it for the signer to fill. - -Apply supplied values with `documents_update` (merge names as keys), re-read `documents_details_get`, and -rebuild the list. One exception: a field with `merge_field: null` cannot be filled through the API at all -(`documents_update` returns `400 There are no such fields in the document`), so a value the user gives for -it cannot land — it can only be waived, and the joiner fills it by hand after opening the document. A field -the user fills must now read `is_filled: true`; a waived field comes off the list. Only when every field on -the list is filled or waived do you offer to send. - -Then show the document name, the recipient and role, the count prefilled, and the fields being left blank on -purpose. Ask: - -> Ready to send **"\"** to **\**? \ fields prefilled, \ left blank by your choice -> (\), plus the signer's own fields (signature, and any others assigned to them). - -Only on an explicit yes, `documents_send` with a `subject` and short `message`. Retry once on `409 The -document isn't ready for a status transition yet`. - -**A yes has to come from the user.** Not from your own reasoning that sending is obviously what they -wanted, not from restating the question and answering it yourself. If no reply arrives, the answer is not -yes — leave the document as a draft, hand over the link, and say it is waiting on their go-ahead. That is -a complete outcome, not a failure to finish. Employment paperwork landing in a new hire's inbox before -anyone approved it is the worse error by a wide margin, and a draft costs nothing. The same applies -whenever the user said not to send. - -Report the outcome with the link `https://app.pandadoc.com/a/#/documents/`. - ---- - -## Usage examples - -```txt -@pandadoc-onboarding-agreement — onboarding paperwork for Anna Kowalska starting 1 September, send to -anna.kowalska@example.com -``` - -```txt -@pandadoc-onboarding-agreement — remote work application for Dzmitry, he's on an indefinite contract -``` - ---- - -## Troubleshooting - -### A field I supplied is empty on the document - -Read `merge_field` on that field before doing anything else. It decides which of two different problems you -have, and they have different fixes. - -- **`merge_field` is null.** The field is not addressable by the API at all. No key works — `field_id`, - `uuid` and `name` are all rejected, and `documents_update` answers - `400 There are no such fields in the document: `. Retrying with another key wastes the - user's time. Tell them the template needs a merge field added in the PandaDoc editor, or that the joiner - fills this one by hand. -- **`merge_field` is set but the value did not land.** Then the key really was wrong. Use the merge name - verbatim, including spaces and punctuation — merge names like `MM / DD / YYYY` are common — and set it - with `documents_update`. - -### Two fields need different values but share one merge field - -Not possible. A merge name addresses every field carrying it and writes the same value to all of them. The -template has to give those fields distinct merge names. Say so rather than filling one and leaving the other -wrong. - -### `400 Not all signature, initials fields were assigned` - -A signature field has an empty `assigned_to`. Assign it with `documents_fields_assign`, or add the missing -recipient with `documents_update` if a whole role is unrepresented. - -### `documents_fields_assign` will not create the field I need - -It only assigns existing fields. Add the field to the template in PandaDoc. - -### Several near-identical templates - -Normal for HR libraries — variants per contract type, per signer, per country. Ask rather than guessing; the -distinction is usually legal, not cosmetic. - -### `A phone number is required` - -The recipient defaulted to SMS delivery. `documents_update` with -`delivery_methods: {email: true, sms: false}`. - -### `409` on send - -Known race even after `Draft`. Retry once, do not loop. diff --git a/antigravity/skills/pandadoc-quote-builder/SKILL.md b/antigravity/skills/pandadoc-quote-builder/SKILL.md deleted file mode 100644 index 0cc2b54..0000000 --- a/antigravity/skills/pandadoc-quote-builder/SKILL.md +++ /dev/null @@ -1,298 +0,0 @@ ---- -name: pandadoc-quote-builder -category: sales -roles: [sales, bizops, owner] -complexity: advanced -prerequisites: [PandaDoc] -description: Build a priced PandaDoc quote or proposal from a template — fill the pricing table and variables, verify the totals and discount actually landed as asked, confirm, then send for signature. Use when someone wants a quote, proposal or priced offer created for a client. -tags: [pandadoc, quotes, proposals, pricing, e-signature, sales] -version: "0.1.1" ---- - -# PandaDoc Quote & Proposal Builder - -Turn "quote Acme for 200 licences with 10% off" into a real, correctly priced PandaDoc document, checked -before anyone sees it, and sent only on confirmation. - -The hard part is not creating the document. It is that **the API will accept your pricing and silently -produce different numbers than you asked for.** Most of this skill is about catching that. - ---- - -## Prerequisites - -The PandaDoc MCP Server must be connected. Required tools: `templates_list`, `templates_details_get`, -`documents_create`, `documents_status_get`, `documents_details_get`, `documents_update`, `documents_send`. -If they are missing, tell the user the MCP Server is not connected and point them at -. - ---- - -## What the user provides - -Extract from their message: - -- **Client name** — required. Used for the document name and the `Client.*` tokens. -- **Template** — a name or description ("our standard proposal"). If missing, ask; do not pick one. -- **Line items** — description, unit price, quantity for each. If prices are missing, ask. -- **Discount** — optional. Read carefully: "10% discount" almost always means *the client pays 10% less*, - not *add 10% to whatever the template already discounts*. See Step 3. -- **Recipient email** — needed only if sending. If the user only wants a draft, skip it. - ---- - -## Why this is not just create-with-pricing - -Four behaviours, all verified against the live MCP, that a naive flow gets wrong: - -1. **`discount.name` decides whether your discount replaces the template's or is added to it.** It is a - key, not a label. Verified on a template carrying 15%: passing `10` under the name `"Discount"` — the - name the template already uses — produced exactly **10%**, replacing it. Passing `10` under a new name - like `"Special promo"` produced **25%**, appending a second discount. Get the name wrong and the price - is wrong, with no error and a document that looks normal. -2. **`grand_total` is not the document's value.** It has been observed reading `{amount: "0", currency: - "PLN"}` on a document whose `pricing.total` was `200000` USD. Never quote `grand_total` to a user. -3. **Optional rows that are not selected are excluded from the total.** A row with - `options.optional_selected: false` contributes nothing, so a total computed by summing line items - yourself will disagree with the document. -4. **Some templates cannot be read at all.** `templates_details_get` can fail with - `PANDADOC_API_RESPONSE_INVALID` on a specific template, and every document created from it fails the - same way — while creation itself succeeds. So a successful create does not mean you can verify the - result. - ---- - -## Workflow - -### Step 1: Find the template - -Call `templates_list` with `q` set to the user's wording and `count: 10`. - -`q` is **not** a name substring match despite what the parameter description says — a search for "NDA" -has returned a template called `Declaration H&S standards and regulations`. So: - -- **One match:** report it by name and ID, and continue. Say which template you picked. -- **Several matches:** list name + ID and ask. Never guess — the wrong template means the wrong prices. -- **No matches:** broaden the term once, then list all templates and ask. - -**If the account has no suitable template, say so plainly and stop.** A priced document cannot be built -without one, and the workarounds that look available all fail. Verified 2026-08-06: - -- **`source: "markdown"` cannot carry pricing.** The parameter does not exist on that source — the schema - rejects `pricing_tables` outright — and writing a price table into the markdown body does not help: it - lands as an ordinary content table, `pricing` comes back `{}`, and `grand_total` is `0`. The numbers are - inert text. There is nothing to verify, no discount to apply, no currency. The document also has - `fields: []` and its recipient is silently downgraded to `recipient_type: "CC"`, so nobody can sign it. -- **`source: "file"` cannot either.** It takes no `pricing_tables`. `parse_form_fields` handles fields, not - pricing. -- **`templates_create` is not a way round it.** It builds a template from a PDF at a public HTTPS URL — - which a chat cannot produce — and a PDF-derived template does not carry a structured pricing table - anyway. - -So when the user says "just make me a quote" and there is no template, the honest answer is that one has to -exist first. Producing a markdown document with prices typed into it is the worst available outcome: it -looks like a quote, the totals are unverifiable, the discount was never applied, and it cannot be signed. -Do not do it, and do not offer it as a fallback. - -Two further requirements the template itself must meet — check them in Step 2 before promising anything: -its pricing block must be a **classic pricing table** rather than a Quote block, and it must already be in -the **currency** the user wants, because currency cannot be set through the API. - -### Step 2: Inspect the template — and handle it being unreadable - -Call `templates_details_get` with the chosen ID. You need three things from it: - -- **`pricing.tables[]`** — the name of the pricing table you will populate. You must pass this name - exactly; pricing is matched by name, not position. -- **`pricing.tables[].summary.discount` and `subtotal`** — the template's **existing** discount. Compute - it as a percentage: `summary.discount / summary.subtotal`. You need this number in Step 3. -- **`roles[]`** — who signs. Note whether any role has `preassigned_person` set; roles without it need an - explicit recipient. - -**If this call fails** with `PANDADOC_API_RESPONSE_INVALID`, stop and tell the user that template is -unreadable through the API and suggest another. Do not proceed — you cannot compute the discount delta -without the template's current discount, and you will not be able to verify the result afterwards either. - -If `pricing.tables` is empty, check `pricing.quotes` before saying anything — the two cases look alike and -are not: - -- **`tables` and `quotes` both empty** — the template genuinely has no pricing block. Say so and ask for - one that has. -- **`tables` empty but `quotes` populated** — the template uses a Quote block. It *has* pricing, but the - API cannot write to it: there is no `quotes` parameter on `documents_create`, and `pricing_tables` is - accepted and then **silently ignored**, leaving a document with the template's original empty rows and - `total: "0"`. Do not attempt it and do not report a total from such a document. Tell the user this - template's pricing block is a Quote and cannot be filled through the API, and ask for one built on a - classic pricing table. - -The second case is the dangerous one: nothing errors, so a quote that was never priced comes back looking -like a finished document worth nothing. - -Note the currency too. `pricing.tables[].currency` is fixed by the template and **cannot be overridden** -at creation — passing a different one fails with `Wrong request currency … mismatch template currency`. -If the user's amounts are in another currency, say so and ask for a template in theirs rather than -writing their numbers into the wrong denomination. - -### Step 3: Decide what the discount means, then pick the name accordingly - -This is the step that makes the difference — and it needs no arithmetic. - -Let `T` be the discount the user named and `D` the template's existing discount from Step 2. If `D` is -zero, pass `T` and move on. If `D` is non-zero, the request is ambiguous and only the user can settle it, -so ask: - -> This template already discounts `D`%. You asked for `T`% — should the client end up at `T`% overall, or -> `T`% on top of the existing `D`%? - -Then pick the discount **name** to match the answer, because the name is what controls composition: - -| The user wants | Pass | Under `discount.name` | -| --- | --- | --- | -| `T`% in total | `T` | `"Discount"` — the template's own name, which **replaces** it | -| `T`% on top of `D`% | `T` | any new name, e.g. `"Special promo"` — which **appends** | - -Do **not** compute a delta like `T − D`. That was an earlier misreading of this API and it produces a -surcharge when the name happens to match: passing `−5` under `"Discount"` on a 15% template yields a −5% -surcharge, not 10% off. - -One caveat that makes Step 6 mandatory: **the template's discount name cannot be read from -`templates_details_get`.** The template response carries `summary.discount` but leaves `items[].discounts` -empty — names only appear on created documents. `"Discount"` is the observed default and a sound first -guess, but it is a guess. Verify, then correct. - -Whichever reading you applied, **say so in your output.** - -### Step 4: Create the document - -Call `documents_create`: - -```json -{ - "source": "template", - "template_uuid": "", - "name": " - - ", - "recipients": [ - { "email": "", "first_name": "", "last_name": "", - "role": "", - "delivery_methods": { "email": true, "sms": false } } - ], - "tokens": [{ "name": "Client.Company", "value": "" }], - "pricing_tables": [{ - "name": "", - "options": { "currency": "USD", "discount": { "name": "Discount", "type": "percent", "value": "" } }, - "sections": [{ - "title": "Products", "default": true, - "rows": [{ - "data": { "name": "", "price": , "qty": }, - "options": { "optional": false, "optional_selected": false, "qty_editable": false } - }] - }] - }] -} -``` - -Two things that will otherwise cost you a round trip: - -- **`options` is required on every row**, even though the schema marks it optional. Omitting it returns - `400 validation_error` with `{"rows": [{"options": ["This field is required."]}]}`. -- Always set `delivery_methods` to email-only unless a phone number was supplied, or sending later fails - with `A phone number is required`. - -### Step 5: Wait for draft - -`documents_create` returns status `Uploaded`. Poll `documents_status_get` until it reads `Draft`. Usually -one or two calls. - -Be aware that `Draft` does **not** guarantee the document is ready for a status transition — see Step 7. - -### Step 6: Verify the numbers — do not skip this - -Call `documents_details_get` and check, before showing the user anything: - -1. **The effective discount matches what the user asked for.** Compute - `summary.discount / summary.subtotal` and compare against `T`. A **negative** `summary.discount` means - you produced a surcharge — the name did not behave as expected. Either way, if it does not match, - correct it with `documents_update` passing the target value directly, then re-read and check again. - Never report a figure you have not verified. -2. **Every line item is present** with the right `qty` and `price`, and `subtotal` per row is what you - expect. -3. **No row you intended to count has `options.optional_selected: false`.** If one does, its subtotal is - excluded from the total. -4. **Read the value from `pricing.total`**, never `grand_total`. -5. **Currency** comes from `pricing.tables[].currency`. - -Verifying the result rather than trusting the request is deliberate, and it has already earned its place: -the first version of this skill computed a delta on the assumption that discounts always add, and a live -run produced a 5% **surcharge** instead of a 10% discount. The verification step caught it and the -correction landed on the right number. Keep this step even if the composition rules change — it is correct -under any of them. - -### Step 7: Confirm, then send - -Show the user the numbers you verified — subtotal, discount as a percentage and an amount, total, -currency — plus which template and which discount reading you used. Then ask: - -> Ready to send **"\"** to **\**? Subtotal \, \% discount, total \ \. - -Only on an explicit yes, call `documents_send` with a `subject` and short `message`. - -If it returns `409 The document isn't ready for a status transition yet`, wait a moment and retry once. -This happens even when `documents_status_get` already reported `Draft` — it is a known race, not an error -in your input. - -If it returns `400 Not all signature, initials fields were assigned`, a signing role has no recipient. -Read `roles[]` again, add the missing recipient with `documents_update`, then retry. - -If the user does not want it sent, leave it as a draft and give them the link: -`https://app.pandadoc.com/a/#/documents/` - ---- - -## Usage examples - -```txt -@pandadoc-quote-builder — quote Acme Corp from our standard proposal: 200 Enterprise licences at $1000, -12 months support at $1500/mo, 10% discount. Send to procurement@acme.com -``` - -```txt -@pandadoc-quote-builder — build me a draft quote for Globex, 50 seats at $99, no discount. Don't send it. -``` - ---- - -## Troubleshooting - -### The total does not match what I calculated - -Check for optional rows. A row with `options.optional_selected: false` is excluded from -`summary.subtotal`. Also confirm you are reading `pricing.total` and not `grand_total`. - -### The discount came out higher than requested - -Your `discount.name` did not match the template's, so yours was appended rather than replacing it. -Re-issue with `name: "Discount"`, or correct the document with `documents_update` passing the target value -directly. - -### The discount came out negative — the total is higher than the subtotal - -You passed a negative value under a name that replaced the template's discount, so it became a surcharge. -Pass the target percentage as a positive number instead. Never pass a computed delta. - -### `PANDADOC_API_RESPONSE_INVALID` on the template or the document - -That template's data cannot be serialised by the API. It affects both the template and every document -created from it, though creation itself succeeds. Use a different template and report the broken one. - -### `400 validation_error` mentioning `options` - -Every pricing row needs an `options` object, even an effectively empty one. The schema says it is -optional; the API disagrees. - -### `grand_total` shows 0 - -Expected. It does not reflect the pricing table. Use `pricing.total`. - -### `409` on send - -The document is still settling after creation. Retry once. Do not loop. diff --git a/antigravity/skills/pandadoc-renewal-radar/SKILL.md b/antigravity/skills/pandadoc-renewal-radar/SKILL.md deleted file mode 100644 index 5a7e9d4..0000000 --- a/antigravity/skills/pandadoc-renewal-radar/SKILL.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -name: pandadoc-renewal-radar -category: legal -roles: [sales, legal, finance, owner] -complexity: advanced -prerequisites: [PandaDoc] -description: Digest of signed contracts approaching renewal or expiry, with counterparty, value and days remaining. Distinguishes a contract's own end date from the signing-link expiry, which are not the same thing. Use when someone asks what is coming up for renewal. -tags: [pandadoc, renewals, contracts, legal, reporting] -version: "0.1.0" ---- - -# PandaDoc Contract Renewal Radar - -Which signed contracts are coming up for renewal, what they are worth, and how long is left. - -**The trap in this skill is which date you use.** PandaDoc has three date concepts that look -interchangeable and are not. Getting it wrong produces a list that is confidently about the wrong thing. - ---- - -## Prerequisites - -The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_search`, -`documents_details_get`, `documents_metadata_get`. Useful: `documents_metadata_batch_get` to batch -metadata lookups instead of one call per document, and `documents_content_get` or -`documents_summary_get` for reading a term out of the text. If they are missing, say so and point at -. - ---- - -## Three dates, only one of which is a renewal date - -| Concept | Where | What it actually is | -| --- | --- | --- | -| `expiration_date` / `date_expiration` / `date_expired` | `documents_list`, `documents_details_get`, `documents_search` | **The signing link's expiry.** Set at send time — observed as `date_sent` + ~60 days. Meaningless once signed. | -| `effective_date` | `documents_search` filter column only | When the agreement takes effect. Closer to what you want, but it is a start date, not an end date. | -| `Agreement date` | `documents_metadata_get`, AI-extracted | The date on the face of the contract. Also a start, not an end. | - -**None of them is a renewal date.** PandaDoc does not store a contract end date as a field — the term -("two years from the effective date", "renews annually unless cancelled") lives in the contract text. - -So a renewal radar has to derive it. Be explicit with the user about how you did, because a derived date -presented as a stored one is misleading. - -**Never build this list from `expiration_date`.** That is the signing link, and on a signed contract it -tells you nothing. A list of "contracts expiring soon" built from it is simply wrong — and it will look -plausible, which is the problem. - ---- - -## Workflow - -### Step 1: Get the signed contracts - -Only `Completed` and `Paid` documents can be up for renewal. A `Draft` or `Sent` document is not a contract -yet. - -`documents_list` with `status: "Completed"`, then `status: "Paid"`, `count: 100`. - -Do **not** use `documents_search` with a `signature_date` window to establish "signed": that filter returns -documents nobody signed. It matches force-completed documents whose `recipients[].signature_date` is `null`. -If you need to confirm a document was genuinely signed, check `signature_date` per recipient in -`documents_details_get`. - -### Step 2: Establish a start date per contract - -In order of preference: - -1. `documents_metadata_get` → **`Agreement date`**. Only present on completed documents and extracted - asynchronously. Handle `{code: "extraction_pending", retry_after: N}` by retrying once, and - `{code: "not_started"}` or `{code: "failed"}` by falling back. -2. `documents_search` with `date_filter_column: "effective_date"` if you are narrowing rather than reading - per document. -3. `date_completed` as a last resort — the day it was signed. Say you used it. - -Every extracted value carries `acceptance_status`, which was `pending` on every sample — AI-extracted, not -human-confirmed. **Say so when you report a date that came from metadata.** - -### Step 3: Establish a term, and be honest about it - -The term is in the text. Read it with `documents_summary_get` (`summary_type: "detailed"`) or -`documents_content_get` and look for the duration and any auto-renewal language. - -Two warnings: - -- `documents_summary_get` returns a **markdown re-rendering of the document**, not a structured summary, and - on a document with empty fields it renders **placeholders as if they were terms** — `Effective Date: May - 12`, `Governing Law … State of (State)`. Do not read a term off a placeholder. If the value looks like a - template default, treat the term as unknown. -- If you cannot find a term, say the renewal date is unknown for that contract rather than assuming twelve - months. An invented renewal date is worse than a gap, because nobody will check it. - -Renewal date = start date + term. State the arithmetic you used per contract. - -### Step 4: Add counterparty and value - -- counterparty → `documents_metadata_get` → `Counterparty name`, with the same `acceptance_status` caveat -- value → `documents_details_get` → **`pricing.total`**, currency from `pricing.tables[].currency`, or - `pricing.quotes[].currency` when `tables` is empty — a Quote-block document keeps its pricing there -- **never `grand_total`** — observed as `0 PLN` on a document worth 200,000 USD - -`documents_metadata_batch_get` covers 1–40 documents per request and is worth using instead of a call each. - -### Step 5: Report - -Sort by days remaining, soonest first. Per contract: counterparty, name, start date and where it came from, -term and where it came from, computed renewal date, days left, value with currency. - -Then state: - -- that renewal dates are **derived**, not stored, and how -- which came from AI-extracted metadata with `acceptance_status: pending` -- how many contracts you could not date at all, so a short list is not read as a quiet pipeline - ---- - -## Usage examples - -```txt -@pandadoc-renewal-radar — what's up for renewal in the next 90 days? -``` - -```txt -@pandadoc-renewal-radar — weekly renewal digest with values -``` - ---- - -## Troubleshooting - -### The list is full of recently sent documents - -You used `expiration_date`. That is the signing link expiry, set at send time to roughly 60 days out, and it -has nothing to do with renewals. Rebuild from `Completed`/`Paid` and a derived contract end date. - -### A contract shows a term that matches the template default - -You read a placeholder rather than a filled value. `documents_summary_get` renders unfilled template fields -as though they were content. Treat the term as unknown. - -### Metadata returns `not_started` - -Extraction only runs on completed documents and is asynchronous. If the document is completed, retry after -the `retry_after` interval; otherwise fall back to `date_completed` and say so. - -### Value shows 0 - -You read `grand_total`. Use `pricing.total`. - -### A "signed" contract turns out to be unsigned - -It was flipped with `documents_status_change`, which sets `date_completed` but leaves every -`recipients[].signature_date` as `null`. Check per-recipient signature dates before treating a document as a -contract. diff --git a/antigravity/skills/pandadoc-signing-velocity/SKILL.md b/antigravity/skills/pandadoc-signing-velocity/SKILL.md deleted file mode 100644 index 2442740..0000000 --- a/antigravity/skills/pandadoc-signing-velocity/SKILL.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -name: pandadoc-signing-velocity -category: reporting -roles: [sales, owner, bizops] -complexity: advanced -prerequisites: [PandaDoc] -description: Build a signing pipeline report — status counts, average time to sign, value in flight, and documents that are stuck. Computed client-side with the sample size and method stated. Use when someone wants a weekly signing report or pipeline overview. -tags: [pandadoc, reporting, metrics, pipeline, e-signature] -version: "0.1.0" ---- - -# PandaDoc Signing Velocity & Pipeline Report - -Status counts, time to sign, value in flight, and what is stuck — as one report. - -Every number here is computed by you over a bounded set. **There is no aggregation endpoint**, so the -honesty of this report depends entirely on stating what went into it. - ---- - -## Prerequisites - -The PandaDoc MCP Server must be connected. Required tools: `documents_list`, `documents_details_get`. -If they are missing, say so and point at . - ---- - -## The rule for this skill - -**Every figure ships with its sample size and its method.** A single average with no denominator is worse -than no average, because it reads as authoritative. If you computed time-to-sign over four documents, say -"over 4 documents". If you capped the set, say where you stopped. - ---- - -## Workflow - -### Step 1: Count by status - -`documents_list` per status, `count: 100`. Statuses worth counting for a pipeline view: - -- **In flight:** `Sent`, `Viewed`, `Waiting for Approval`, `External Review` -- **Finished:** `Completed`, `Paid` -- **Dead or stalled:** `Declined`, `Expired`, `Rejected` -- **Not started:** `Draft` - -One call per status — the parameter takes a single string. If a status returns 100 results, more may exist; -say the count is a floor rather than a total. - -### Step 2: Average time to sign - -Only `Completed` and `Paid` documents have a duration. For each, you need: - -- `date_sent` — **only in `documents_details_get`**, one call per document -- `date_completed` — in both `documents_list` and details - -So the interval is `date_sent → date_completed`. Cap the number of detail calls (30 is reasonable) and say -if you capped. - -Three things to disclose, not bury: - -1. **The sample size.** "3.2 days over 7 documents", never just "3.2 days". -2. **In-flight documents are excluded**, because they have no completion date. That biases the average - **downward** — fast signers are over-represented and anything still dragging is invisible. Say so. -3. **Force-completed documents have no real signature.** A document flipped via `documents_status_change` - has `date_completed` set but every `recipients[].signature_date` as `null` and signature fields - `is_filled: false`. Its "time to sign" is meaningless. Check `signature_date` and exclude those, or - report them separately. - -If nothing qualifies, say the sample is empty. Do not produce a number. - -### Step 3: Value in flight - -Sum `pricing.total` across the in-flight statuses. - -- **Never `grand_total`** — it has been observed as `0 PLN` on a document worth 200,000 USD. -- Currency lives per table in `pricing.tables[].currency` and **varies per document**. When `tables` is - empty, read `pricing.quotes[].currency` instead — Quote-block documents keep their pricing there, and - looking only at `tables` leaves their amounts unlabelled. Do not add across currencies. Report per - currency, or report the dominant one and say what was excluded. -- Rows with `options.optional_selected: false` are excluded from the table total, so use `pricing.total` - rather than summing line items. -- Documents with no pricing table contribute nothing. Say how many had no value, so a small total is not - mistaken for a small pipeline. - -### Step 4: Stuck documents - -Age in-flight documents from `date_sent`. Flag anything out more than 14 days with no completion, and -anything partially signed — some `recipients[].has_completed: true`, others false — for more than 7 days. - -Respect `signing_order`: a later signer in a sequential flow is not late. - -### Step 5: Report - -Lead with the counts, then time to sign with its denominator, then value in flight per currency, then the -stuck list oldest first. Close with a short method note: which statuses, what was capped, what age is -measured from, and what was excluded and why. - ---- - -## Usage examples - -```txt -@pandadoc-signing-velocity — weekly signing report -``` - -```txt -@pandadoc-signing-velocity — how's the pipeline looking, and what's stuck? -``` - ---- - -## Troubleshooting - -### The average time to sign looks implausibly fast - -Check for force-completed documents. `documents_status_change` sets `date_completed` immediately, so a -document created and flipped in the same minute reports a near-zero duration. Exclude anything whose -`recipients[].signature_date` is `null`. - -### Value in flight looks far too low - -Either you read `grand_total` instead of `pricing.total`, or most in-flight documents genuinely have no -pricing table. Report the count of documents with no value so the two cases are distinguishable. - -### Totals across currencies do not add up - -They should not be added. `pricing.tables[].currency` varies per document — USD and PLN have both been seen -in the same account. Report per currency. - -### A status count seems capped - -`documents_list` maxes at `count: 100`. If a status returned exactly 100, treat it as a floor and page or -say so. - -### I cannot get `date_sent` without a call per document - -Correct — it exists only in `documents_details_get`. Cap the number of documents you expand and disclose the -cap rather than silently truncating the sample. diff --git a/scripts/build-antigravity-plugin.mjs b/scripts/build-antigravity-plugin.mjs index 3721c88..2eadbea 100644 --- a/scripts/build-antigravity-plugin.mjs +++ b/scripts/build-antigravity-plugin.mjs @@ -1,83 +1,76 @@ #!/usr/bin/env node /** - * build-antigravity-plugin.mjs — mirror ./skills into the Antigravity plugin at ./antigravity. + * build-antigravity-plugin.mjs — assemble the Google Antigravity plugin in dist/. * - * The repo root is the Claude / Agent Plugins plugin; Antigravity's plugin.json schema allows only - * `$schema`, `name` and `description` (additionalProperties: false), so it cannot share the root - * manifest and lives in its own directory. Skills are copied, not symlinked, so the directory stays - * self-contained when Antigravity stages or packages it on its own. + * Antigravity's plugin.json schema allows only `$schema`, `name` and `description` + * (additionalProperties: false), so it cannot share the root manifest. antigravity/ holds the + * Antigravity-only files; this script combines them with the shared ./skills. * * Usage: - * node scripts/build-antigravity-plugin.mjs # rewrite antigravity/skills from ./skills - * node scripts/build-antigravity-plugin.mjs --check # exit 1 if antigravity/ is out of date + * node scripts/build-antigravity-plugin.mjs # → dist/antigravity/pandadoc/ + * node scripts/build-antigravity-plugin.mjs --zip # also → dist/pandadoc-antigravity-plugin-v.zip + * + * The Antigravity manifest carries no version, so the zip takes it from .claude-plugin/plugin.json. */ +import { execFileSync } from "node:child_process"; import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { hashDirectory } from "./lib/hash.mjs"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const SOURCE_DIR = join(REPO_ROOT, "antigravity"); const SKILLS_SRC = join(REPO_ROOT, "skills"); -const PLUGIN_DIR = join(REPO_ROOT, "antigravity"); -const SKILLS_DEST = join(PLUGIN_DIR, "skills"); +const DIST_DIR = join(REPO_ROOT, "dist"); const MANIFEST_KEYS = new Set(["$schema", "name", "description"]); const NAME_PATTERN = /^[a-zA-Z0-9-_]+$/; -const check = process.argv.includes("--check"); - main(); function main() { - const errors = validateManifests(); - const srcSlugs = listSkills(SKILLS_SRC); - if (srcSlugs.length === 0) errors.push(`No skills found under ${SKILLS_SRC}`); - - if (check) { - const destSlugs = existsSync(SKILLS_DEST) ? listSkills(SKILLS_DEST) : []; - const missing = srcSlugs.filter((s) => !destSlugs.includes(s)); - const extra = destSlugs.filter((s) => !srcSlugs.includes(s)); - const stale = srcSlugs.filter( - (s) => destSlugs.includes(s) && hashDirectory(join(SKILLS_SRC, s)) !== hashDirectory(join(SKILLS_DEST, s)), - ); - if (missing.length) errors.push(`Missing from antigravity/skills: ${missing.join(", ")}`); - if (extra.length) errors.push(`Not in ./skills: ${extra.join(", ")}`); - if (stale.length) errors.push(`Out of date in antigravity/skills: ${stale.join(", ")}`); - if (missing.length + extra.length + stale.length) { - errors.push("Run `node scripts/build-antigravity-plugin.mjs` and commit the result."); - } - finish(errors, `antigravity/ is in sync (${srcSlugs.length} skills).`); - return; + const errors = []; + const manifest = readJson(join(SOURCE_DIR, "plugin.json"), errors); + const mcp = readJson(join(SOURCE_DIR, "mcp_config.json"), errors); + if (manifest) validateManifest(manifest, errors); + if (mcp) validateMcpConfig(mcp, errors); + const skills = listSkills(SKILLS_SRC); + if (skills.length === 0) errors.push(`No skills found under ${SKILLS_SRC}`); + if (errors.length) { + for (const e of errors) console.error(`ERROR: ${e}`); + process.exit(1); } - if (errors.length) finish(errors); - rmSync(SKILLS_DEST, { recursive: true, force: true }); - mkdirSync(SKILLS_DEST, { recursive: true }); - for (const slug of srcSlugs) { - cpSync(join(SKILLS_SRC, slug), join(SKILLS_DEST, slug), { recursive: true }); + const outDir = join(DIST_DIR, "antigravity", manifest.name); + rmSync(outDir, { recursive: true, force: true }); + mkdirSync(join(outDir, "skills"), { recursive: true }); + cpSync(join(SOURCE_DIR, "plugin.json"), join(outDir, "plugin.json")); + cpSync(join(SOURCE_DIR, "mcp_config.json"), join(outDir, "mcp_config.json")); + for (const slug of skills) cpSync(join(SKILLS_SRC, slug), join(outDir, "skills", slug), { recursive: true }); + console.log(`Built: ${outDir} (${skills.length} skills)`); + + if (process.argv.includes("--zip")) { + const { version } = JSON.parse(readFileSync(join(REPO_ROOT, ".claude-plugin", "plugin.json"), "utf8")); + const zipFile = join(DIST_DIR, `pandadoc-antigravity-plugin-v${version}.zip`); + rmSync(zipFile, { force: true }); + execFileSync("zip", ["-qr", zipFile, "."], { cwd: outDir, stdio: "inherit" }); + console.log(`Zipped: ${zipFile}`); } - finish([], `Mirrored ${srcSlugs.length} skills into antigravity/skills.`); } -function validateManifests() { - const errors = []; - const manifest = readJson(join(PLUGIN_DIR, "plugin.json"), errors); - if (manifest) { - const unknown = Object.keys(manifest).filter((k) => !MANIFEST_KEYS.has(k)); - if (unknown.length) errors.push(`antigravity/plugin.json: keys not allowed by the schema: ${unknown.join(", ")}`); - if (typeof manifest.name !== "string" || !NAME_PATTERN.test(manifest.name)) { - errors.push("antigravity/plugin.json: `name` is required and must match ^[a-zA-Z0-9-_]+$"); - } +function validateManifest(manifest, errors) { + const unknown = Object.keys(manifest).filter((k) => !MANIFEST_KEYS.has(k)); + if (unknown.length) errors.push(`antigravity/plugin.json: keys not allowed by the schema: ${unknown.join(", ")}`); + if (typeof manifest.name !== "string" || !NAME_PATTERN.test(manifest.name)) { + errors.push("antigravity/plugin.json: `name` is required and must match ^[a-zA-Z0-9-_]+$"); } - const mcp = readJson(join(PLUGIN_DIR, "mcp_config.json"), errors); - if (mcp) { - for (const [id, server] of Object.entries(mcp.mcpServers || {})) { - if (!server.serverUrl) errors.push(`antigravity/mcp_config.json: server "${id}" needs \`serverUrl\``); - for (const legacy of ["url", "httpUrl", "type"]) { - if (legacy in server) errors.push(`antigravity/mcp_config.json: server "${id}" uses unsupported key \`${legacy}\``); - } +} + +function validateMcpConfig(mcp, errors) { + for (const [id, server] of Object.entries(mcp.mcpServers || {})) { + if (!server.serverUrl) errors.push(`antigravity/mcp_config.json: server "${id}" needs \`serverUrl\``); + for (const legacy of ["url", "httpUrl", "type"]) { + if (legacy in server) errors.push(`antigravity/mcp_config.json: server "${id}" uses unsupported key \`${legacy}\``); } } - return errors; } function listSkills(dir) { @@ -95,11 +88,3 @@ function readJson(path, errors) { return null; } } - -function finish(errors, okMessage) { - if (errors.length) { - for (const e of errors) console.error(`ERROR: ${e}`); - process.exit(1); - } - console.log(okMessage); -} diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs index 80a41f1..5009a93 100644 --- a/scripts/package-plugin.mjs +++ b/scripts/package-plugin.mjs @@ -1,20 +1,15 @@ #!/usr/bin/env node /** - * package-plugin.mjs — build a distributable zip of the Claude or Antigravity plugin. + * package-plugin.mjs — build a distributable zip of the Claude plugin. * - * Claude (default): this repo's root is the plugin, so the zip includes only the plugin's own - * paths (.claude-plugin/, skills/, hooks/, .mcp.json) at the zip root — not the repo's other - * platform manifests or tooling. - * Antigravity: the contents of antigravity/ at the zip root. - * Uses `git archive`, so only committed files are included. + * This repo's root is the plugin, so the zip includes only the plugin's own paths + * (.claude-plugin/, skills/, hooks/, .mcp.json) at the zip root — not the repo's other + * platform manifests or tooling. Uses `git archive`, so only committed files are included. * * Usage: - * node scripts/package-plugin.mjs [ref] [--target claude|antigravity] - * ref defaults to HEAD; pass a tag e.g. v1.0.0 + * node scripts/package-plugin.mjs [ref] # ref defaults to HEAD; pass a tag e.g. v1.0.0 * - * Output: dist/pandadoc--plugin-v.zip (Claude keeps dist/pandadoc-plugin-v.zip) - * (+ prints sha256 for archive-source pinning). The Antigravity manifest carries no version, so - * both targets take it from .claude-plugin/plugin.json. + * Output: dist/pandadoc-plugin-v.zip (+ prints sha256 for archive-source pinning) */ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; @@ -23,21 +18,8 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const TARGETS = { - claude: { treeish: (ref) => ref, paths: [".claude-plugin/plugin.json", "skills", "hooks", ".mcp.json"], prefix: "pandadoc-plugin" }, - antigravity: { treeish: (ref) => `${ref}:antigravity`, paths: [], prefix: "pandadoc-antigravity-plugin" }, -}; - -const argv = process.argv.slice(2); -const targetIdx = argv.indexOf("--target"); -const targetName = targetIdx === -1 ? "claude" : argv[targetIdx + 1]; -const target = TARGETS[targetName]; -if (!target) { - console.error(`ERROR: unknown --target "${targetName}" (expected: ${Object.keys(TARGETS).join(", ")})`); - process.exit(1); -} -const positional = argv.filter((_, i) => targetIdx === -1 || (i !== targetIdx && i !== targetIdx + 1)); -const ref = positional[0] || process.env.REF || "HEAD"; +const PLUGIN_PATHS = [".claude-plugin/plugin.json", "skills", "hooks", ".mcp.json"]; +const ref = process.argv[2] || process.env.REF || "HEAD"; // Read the version from the ref itself, so the filename matches the archived content. const manifest = execFileSync( @@ -49,17 +31,16 @@ const version = JSON.parse(manifest).version; const outDir = join(REPO_ROOT, "dist"); mkdirSync(outDir, { recursive: true }); -const outFile = join(outDir, `${target.prefix}-v${version}.zip`); +const outFile = join(outDir, `pandadoc-plugin-v${version}.zip`); execFileSync( "git", - ["archive", "--format=zip", "-o", outFile, target.treeish(ref), ...target.paths], + ["archive", "--format=zip", "-o", outFile, ref, ...PLUGIN_PATHS], { cwd: REPO_ROOT, stdio: "inherit" }, ); const sha256 = createHash("sha256").update(readFileSync(outFile)).digest("hex"); console.log(`\nPackaged: ${outFile}`); -console.log(`Target: ${targetName}`); console.log(`Ref: ${ref}`); console.log(`Version: ${version}`); console.log(`sha256: ${sha256}`); From 4c50855e97347a82205e912471b7612aeb19e5f7 Mon Sep 17 00:00:00 2001 From: Stefan Pandekh Fumani <31704651+Pandekh@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:12:40 +0200 Subject: [PATCH 3/3] refactor: make the repo root the Antigravity plugin `agy plugin install https://github.com//` clones the repo root and accepts the root plugin.json; it only needs an mcp_config.json there to pick up the MCP server (Antigravity reads `serverUrl`, not `url`). Drop the antigravity/ directory and its build script. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +-- antigravity/plugin.json | 5 -- .../mcp_config.json => mcp_config.json | 0 scripts/build-antigravity-plugin.mjs | 90 ------------------- 4 files changed, 4 insertions(+), 102 deletions(-) delete mode 100644 antigravity/plugin.json rename antigravity/mcp_config.json => mcp_config.json (100%) delete mode 100644 scripts/build-antigravity-plugin.mjs diff --git a/README.md b/README.md index 534cc6b..f6e76f0 100644 --- a/README.md +++ b/README.md @@ -256,15 +256,12 @@ The skills are now available — type `/` to browse them, or just describe your ## PandaDoc Antigravity Plugin -The same skills and connector are packaged as a [Google Antigravity plugin](https://antigravity.google/docs/plugins). Antigravity's manifest schema differs from the one at the repo root, so its manifest and MCP config live in [`antigravity/`](antigravity/) and the plugin is assembled from them and the shared `skills/`. +The same skills and connector install as a [Google Antigravity plugin](https://antigravity.google/docs/plugins) straight from this repo: -To install it with the Antigravity CLI: +1. Run `agy plugin install https://github.com/PandaDoc/mcp-server-guide`. +2. Open Antigravity's **Customizations** panel, click **Authenticate** next to the PandaDoc MCP server, and authorize access to your account. Paste the authorization code back into the panel when prompted. -1. Clone this repo and run `node scripts/build-antigravity-plugin.mjs`. -2. Run `agy plugin install ./dist/antigravity/pandadoc`. -3. Open Antigravity's **Customizations** panel, click **Authenticate** next to the PandaDoc MCP server, and authorize access to your account. Paste the authorization code back into the panel when prompted. - -The skills then appear as slash commands such as `/pandadoc-nda-fast-send`, or Antigravity picks them up when you describe a task. Add `--zip` to the build command to get a distributable archive in `dist/`. +The skills then appear as slash commands such as `/pandadoc-nda-fast-send`, or Antigravity picks them up when you describe a task. Antigravity reads the MCP server from `mcp_config.json`. ## Support and Privacy diff --git a/antigravity/plugin.json b/antigravity/plugin.json deleted file mode 100644 index 971c3cd..0000000 --- a/antigravity/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://antigravity.google/schemas/v1/plugin.json", - "name": "pandadoc", - "description": "Create, send and track PandaDoc documents from your agent. Connects the PandaDoc MCP server and adds guided workflows for quotes, NDAs, approvals, onboarding paperwork, invoices, renewals and signing reports." -} diff --git a/antigravity/mcp_config.json b/mcp_config.json similarity index 100% rename from antigravity/mcp_config.json rename to mcp_config.json diff --git a/scripts/build-antigravity-plugin.mjs b/scripts/build-antigravity-plugin.mjs deleted file mode 100644 index 2eadbea..0000000 --- a/scripts/build-antigravity-plugin.mjs +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node -/** - * build-antigravity-plugin.mjs — assemble the Google Antigravity plugin in dist/. - * - * Antigravity's plugin.json schema allows only `$schema`, `name` and `description` - * (additionalProperties: false), so it cannot share the root manifest. antigravity/ holds the - * Antigravity-only files; this script combines them with the shared ./skills. - * - * Usage: - * node scripts/build-antigravity-plugin.mjs # → dist/antigravity/pandadoc/ - * node scripts/build-antigravity-plugin.mjs --zip # also → dist/pandadoc-antigravity-plugin-v.zip - * - * The Antigravity manifest carries no version, so the zip takes it from .claude-plugin/plugin.json. - */ -import { execFileSync } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const SOURCE_DIR = join(REPO_ROOT, "antigravity"); -const SKILLS_SRC = join(REPO_ROOT, "skills"); -const DIST_DIR = join(REPO_ROOT, "dist"); -const MANIFEST_KEYS = new Set(["$schema", "name", "description"]); -const NAME_PATTERN = /^[a-zA-Z0-9-_]+$/; - -main(); - -function main() { - const errors = []; - const manifest = readJson(join(SOURCE_DIR, "plugin.json"), errors); - const mcp = readJson(join(SOURCE_DIR, "mcp_config.json"), errors); - if (manifest) validateManifest(manifest, errors); - if (mcp) validateMcpConfig(mcp, errors); - const skills = listSkills(SKILLS_SRC); - if (skills.length === 0) errors.push(`No skills found under ${SKILLS_SRC}`); - if (errors.length) { - for (const e of errors) console.error(`ERROR: ${e}`); - process.exit(1); - } - - const outDir = join(DIST_DIR, "antigravity", manifest.name); - rmSync(outDir, { recursive: true, force: true }); - mkdirSync(join(outDir, "skills"), { recursive: true }); - cpSync(join(SOURCE_DIR, "plugin.json"), join(outDir, "plugin.json")); - cpSync(join(SOURCE_DIR, "mcp_config.json"), join(outDir, "mcp_config.json")); - for (const slug of skills) cpSync(join(SKILLS_SRC, slug), join(outDir, "skills", slug), { recursive: true }); - console.log(`Built: ${outDir} (${skills.length} skills)`); - - if (process.argv.includes("--zip")) { - const { version } = JSON.parse(readFileSync(join(REPO_ROOT, ".claude-plugin", "plugin.json"), "utf8")); - const zipFile = join(DIST_DIR, `pandadoc-antigravity-plugin-v${version}.zip`); - rmSync(zipFile, { force: true }); - execFileSync("zip", ["-qr", zipFile, "."], { cwd: outDir, stdio: "inherit" }); - console.log(`Zipped: ${zipFile}`); - } -} - -function validateManifest(manifest, errors) { - const unknown = Object.keys(manifest).filter((k) => !MANIFEST_KEYS.has(k)); - if (unknown.length) errors.push(`antigravity/plugin.json: keys not allowed by the schema: ${unknown.join(", ")}`); - if (typeof manifest.name !== "string" || !NAME_PATTERN.test(manifest.name)) { - errors.push("antigravity/plugin.json: `name` is required and must match ^[a-zA-Z0-9-_]+$"); - } -} - -function validateMcpConfig(mcp, errors) { - for (const [id, server] of Object.entries(mcp.mcpServers || {})) { - if (!server.serverUrl) errors.push(`antigravity/mcp_config.json: server "${id}" needs \`serverUrl\``); - for (const legacy of ["url", "httpUrl", "type"]) { - if (legacy in server) errors.push(`antigravity/mcp_config.json: server "${id}" uses unsupported key \`${legacy}\``); - } - } -} - -function listSkills(dir) { - return readdirSync(dir, { withFileTypes: true }) - .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, "SKILL.md"))) - .map((d) => d.name) - .sort(); -} - -function readJson(path, errors) { - try { - return JSON.parse(readFileSync(path, "utf8")); - } catch (e) { - errors.push(`${path}: ${e.message}`); - return null; - } -}