Twenty Partners 共享 API 参考:凭证管理、GraphQL 查询与 Fireflies 集成的一次性维护实践
twenty-partners 应用把五个 Claude 技能(twenty-lead-brief、twenty-partner-shortlist、twenty-partner-intro、twenty-partner-recap、twenty-partner-triage)打包进同一个 Twenty SDK 应用,而这些技能全部依赖一份共享参考文档 partner-api.md 来读取凭证、调用 GraphQL 端点。本篇以该文档为骨架,完整解析其凭证约定、gql() 辅助函数、各实体的查询/变更语句及其背后的真实限制,并结合应用内的 TypeScript 源码与 Python 排序脚本,说明这套"单一事实来源"模式如何保证五个技能行为一致。
一、共享参考文件的定位:改一处,五个技能全部生效
partner-api.md 的开篇就点明了它的设计意图:每个 twenty-partner-* / twenty-lead-* 技能都读取这份文件,而不是各自复述凭证和查询。修一次查询,所有技能都拿到修复。这是典型的"DRY(Don't Repeat Yourself)"技能工程实践。
五个技能如何引用它,可以从各自的 SKILL.md 中逐一验证:
- twenty-lead-brief/SKILL.md:"Credentials, the
gql()helper and every query live in../_shared/partner-api.md. Read it rather than restating anything." - twenty-partner-shortlist/SKILL.md、twenty-partner-intro/SKILL.md 均有同样的引用。
- twenty-partner-recap/SKILL.md 额外声明"This skill needs all three keys"(需要全部三个凭证)。
- twenty-partner-triage/SKILL.md 只需要 partners 的 URL 和 key,其配套脚本 rank.py 在缺失凭证时会直接退出并提示缺失的 key。
这种结构让"凭证与 API 契约"从各技能的业务逻辑中剥离出来,成为一份可独立维护的契约文档。
二、凭证管理:~/.twenty/credentials.env 与"缺 key 即停"原则
所有技能统一读取用户主目录下的 ~/.twenty/credentials.env,文件内容约定为:
TWENTY_PARTNERS_API_URL=https://partners.twenty.com
TWENTY_PARTNERS_API_KEY=<key>
FIREFLIES_API_KEY=<key>
文档给出了首次部署的操作路径:partners 的 key 存放在仓库内 packages/twenty-apps/internal/twenty-partners/.env.prod(已被 gitignore 忽略),首次使用时复制到 ~/.twenty/credentials.env;Fireflies 的 key 则是个人性质的,需要单独提供。
每个技能对凭证的需求不同,文档用一张表明确了"谁需要什么":
| 技能 | Partners URL + key | Fireflies key |
|---|---|---|
twenty-lead-brief |
需要(写 Opportunity) | 仅当输入来自 Fireflies 时需要 |
twenty-partner-shortlist |
需要(只读) | 不需要 |
twenty-partner-intro |
需要(写 Application) | 不需要 |
twenty-partner-recap |
需要 | 需要 |
twenty-partner-triage |
需要(只读) | 不需要 |
文档对缺 key 的处理写了一条硬性纪律:"If a required key is missing, stop and name it. Never proceed on a partial set."(缺了必需 key 就停下来,指名道姓地说缺哪个,绝不在残缺的凭证集合上继续)。这条规则在 rank.py 中得到了源码级印证:其 load_creds() 函数在文件不存在时直接 sys.exit,并给出"从应用 .env.prod 复制 partners key"的提示;即使文件存在,缺少 TWENTY_PARTNERS_API_URL 或 TWENTY_PARTNERS_API_KEY 任一项也会同样终止。
三、gql() 辅助函数:两个端点与一个不可省略的 Header
文档提供了一个仅依赖 Python 标准库的 GraphQL 客户端,五个技能的技能执行都基于它:
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))
几个实现细节值得注意:
- 凭证解析:按行读取、跳过空行与
#注释行,用split("=", 1)只按第一个等号切分,因此 value 中即使含=也不会被截断——这是一个面向 env 格式的小型但严谨的解析器。 User-AgentHeader 不是可选项:文档明确写道"the Fireflies API rejects urllib's default"(Fireflies API 会拒绝 urllib 的默认 User-Agent)。因此所有出站请求都手动带上User-Agent: Mozilla/5.0。rank.py 的 REST 请求同样带了这一 Header,说明这是跨脚本统一遵守的约定,而非某处笔误。- 超时 90 秒:
urlopen(req, timeout=90)给分页拉取大结果集留了充足时间。
两个 GraphQL 端点分别是:
- Partners 工作区:
$TWENTY_PARTNERS_API_URL/graphql(即https://partners.twenty.com/graphql) - Fireflies:
https://api.fireflies.ai/graphql
四、Partner 查询:分页契约与 amountMicros 的金额单位
4.1 列出全部可承接项目的 Partner
这是 twenty-partner-shortlist 的核心查询,筛选条件为"已验证且可用"(validationStage: VALIDATED 且 availability: AVAILABLE):
query ListPartners($after: String) {
partners(
filter: { validationStage: { eq: VALIDATED }, availability: { eq: AVAILABLE } }
after: $after
) {
pageInfo { hasNextPage endCursor }
edges { node {
id name slug introduction
languagesSpoken country region city
deploymentExpertise partnerScope skills typeOfTeam
partnerTier
twentyExperience twentyExperienceNotes
hourlyRate { amountMicros currencyCode }
projectBudgetMin { amountMicros currencyCode }
lastMatchAt
persons { edges { node { name { firstName lastName } emails { primaryEmail } } } }
company { id name domainName { primaryLinkUrl } }
} }
}
}
分页纪律:一直翻页直到 hasNextPage 为 false,每页把上一页的 endCursor 作为 $after 传入。Twenty 的列表查询遵循 Relay 风格的游标分页(pageInfo / edges / endCursor),短名单、匹配、回顾等所有"全量"读取都必须遵守这条循环。
4.2 amountMicros:先除以 1,000,000 再展示
文档对金额字段有一条明确的单位约定:amountMicros 是金额乘以 1,000,000 的微单位,展示前必须除回去。这一点可以从应用自身的 TypeScript 侧得到交叉印证:市场列表查询 list-available-partners.ts 中同样以 hourlyRate { amountMicros currencyCode } 与 projectBudgetMin { amountMicros currencyCode } 读取费率与最低项目预算,映射层负责把微单位换算回展示值。也就是说,"微单位金额"是 Twenty 平台级的金额表示方式,而非该技能文档的私有约定。
4.3 带联系方式与域名的全量 Partner 列表
twenty-partner-recap 在做"会议参会人 → Partner 匹配"时需要这份更精简的数据:
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 } } } } } }
结合 twenty-partner-recap/SKILL.md 的 Phase 2 可以看到这份查询的用途:技能先把全量 Partner 翻页读一遍,建立 email → partner 与 domain → [partners] 两张映射表,再用参会人邮箱(跳过 @twenty.com、host、organizer 等 Twenty 侧人员)做精确邮箱匹配,失败则退回域名匹配(跳过 gmail 等免费邮箱域名,一域名对多 Partner 时标记为歧义并跳过,绝不猜测)。文档本身只提供数据面,消费逻辑在技能侧——这正是共享参考文件"只管契约、不管流程"的边界。
值得补充的源码证据是:应用内市场查询 list-available-partners.ts 使用了与文档几乎一致的过滤条件(VALIDATED + AVAILABLE),并额外加了 slug: { neq: '' } 排除无 slug 的记录——文档侧的查询面向技能脚本,省略了这一条,但两者对"可对外展示 Partner"的定义是一致的。同一文件里还留下了一条重要的实现注释:服务端会忽略嵌套关系的参数,且每个关系读取被 QUERY_MAX_RECORDS_FROM_RELATION(60 条)截断且无序,所以 partnerServices / partnerContents 这类计数型关系要在根层读取后自行分组。这条约束对任何直接写原生 GraphQL 查询的开发者都是必读的。
五、Opportunity 查询:先搜索后创建,以及 designDocUrl 的 LINKS 形态
5.1 创建前必须查重
twenty-lead-brief 在把线索写入 CRM 前,必须先按公司名模糊查找已有 Opportunity(ilike 为不区分大小写的模糊匹配):
query($n:String!){ opportunities(filter:{ name:{ ilike:$n } }, first:5){
edges{ node{ id name stage createdAt company{ name } } } } }
技能文档 twenty-lead-brief/SKILL.md 第 6 步把这一查询标注为"writes to production"(写生产环境),并规定:搜索命中就向用户展示、询问是更新还是新建,"Never create a silent duplicate"(绝不静默创建重复记录)。
5.2 创建与更新
mutation($d:OpportunityCreateInput!){ createOpportunity(data:$d){ id name } }
mutation($id:UUID!,$d:OpportunityUpdateInput!){ updateOpportunity(id:$id,data:$d){ id } }
创建时(twenty-lead-brief)与更新时(twenty-partner-intro 用于写入 introSentAt 时间戳)共用同一组 mutation 形状,变量名 $id / $d 的约定在多个技能间保持一致。
文档还特别说明了 designDocUrl 字段的写入格式——它是一个 LINKS 类型字段,而不是纯字符串:
{ "primaryLinkUrl": "<url>", "primaryLinkLabel": "Partner brief" }
从源码结构看,这个字段在应用内的声明位于 opportunity-intro-sent-at.field.ts 同目录下的 opportunity-design-doc-url.field.ts;而 twenty-partner-intro/SKILL.md 的 Phase 0 规定:若 Opportunity 的 designDocUrl 为空,必须停下来索要 Doc 链接——因为合作伙伴邮件的核心内容就是这个链接,"没有 brief 的引荐会让 Partner 再来一个往返询问"。共享文档中的格式说明与技能中的消费规则形成了闭环。
另外 twenty-lead-brief 还明确规定了边界:创建 Opportunity 时不要设置 isListed(那是市场拉取流程的字段),不要设置 introSentAt、不要创建任何 Application(这两者归 twenty-partner-intro 管)。字段所有权按技能切分,是这套多技能体系避免写冲突的又一机制。
六、Application 查询:INVITED 与 APPLIED 的语义边界
Application 对象把 Partner 关联到 Opportunity。共享文档用两句话定义了状态语义,这是整条引荐链路的语义核心:
INVITED:Twenty 主动把这个 Partner 推给了客户(由twenty-partner-intro写入);APPLIED:Partner 自己通过市场主动申请(由市场侧流程写入)。
对应查询与变更:
query($oid:UUID!){ applications(filter:{ opportunityId:{ eq:$oid } }){
edges{ node{ id state partner{ id name } } } } }
mutation($d:ApplicationCreateInput!){ createApplication(data:$d){ id state } }
# variables: { "d": { "opportunityId": "<id>", "partnerId": "<id>", "state": "INVITED" } }
twenty-partner-intro 的执行顺序体现了"先写库、后发邮件"的可靠性设计:先按 opportunityId 查询已有 Application,已存在就跳过并报告(重跑不产生重复);再创建 { opportunityId, partnerId, state: "INVITED" };最后用 updateOpportunity 给 Opportunity 盖上 introSentAt 时间戳(已有值则先询问是否二次触达)。Application 对象本身的声明见 application.object.ts。
七、Company 与 Person:搜索优先,未命中才创建
twenty-lead-brief 创建 Opportunity 时需要 companyId 和 pointOfContactId 两个外键。共享文档给出的模式是"search first, create only on a miss"(先搜索,未命中才创建):
query($n:String!){ companies(filter:{ name:{ ilike:$n } }, first:5){
edges{ node{ id name } } } }
mutation($d:CompanyCreateInput!){ createCompany(data:$d){ id name } }
query($e:String!){ people(filter:{ emails:{ primaryEmail:{ eq:$e } } }, first:1){
edges{ node{ id name{ firstName lastName } } } } }
mutation($d:PersonCreateInput!){ createPerson(data:$d){ id } }
人员按 emails.primaryEmail 精确匹配(eq 而非 ilike),这保证了"同一个联系人邮箱只对应一条 Person 记录"的去重语义。应用内还有一套可测试的服务实现同一模式——find-or-create-company-and-person.service.ts 位于 modules/shared/,说明"查找或创建公司/人"是被抽成跨域共享逻辑的,与共享文档中的查询契约互为表里。
八、Note 查询:把通话回顾挂到 Partner 档案上
twenty-partner-recap 通过 Note 机制把每次 Partner 通话的回顾写入其 CRM 档案,涉及三组操作:
query($pid:UUID!){ noteTargets(filter:{ targetPartnerId:{ eq:$pid } }){
edges{ node{ note{ id title bodyV2{ markdown } createdAt } } } } }
mutation($d:NoteCreateInput!){ createNote(data:$d){ id title } }
mutation($d:NoteTargetCreateInput!){ createNoteTarget(data:$d){ id targetPartnerId } }
mutation($id:UUID!,$d:NoteUpdateInput!){ updateNote(id:$id,data:$d){ id } }
这里的结构值得注意:Note 本体与"挂在谁身上"(NoteTarget)是分离的两个对象,createNote 与 createNoteTarget 是两步事务式调用,先建正文、再建指向 targetPartnerId 的链接。bodyV2.markdown 是富文本体的 Markdown 通道。
消费侧的幂等设计在技能文档中:twenty-partner-recap 在写入前先按 targetPartnerId 读取该 Partner 的全部 Note,查找正文中含 Fireflies <transcript-id> 的条目——没有则新建;已有则重新生成回顾、diff 之后只把净新增信息以带日期的 Update <YYYY-MM-DD>: 块追加,没有新内容就保持不动。这个 Fireflies <transcript-id> 行被文档称为"load-bearing"(承重)行:它既是重跑时的去重键,也是 --prune 阶段回查并删除 Fireflies 录音的键。
九、Fireflies 查询:epoch 毫秒、硬性的 50 条上限与 URL 解析
Fireflies 端点是 https://api.fireflies.ai/graphql,共享文档给出三条查询:
query{ transcripts(limit:50){ id title date duration participants
meeting_attendees{ displayName email } } }
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 } } }
mutation($id:String!){ deleteTranscript(id:$id){ id title } }
文档对 Fireflies 标注了两条"真实限制"(both real):
date是 epoch 毫秒,不是 ISO 字符串。twenty-partner-recap默认拉取"最近 2 天"的会议,时间窗口过滤就发生在这个单位上。limit上限是 50,且是硬 400 错误:传更大的值会收到invalid_arguments的 400 响应,而不是被服务端静默钳制到 50。这意味着任何试图"一次拉全量"的写法都会直接失败,必须以 50 为页大小分批。
此外还有一条 URL 解析规则:Fireflies 的分享链接形如 app.fireflies.ai/view/<slug>::<ID>,其中真正的 transcript ID 是 :: 之后以 01K… 开头的那一段(典型的 ULID 前缀)。twenty-lead-brief 接受"Fireflies 链接或 ID"作为输入时,靠这条规则从 URL 中还原出查询用的 $id。
deleteTranscript 仅由 twenty-partner-recap --prune 使用,且技能侧要求删除前逐条确认、删除后重新列表验证 ID 确已消失,并报告 deleted N/M——外部服务的不可逆删除被放在了最谨慎的执行纪律之下。
十、Gmail 草稿:view=cm 组合 URL 及其两条真实限制
twenty-partner-intro 需要把 N 个合作伙伴的邮件(共 1 + 2N 封)以"可发送草稿"形式打开。共享文档给出的实现:
import subprocess, urllib.parse, time, sys
params = {"view": "cm", "fs": "1", "to": to, "su": subject, "body": body}
if cc: params["cc"] = cc
url = "https://mail.google.com/mail/?" + urllib.parse.urlencode(params)
if sys.platform == "darwin":
subprocess.run(["open", "-a", "Google Chrome", url])
else:
import webbrowser; webbrowser.open(url)
time.sleep(1.5)
参数语义:view=cm 指定打开"撰写"界面,fs=1 全屏,to / su / body / cc 预填收件人、主题、正文与抄送;time.sleep(1.5) 对应技能文档中"每封间隔 1.5 秒顺序打开"的节奏。
文档同时列出了两条"都真实存在"的限制:
view=cm永远开一个新撰写窗口,无法在既有会话线程内回复。因此"回复客户线程"那一封(Email 1)只能以纯文本形式交给用户手动粘贴,不能走 URL 预填路径。twenty-partner-intro的技能文档与之严格一致:Email 1 是 thread reply,只输出正文文本;只有 Partner 侧的 Email 2(单发)与 Email 3(三方引荐)才通过该辅助函数打开。- 过长的正文会撑爆 URL。因此正文保持"几个短段落",详细 brief 一律以链接形式给出,而不粘贴全文——这也与"brief 以 Google Doc 链接随邮件发送、绝不附件"的规则相呼应。
十一、源码交叉印证:同一契约的两种实现形态
共享文档的价值在应用源码中得到多处印证,可以归纳为"同一 API 契约、两种消费形态":
- GraphQL 形态(技能脚本侧):
partner-api.md中的原生查询,供 Python 辅助函数直连https://partners.twenty.com/graphql,服务对象是五个 Claude 技能与rank.py。 - 类型化 SDK 形态(应用代码侧):Twenty 应用自身的逻辑函数走的是从工作区 schema 代码生成的
CoreApiClient,如 list-available-partners.ts 中的partners { __args: { filter: { validationStage: { eq: 'VALIDATED' }, availability: { eq: 'AVAILABLE' } } ... } },与共享文档的ListPartners过滤条件一一对应,且因代码生成而严格类型化,注释中强调"HTTP 契约永远不可能与我们实际请求的内容漂移"。按 AGENTS.md 的架构约定,原生查询只允许出现在各域的graphql/目录、出站第三方 API 调用只允许出现在connector/目录,业务逻辑必须位于可测试的services/——技能脚本属于应用外部的独立执行体,因此以 Markdown 契约而非 TypeScript 模块的方式共享同一份查询定义。 - REST 形态(
rank.py侧):rank.py 走的是{url}/rest/partners?limit=200&depth=1&starting_after=...的 REST 分页拉取全部 Partner,再用VALIDATED集合作为基线、对APPLICATION阶段申请者计算"净新增"地理/语言/范围/技能并叠加"真实 Twenty 交付证明"信号(workspace_url、customers、migration三类正则)打分,权重与分级阈值集中在文件顶部的WEIGHTS/THRESHOLDS(如 geo +3、lang +3、proof +6,技能分上限 3 条,防"技能清单灌水")。它读取的正是共享文档约定的~/.twenty/credentials.env——凭证契约在 Markdown 文档、Python 脚本之间完全一致。 - 凭证路径的三方一致:
partner-api.md的 Python 解析器、rank.py的load_creds()、技能文档中"缺 key 即停"的表述,三者在同一文件路径与"指名缺失 key"的行为上完全吻合。
应用整体的定位(把 CRM 变成 Twenty 伙伴计划的操作系统:接收合作资格交易、匹配已验证伙伴、端到端跟踪匹配管道,以及 matchStatus 的完整状态表)见 README.md,可作为理解上述字段(如 introSentAt、designDocUrl)所处业务上下文的入口。
十二、实践要点小结
- 单一事实来源:凭证位置、端点 URL、查询定义、金额单位、状态语义全部收敛到
partner-api.md一处,五个技能只保留各自的流程与判断逻辑,修一处即全局生效。 - 写生产环境的操作一律"先查后写":Opportunity 按
ilike查重、Application 按opportunityId查已有记录、Person 按primaryEmail精确匹配、Note 按正文中的Fireflies <id>去重——所有 mutation 之前都有一个对应的 read,且字段所有权按技能严格切分(谁创建、谁盖章、谁不许碰)。 - 外部 API 的限制要写进契约文档并标注"真实":Fireflies 的 50 条硬上限、urllib 默认 User-Agent 被拒、Gmail
view=cm无法线程内回复与 URL 长度上限,这些都不是风格偏好,而是踩过坑后固化的行为约束。 - 分页必须遵守游标循环:GraphQL 侧以
hasNextPage/endCursor循环到底,REST 侧以starting_after/pageInfo循环到底;半量数据会直接让匹配与回顾流程产生系统性偏差。 - 单位与格式契约:金额除以 1,000,000 再展示,LINKS 字段按
{ primaryLinkUrl, primaryLinkLabel }结构写入,Firefliesdate按 epoch 毫秒比较,transcript ID 取 URL 中::后的01K…段——这些细节日益成为多技能协作不出错的地基。
对维护者而言,这份共享参考的维护成本极低而收益明确:任何端点、字段或限流的变更只改 partner-api.md 一个文件;对使用者而言,按第二节补齐 ~/.twenty/credentials.env、遵守"缺 key 即停"的纪律,再配合各 SKILL.md 的流程说明,即可完整复现从通话转录到 CRM 记录、再到合作伙伴引荐邮件草稿的整条链路。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00