diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index 2041bbfc6d..d441801312 100644 --- a/packages/twenty-apps/internal/twenty-partners/package.json +++ b/packages/twenty-apps/internal/twenty-partners/package.json @@ -1,6 +1,6 @@ { "name": "twenty-partners", - "version": "0.5.4", + "version": "0.5.5", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/internal/twenty-partners/src/skills/twenty-partner-application-triage/SKILL.md b/packages/twenty-apps/internal/twenty-partners/src/skills/twenty-partner-application-triage/SKILL.md new file mode 100644 index 0000000000..b41deef9ca --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/skills/twenty-partner-application-triage/SKILL.md @@ -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= +``` + +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: " 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) +- **** () — +/; proof: + why: + reach: · + +## Worth a look (Tier B) +- ; reach: + +## Skip for now (Tier C) — , 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. diff --git a/packages/twenty-apps/internal/twenty-partners/src/skills/twenty-partner-application-triage/rank.py b/packages/twenty-apps/internal/twenty-partners/src/skills/twenty-partner-application-triage/rank.py new file mode 100644 index 0000000000..10b8c33f83 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/skills/twenty-partner-application-triage/rank.py @@ -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)) diff --git a/packages/twenty-apps/internal/twenty-partners/src/skills/twenty-partner-meeting-recap/SKILL.md b/packages/twenty-apps/internal/twenty-partners/src/skills/twenty-partner-meeting-recap/SKILL.md new file mode 100644 index 0000000000..b20aa5403f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/skills/twenty-partner-meeting-recap/SKILL.md @@ -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= +FIREFLIES_API_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/::`). + +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 ` 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 (call , transcript|summary). +``` + +The `Source: Fireflies ` 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 `. +- **No such note** → create one: `createNote` with `bodyV2.markdown`, then `createNoteTarget` linking `noteId` to `targetPartnerId`. Title: `Partner call recap: ()`. +- **Note already exists** → regenerate the recap, diff it against the existing body, and **append only net-new information** under a dated `**Update :**` 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 ` 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 ` 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 ` / `… - x Rashad`) against the partner name in the note title. New notes written by this skill carry `Fireflies ` 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": "", "targetPartnerId": "" } } +``` + +**Append to an existing note (re-run path):** +```graphql +mutation($id:UUID!,$d:NoteUpdateInput!){ updateNote(id:$id,data:$d){ id } } +# variables: { "id": "", "d": { "bodyV2": { "markdown": "" } } } +``` + +**Verify a write:** +```graphql +query($id:UUID!){ note(filter:{ id:{ eq:$id } }){ + id title noteTargets{ edges{ node{ targetPartnerId targetPartner{ name } } } } } } +```