feat(partners): add partner-application-triage and partner-meeting-recap skills (#21819)

## What

Adds two Twenty partner-pipeline skills to
`twenty-partners/src/skills/`, plus a patch version bump.

### `twenty-partner-application-triage`
Ranks the partner-application backlog by net-new value and surfaces a
short chase-list of high-value applicants who haven't booked a call.
Read-only against the live partners workspace. Ships `rank.py` as its
scoring helper.

### `twenty-partner-meeting-recap`
After partner calls, pulls Fireflies meetings, matches each to an
existing Partner by attendee email/domain, writes a recap
(transcript-first, Fireflies summary as fallback), and injects it as a
Note linked to the partner via `NoteTarget`. Skips leads/discovery calls
(no Partner match) and meetings whose content isn't ready yet. Optional
`--prune` deletes the Fireflies recording once its recap is safely in
the CRM (confirmed first).

## Version
`twenty-partners` 0.5.4 → **0.5.5** (patch: additive skill docs, no app
behaviour change).

## Notes
- Both skills read credentials from `~/.twenty/credentials.env`; no
secrets committed.
- All GraphQL queries/mutations are the proven ones used against the
live workspace.
This commit is contained in:
Rashad Karanouh
2026-06-19 11:35:47 +04:00
committed by GitHub
parent ba7b435885
commit 0e97e1a908
4 changed files with 534 additions and 1 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "0.5.4",
"version": "0.5.5",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -0,0 +1,128 @@
---
name: twenty-partner-application-triage
description: Rank the partner-application backlog by net-new value and surface a short chase-list of high-value applicants who haven't booked a call. Use when the user wants to triage, rank, or prioritize partner applications, find which applicants are worth chasing, run the daily/weekly application review, or asks "who should I reach out to" / "which applications matter". Reads the live partners workspace; read-only.
trigger: /twenty-partner-application-triage
---
# twenty-partner-application-triage
Rank `APPLICATION`-stage partners by the value they would *add* — geographies and
languages we don't yet cover, plus proof of real Twenty work — and hand back a short
**chase-list**: the high-value applicants worth a personal nudge.
**The door stays open for everyone.** This skill does not reject or filter anyone out of
the pipeline. Booking a call is the motivation filter; this just makes sure the good
applicants who *didn't* book bubble up so they don't rot. The point is a few high-confidence
partners, not maximum coverage.
Read-only. It never mutates a record.
---
## Credentials
Needs `~/.twenty/credentials.env` (same file the other partner skills use):
```env
TWENTY_PARTNERS_API_URL=https://partners.twenty.com
TWENTY_PARTNERS_API_KEY=<your key>
```
The key lives in `packages/twenty-apps/internal/twenty-partners/.env.prod` (gitignored) —
copy it to `~/.twenty/credentials.env` on first setup.
---
## Phase 0 — Run the ranker
```bash
python3 "$(dirname "$0")/rank.py" # or: python3 rank.py from the skill dir
```
`rank.py` is the deterministic core. It pulls every partner, computes each applicant's
net-new geo / language / scope / skills vs the **VALIDATED** baseline, detects a "real
Twenty work" proof signal in the notes, scores, and prints ranked JSON. It does not call an
LLM — the judgment lives in you (Phase 1).
Each ranked entry: `name, score, tier (A/B/C), new_geo, new_lang, new_scope, new_skills,
proof{workspace_url|customers|migration}, team, contact_name, email, linkedin, website,
notes`.
Scoring (in `rank.py`, tune there if it drifts): geo +3 each, language +3 each, scope +1,
skills +1 capped at 3 (so generic dev shops that spray skill lists can't dominate), proof
+6. Any proof signal ⇒ at least tier A.
**Booking state:** if the JSON has `booking_state_wired: false`, the `callBookedAt` field
isn't on the model yet, so the ranker scores **all** applications. Say so in the output.
Once `callBookedAt` exists, the ranker auto-narrows to the un-booked (the true chase set) —
no skill change needed.
If the run prints a missing-credentials error, stop and tell the user exactly which key to
add and where.
---
## Phase 1 — Judgment pass (this is the point)
The score surfaces; you decide. Read the `notes` of the **top ~15** plus anything tier-B/C
with a non-trivial note, and adjust:
- **Rescue the motivated-but-unobvious.** Someone whose checkboxes are thin but whose notes
show real intent, a live Twenty instance, named customers, or a thoughtful pitch is a
chase even at a low score. This is the whole reason a human/LLM reads the notes — the
applicants who do things we don't see in the form are exactly who we don't want to lose.
- **Sanity-check volume inflation.** A high score driven by 6 net-new *languages* from one
solo, or a long skills list, may be aspirational. Confirm it against the notes before
ranking it top. Real net-new geography with proof beats a long list every time.
- **Demote noise.** Empty notes, agency spam, or "Tally submission: <id>" with nothing else
is tier C regardless of score. Don't chase them.
- **Note proof quality.** `workspace_url` + `customers` together (a live workspace with named
clients) is the strongest signal — stronger than the raw score. Call it out.
Don't invent facts. If a note is ambiguous, say so rather than upgrading on a guess.
---
## Phase 2 — Output the chase-list
A tight digest, grouped by tier, A first. Lead with the count and the booking-state caveat.
```
# Partner application triage — N applications ranked (booking state: not wired / un-booked only)
## Chase now (Tier A — fills a gap AND/OR proven)
- **<name>** (<team>) — +<geo>/<lang>; proof: <workspace+customers/…>
why: <one line, drawn from notes>
reach: <email> · <linkedin>
## Worth a look (Tier B)
- <name> — <one line>; reach: <email>
## Skip for now (Tier C) — <count>, not listed (empty/spam/no gap)
```
Rules:
- Tier A is the actual worklist. Keep it short — if it's 18 long, the proof-backed gap-fillers
go first and the volume-only ones go last.
- Always give a reach handle (email from `contact_name`/`email`, else linkedin, else website).
If none, say "no contact on record" — that itself is a data-quality flag.
- Be honest. If only a handful are genuinely worth chasing, say so; don't pad the A-tier.
---
## What this is not
- Not a gate. It never moves anyone to `REJECTED` or out of the funnel.
- Not a writer. It never edits a record. Surfacing only.
- Not the production cron. This is the **dev surface** for the ranking. Once the chase-list
is trustworthy, the deterministic core (`rank.py`) is what gets ported to a daily
logic-function cron in the partner app that writes `ranking` + a tier onto each un-booked
record so the workspace view sorts itself. The LLM judgment pass stays here, for the runs
where you want a human in the loop. Build skill → trust it → port the cheap part. Don't
build both.
## Self-check
`python3 rank.py --selftest` asserts the scoring orders a gap-filler-with-proof above a
skill-sprayer above an empty record, and that skill volume stays capped. Run it after any
edit to the weights or signal regexes.
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Deterministic ranker for the twenty-partner-application-triage skill.
Ranks APPLICATION-stage partners by NET-NEW value vs the current VALIDATED set:
geography and language we don't yet cover (weighted high), plus a "real Twenty
work" proof signal read from the application notes. Skill volume is deliberately
capped so generic dev shops that spray skill lists don't dominate.
Reads creds from ~/.twenty/credentials.env. Emits ranked JSON to stdout.
Run `rank.py --selftest` to verify scoring without hitting the API.
# ponytail: fixed weights + tier thresholds. Tune the WEIGHTS/THRESHOLDS dicts
# below if the ranking drifts; everything else is mechanical.
"""
import json
import os
import re
import sys
import urllib.request
WEIGHTS = {"geo": 3, "lang": 3, "scope": 1, "skill": 1, "skill_cap": 3, "proof": 6}
THRESHOLDS = {"A": 12, "B": 5} # >=A => A; >=B => B; else C. Any proof => at least A.
# "real Twenty work" signals in the free-text notes (the high-confidence axis).
PROOF_WORKSPACE = re.compile(r"https?://[^\s]*(twenty|crm)[^\s]*", re.I)
PROOF_CUSTOMERS = re.compile(r"(customers?\s+onboarded|real implementation|clients?\s+(moving|migrat)|delivered \d|named customer)", re.I)
PROOF_MIGRATION = re.compile(r"(switch\w*|migrat\w*|moved|replac\w*|our crm|own crm|we use twenty|dogfood|managed service)", re.I)
CRED_PATH = os.path.expanduser("~/.twenty/credentials.env")
def load_creds():
if not os.path.exists(CRED_PATH):
sys.exit(f"Missing {CRED_PATH}. Copy the partners key from the app's "
".env.prod into it (TWENTY_PARTNERS_API_URL / _API_KEY).")
env = {}
with open(CRED_PATH) as fh:
for line in fh:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
url = env.get("TWENTY_PARTNERS_API_URL")
key = env.get("TWENTY_PARTNERS_API_KEY")
if not url or not key:
sys.exit("credentials.env is missing TWENTY_PARTNERS_API_URL or TWENTY_PARTNERS_API_KEY.")
return url.rstrip("/"), key
def fetch_all_partners(url, key):
recs, after = [], None
while True:
path = f"{url}/rest/partners?limit=60&depth=1" + (f"&starting_after={after}" if after else "")
req = urllib.request.Request(path, headers={"Authorization": f"Bearer {key}", "User-Agent": "Mozilla/5.0"})
data = json.load(urllib.request.urlopen(req))
page = data["data"]["partners"]
recs += page
info = data.get("pageInfo", {})
if info.get("hasNextPage") and page:
after = info["endCursor"]
continue
return recs
def tok(value):
"""Flatten any nested value into a set of lowercased non-empty string tokens."""
out = set()
if value is None:
return out
if isinstance(value, str):
s = value.strip().lower()
if s:
out.add(s)
elif isinstance(value, list):
for item in value:
out |= tok(item)
elif isinstance(value, dict):
for item in value.values():
out |= tok(item)
else:
out.add(str(value).lower())
return out
def notes_str(rec):
v = rec.get("applicationNotes")
if isinstance(v, str):
return v
return "" if v is None else json.dumps(v)
def contact(rec):
persons = rec.get("persons") or []
if not persons:
return None, None, rec.get("linkedin")
p = persons[0]
name = p.get("name") or {}
full = " ".join(x for x in [name.get("firstName"), name.get("lastName")] if x) or None
email = (p.get("emails") or {}).get("primaryEmail")
linkedin = p.get("linkedinLink") or rec.get("linkedin")
return full, email, linkedin
def baseline(validated):
geo, lang, scope, skill = set(), set(), set(), set()
for r in validated:
geo |= tok(r.get("region")) | tok(r.get("country"))
lang |= tok(r.get("languagesSpoken"))
scope |= tok(r.get("partnerScope"))
skill |= tok(r.get("skills"))
return {"geo": geo, "lang": lang, "scope": scope, "skill": skill}
def score_one(rec, base):
new_geo = sorted((tok(rec.get("region")) | tok(rec.get("country"))) - base["geo"])
new_lang = sorted(tok(rec.get("languagesSpoken")) - base["lang"])
new_scope = sorted(tok(rec.get("partnerScope")) - base["scope"])
new_skill = sorted(tok(rec.get("skills")) - base["skill"])
notes = notes_str(rec)
proof = {
"workspace_url": bool(PROOF_WORKSPACE.search(notes)),
"customers": bool(PROOF_CUSTOMERS.search(notes)),
"migration": bool(PROOF_MIGRATION.search(notes)),
}
has_proof = any(proof.values())
score = (WEIGHTS["geo"] * len(new_geo)
+ WEIGHTS["lang"] * len(new_lang)
+ WEIGHTS["scope"] * len(new_scope)
+ WEIGHTS["skill"] * min(len(new_skill), WEIGHTS["skill_cap"])
+ (WEIGHTS["proof"] if has_proof else 0))
if has_proof or score >= THRESHOLDS["A"]:
tier = "A"
elif score >= THRESHOLDS["B"]:
tier = "B"
else:
tier = "C"
name, email, linkedin = contact(rec)
return {
"name": rec.get("name"),
"score": score,
"tier": tier,
"new_geo": new_geo,
"new_lang": new_lang,
"new_scope": new_scope,
"new_skills": new_skill,
"proof": {k: v for k, v in proof.items() if v},
"team": rec.get("typeOfTeam"),
"contact_name": name,
"email": email,
"linkedin": linkedin if isinstance(linkedin, str) else (linkedin or {}).get("primaryLinkUrl") if isinstance(linkedin, dict) else None,
"website": (rec.get("website") or {}).get("primaryLinkUrl") if isinstance(rec.get("website"), dict) else None,
"notes": notes.strip()[:400],
}
def rank(recs):
validated = [r for r in recs if r.get("validationStage") == "VALIDATED"]
apps = [r for r in recs if r.get("validationStage") == "APPLICATION"]
# Forward-compat: once callBookedAt exists, narrow to the un-booked (the chase set).
has_booked_field = any("callBookedAt" in r for r in recs)
if has_booked_field:
apps = [r for r in apps if not r.get("callBookedAt")]
base = baseline(validated)
ranked = sorted((score_one(r, base) for r in apps), key=lambda d: -d["score"])
return {
"validated_count": len(validated),
"application_count": len(apps),
"booking_state_wired": has_booked_field,
"coverage": {k: sorted(v) for k, v in base.items()},
"ranked": ranked,
}
def selftest():
base_recs = [{"validationStage": "VALIDATED", "country": ["france"],
"languagesSpoken": ["french", "english"], "partnerScope": ["development"],
"skills": ["react", "postgres"]}]
apps = [
{"validationStage": "APPLICATION", "name": "gap+proof", "country": ["germany"],
"languagesSpoken": ["german"], "applicationNotes":
"Live workspace https://crm.acme.de — customers onboarded: Foo GmbH"},
{"validationStage": "APPLICATION", "name": "skill-sprayer",
"skills": ["php", "vue", "kotlin", "swift", "laravel", "mongodb"]},
{"validationStage": "APPLICATION", "name": "empty"},
]
out = rank(base_recs + apps)["ranked"]
order = [r["name"] for r in out]
assert order == ["gap+proof", "skill-sprayer", "empty"], order
assert out[0]["tier"] == "A", out[0]
assert out[1]["score"] == WEIGHTS["skill_cap"], out[1] # 6 new skills capped at 3
assert out[2]["tier"] == "C", out[2]
print("selftest ok:", order)
if __name__ == "__main__":
if "--selftest" in sys.argv:
selftest()
else:
url, key = load_creds()
print(json.dumps(rank(fetch_all_partners(url, key)), indent=2, ensure_ascii=False))
@@ -0,0 +1,205 @@
---
name: twenty-partner-meeting-recap
description: Pull recent Fireflies partner meetings, match each to an existing Partner record by attendee email/domain, write a recap (transcript-first, Fireflies summary as fallback), and inject it as a Note on the partner's profile. Use after a batch of partner calls when you want each partner's CRM record updated with what was said. Read-only for leads/discovery calls (they have no Partner match and are skipped).
trigger: /twenty-partner-meeting-recap
---
# twenty-partner-meeting-recap
After partner calls: pull the Fireflies meetings, figure out which partner each one is (by matching an attendee to an existing Partner record), summarize the call, and drop that summary as a Note on the partner's profile. Runs end to end with no per-note confirmation.
Sibling of `twenty-partner-match`. This one is about **existing partners** (recap their calls), not about matching a lead to partners.
Optional `--prune`: after recaps are safely in the CRM, delete the corresponding Fireflies recordings to free storage (confirmed first — see Phase 6).
---
## Credentials
Reads `~/.twenty/credentials.env`:
```env
TWENTY_PARTNERS_API_URL=https://partners.twenty.com
TWENTY_PARTNERS_API_KEY=<your key>
FIREFLIES_API_KEY=<your key>
```
All three are required. The partners key lives in `packages/twenty-apps/internal/twenty-partners/.env.prod` (gitignored); the Fireflies key is your personal API key. Stop cleanly and name the missing key if any is absent.
---
## Phase 0 — Prerequisites
Read `~/.twenty/credentials.env`. Verify `TWENTY_PARTNERS_API_URL`, `TWENTY_PARTNERS_API_KEY`, `FIREFLIES_API_KEY` are all present. If one is missing, stop and tell the user exactly which key to add and where.
---
## Phase 1 — Pull meetings
Default scope: **meetings from the last 2 days** (covers "yesterday"). The user can override per run — "last week", a date, or by pasting specific Fireflies URLs/IDs (the ID is the trailing `01K...` segment of `app.fireflies.ai/view/<slug>::<ID>`).
List recent transcripts, then keep only those inside the scope window (Fireflies `date` is epoch milliseconds). For each kept transcript, fetch its detail (attendees + summary + sentences). See **Reference queries** for the exact GraphQL. Add a `User-Agent` header to every Fireflies request — the API rejects the default urllib agent.
`transcripts(limit:)` is **capped at 50** by Fireflies — a higher value is a hard `invalid_arguments` 400, not a soft clamp. Use 50 and page if you ever need more.
If two transcripts share the same partner and day (Fireflies sometimes double-records), keep the one with more sentences.
---
## Phase 2 — Match each meeting to a Partner
Only meetings tied to an existing Partner get processed. Leads/discovery calls (no Partner match) are skipped and listed at the end.
1. Page through all partners once, pulling each partner's linked person emails and company domain (see Reference queries). Build two maps:
- `email -> partner` from every `persons.edges.node.emails.primaryEmail`
- `domain -> [partners]` from each partner's `company.domainName.primaryLinkUrl`
2. For each meeting, take the attendee emails, drop anything `@twenty.com` and the host/organizer (that's the Twenty side). For each remaining attendee email:
- **Exact email match** against `email -> partner` wins (strongest signal).
- Else **domain match** against `domain -> [partners]`, skipping free providers (`gmail.com`, `outlook.com`, `hotmail.com`, `yahoo.com`, `icloud.com`, `proton.me`, etc.). If exactly one partner shares the domain, match it. If several do, **flag in the report and skip** rather than guess.
3. The meeting title is a secondary hint only (`Partner intro between … and <name>` was the historical convention) — never the primary matcher.
A meeting with no Partner match is a lead/other call: skip it, record it under "skipped" with the reason.
---
## Phase 3 — Summarize (transcript first)
For each matched meeting, pick the better source. **Judge content quality first — never write a note from nothing or from noise:**
- A transcript is **usable** only if it has real content: roughly `>= 15` sentences **and** an average of `>= 4` words per sentence. A handful of one-word lines (`Platform.` `Opportunity.` `Background.`) is garbled ASR, not a transcript — treat it as unusable even though the array is non-empty.
- **Usable transcript** → write the recap yourself from the transcript. This is the default whenever a usable transcript exists.
- **No usable transcript, but a Fireflies summary exists** → fall back to `summary.overview` (try richer fields, then `short_summary`). Note the fallback in the source line.
- **Neither** (no usable transcript AND empty summary) → the call is **still processing or unrecorded**. Skip it, record it under "skipped: content not ready", and move on. Today's calls often land here for a while after they end. Never inject an empty or placeholder note.
Write the note in English, structured (the format validated previously). No em dashes — use `:` or `,`.
```
**TL;DR:** one-line verdict / state of the relationship.
**Profil:** team size, location, languages, structure.
**Compétences Twenty:** deployment (cloud / self-host), data model, migrations, what they've actually shipped.
**Contexte:** background, how they found Twenty, motivation, target clients, current partnerships.
**Next steps:** concrete follow-ups (who owes what).
**Flags:** risks, unknowns, ASR artifacts to double-check.
Source: Fireflies <transcript-id> (call <YYYY-MM-DD>, transcript|summary).
```
The `Source: Fireflies <transcript-id>` line is load-bearing: it is the dedup key for re-runs. Always include the real transcript id.
---
## Phase 4 — Inject the Note (automatic)
For each matched meeting, before writing, check whether this meeting was already noted:
- Read the partner's existing notes (noteTargets filtered by `targetPartnerId`). Look for a note whose body contains `Fireflies <transcript-id>`.
- **No such note** → create one: `createNote` with `bodyV2.markdown`, then `createNoteTarget` linking `noteId` to `targetPartnerId`. Title: `Partner call recap: <Partner name> (<YYYY-MM-DD>)`.
- **Note already exists** → regenerate the recap, diff it against the existing body, and **append only net-new information** under a dated `**Update <YYYY-MM-DD>:**` block via `updateNote` (`bodyV2.markdown` = existing body + the new block). If nothing is new, leave it untouched.
No confirmation step — match, summarize, write. Then verify each write by reading the note back and confirming the partner link resolved.
---
## Phase 5 — Report
Print one table:
| Meeting (date · title) | Attendee matched | Partner | Action |
|---|---|---|---|
`Action` is one of: `created`, `updated (appended)`, `unchanged`, `skipped: no partner match`, `skipped: ambiguous domain (N partners)`, `skipped: content not ready`. End with counts (`created / updated / unchanged / skipped`).
---
## Phase 6 — Prune (`--prune`, opt-in, deletes Fireflies recordings)
Runs only when invoked with `--prune` (Fireflies storage fills up; recordings whose content is already safe in the CRM are dead weight). **Deletion is irreversible and on an external service — always confirm before deleting.**
A recording is **safe to prune** only when its recap note is confirmed written this run (`created` or `updated`) **or** already exists in the CRM with this transcript's `Fireflies <id>` in its body. Never prune a meeting that was skipped, has no note, or whose note you could not verify — losing the recording would lose the only copy.
1. Build the prune set from this run's safe meetings (plus, if asked to "free more", existing recap notes whose `Fireflies <id>` you can resolve to a still-present transcript).
2. **Present the exact list** (partner, transcript id, date) and get explicit confirmation. Default to keeping the most recent unless told otherwise.
3. Delete each confirmed transcript with `deleteTranscript(id:)`, then **verify** by re-listing and confirming the ids are absent. Report `deleted N/M` and how many transcripts remain.
Matching old notes back to transcripts: yesterday's notes embed only a date, not the id, so fall back to the meeting-title person name (`Partner intro between … and <name>` / `… - <name> x Rashad`) against the partner name in the note title. New notes written by this skill carry `Fireflies <id>` in the body, so the mapping is exact going forward.
---
## Reference queries
All partner calls go to `$TWENTY_PARTNERS_API_URL/graphql` with `Authorization: Bearer $TWENTY_PARTNERS_API_KEY`. All Fireflies calls go to `https://api.fireflies.ai/graphql` with `Authorization: Bearer $FIREFLIES_API_KEY` **and** a browser `User-Agent`. Helper:
```python
import os, json, urllib.request
creds = {}
for line in open(os.path.expanduser("~/.twenty/credentials.env")):
line = line.strip()
if line and "=" in line and not line.startswith("#"):
k, v = line.split("=", 1); creds[k] = v.strip()
def gql(url, key, query, variables=None):
body = json.dumps({"query": query, "variables": variables or {}}).encode()
req = urllib.request.Request(url, data=body, headers={
"Content-Type": "application/json",
"Authorization": "Bearer " + key,
"User-Agent": "Mozilla/5.0"})
return json.load(urllib.request.urlopen(req, timeout=90))
```
**Fireflies — list recent transcripts** (`date` is epoch ms; `limit` max 50):
```graphql
query{ transcripts(limit:50){ id title date duration participants meeting_attendees{ displayName email } } }
```
**Fireflies — delete a recording (`--prune` only):**
```graphql
mutation($id:String!){ deleteTranscript(id:$id){ id title } }
```
**Fireflies — one transcript's detail:**
```graphql
query($id:String!){ transcript(id:$id){
title date duration participants host_email organizer_email
meeting_attendees{ displayName email }
summary{ overview short_summary keywords }
sentences{ speaker_name text } } }
```
**Partners — page through all with person emails + company domain:**
```graphql
query($a:String){ partners(after:$a){
pageInfo{ hasNextPage endCursor }
edges{ node{
id name slug validationStage
persons{ edges{ node{ name{ firstName lastName } emails{ primaryEmail } } } }
company{ name domainName{ primaryLinkUrl } } } } } }
```
Paginate until `pageInfo.hasNextPage` is false, passing `endCursor` as `$a`.
**Partner — existing notes (dedup check):**
```graphql
query($pid:UUID!){ noteTargets(filter:{ targetPartnerId:{ eq:$pid } }){
edges{ node{ note{ id title bodyV2{ markdown } createdAt } } } } }
```
**Write a note and link it to the partner:**
```graphql
mutation($d:NoteCreateInput!){ createNote(data:$d){ id title } }
# variables: { "d": { "title": "...", "bodyV2": { "markdown": "..." } } }
mutation($d:NoteTargetCreateInput!){ createNoteTarget(data:$d){ id targetPartnerId } }
# variables: { "d": { "noteId": "<note id>", "targetPartnerId": "<partner id>" } }
```
**Append to an existing note (re-run path):**
```graphql
mutation($id:UUID!,$d:NoteUpdateInput!){ updateNote(id:$id,data:$d){ id } }
# variables: { "id": "<note id>", "d": { "bodyV2": { "markdown": "<existing + new block>" } } }
```
**Verify a write:**
```graphql
query($id:UUID!){ note(filter:{ id:{ eq:$id } }){
id title noteTargets{ edges{ node{ targetPartnerId targetPartner{ name } } } } } }
```