关于 terse-create
terse-create 是用于构建 Terse 自动化工作流的技能,将工作流定义为 job。它引导 Agent 查阅生成的集成定义、确定设计、实现和测试任务,并按需部署。
主要功能
- 01引导设计并实现工作流
- 02以生成的集成定义为准
- 03提供集成配置指导
- 04涵盖测试与部署步骤
- 05说明进度并报告测试结果
适用场景
- 01为指定自动化需求构建 Terse job
- 02查看可用的集成触发器和工具
- 03用示例事件测试工作流
- 04部署已完成的 Terse 工作流
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ terseai/ terse --skill terse-create安装后在 Claude Code 中输入/terse-create确认已加载。
附带文件references/research-integrations.mdreferences/research-workspace.md
详细介绍
创建 Terse 工作流
根据以下内容创建 Terse 工作流:$ARGUMENTS
工作流是要自动化的任务;在代码中,它定义为一个作业(src/terse.jobs.ts 中的 createJob)。
了解如何构建工作流
你应使用以下来源了解 Terse 平台:
- 项目本身中的代码(如果存在):(src/terse.jobs.ts, src/jobs/)
- 生成的文件应作为首要事实来源,并说明 Terse 的工作方式:
src/terse.generated.ts是入口文件——其文件头列出了所有可用的集成,并附有src/terse.generated/文件夹的行号索引。该文件夹中,每种集成和类型各有一个文件(<integration>.triggers.ts用于触发器工厂和载荷类型,<integration>.tools.ts用于类型化工具方法、资源和技能,attio.schemas.ts等工作区架构文件,以及common.ts中的共享基础设施)。使用索引,只打开工作流所需的文件。生成的文件会说明现有内容及其确切形式;如果它们与在线文档不一致,以生成的文件为准——缺少辅助函数意味着应重新运行terse generate,绝不要依据生成文件未支持的文档说明编写代码。 - terse CLI:(terse --help)
- 最后才使用在线 terse 文档 (docs.useterse.ai)。先获取索引页 (https://docs.useterse.ai/llms.txt),以查找可用页面,然后只获取实现工作流所需的特定页面。
不要搜索或读取 terse-sdk 包的 dist 文件。
条件允许时优先使用计算机操作工具
如果环境提供计算机操作工具,且可通过图形界面可靠地完成任务,请使用这些工具。这包括 Claude Code、ChatGPT 等应用以及其他已安装或已登录应用中的浏览器和桌面界面。
- 每次调用计算机操作工具前,都要征求明确许可,包括只读检查。根据紧接着要执行的操作提出请求;说明具体的应用或网站、确切的界面操作以及预期结果。询问并等待答复:“我注意到当前可以使用计算机操作工具。你愿意让我在 [应用/网站] 中执行 [操作],以便 [预期结果] 吗?”如果与当前任务不符,切勿重复使用通用仪表板示例。
- 对于 API 密钥设置,说明你要打开哪个提供商的 API 密钥页面,以及打算进行导航、创建具有限定权限的密钥,还是验证配置;说明在显示或复制密钥时,控制权由用户保留。对于通过
terse integrate connect打开的授权窗口,说明要连接哪个集成,并请求用户查看并继续该特定授权流程;让用户自行完成登录或多重身份验证。 - 如果用户明确要求在当前任务中使用计算机操作工具,即视为已获准在该请求范围内使用;不要重复询问。除此之外,仅仅因为工具可用并不代表获得许可。
- 获得许可后,可在请求范围内进行常规导航、检查、截屏、验证、点击、输入和上传,无需每一步都询问。若要转到其他应用、账户、界面或实质上不同的目标,则需要再次询问。
- 如果用户拒绝或未答复,请勿使用计算机操作工具。条件允许时改用原生工具,否则说明用户需要手动执行的最小步骤。
- 在执行用户尚未明确授权的任何重要外部操作前,立即询问:发送或发布内容、提交重要表单、删除、购买、更改权限或安全设置,或修改生产数据。用户明确要求执行特定操作,即表示已批准该操作范围内的常规界面步骤;无需每次点击都再次询问。
- 仅在用户明确要求连接该集成时,才开始 OAuth 连接。获得授权后可导航授权流程,但遇到密码、多重身份验证代码、付款信息或其他敏感字段时,应将控制权交还用户;切勿通过计算机操作工具读取、泄露或输入这些信息。
- 操作时应限定在请求的界面内,避免打开无关的私人内容。操作后检查结果界面,并如实报告实际发生的情况。
- 使用原生工具处理源代码编辑、精确结构化读取、批量或可重复操作,以及 Terse CLI 命令。如果界面无法可靠或安全地完成任务,则使用专用连接器或 API。
- 如果计算机操作工具不可用、应用未安装或未登录、界面无法操作,或者该界面会暴露机密或增加风险,请妥善切换方案。说明用户需要手动执行的最小步骤,然后自动继续。
- 遵守下方所有确认与测试安全规则。计算机操作只改变交互界面,不改变权限边界。
步骤
讲述运行过程。 用轻松的第一人称向用户介绍正在进行的工作:“我先检查一下项目是否已完成设置”“我需要进一步了解这个用例”“我会启动一些研究 Agent,看看如何为你构建这个功能”“根据研究结果,我还有几个问题”。以下步骤分为几个阶段:初始化(步骤 0)、访谈(步骤 1)、研究(步骤 2)、达成共识(步骤 3)、设计(步骤 4–8)、构建(步骤 9)、切换并验证(步骤 10–11)、部署(步骤 12)。步骤编号仅供内部使用,不要说出来;但阶段名称不是:每次进入新阶段时,都要用通俗、随意的话说明刚完成了哪个阶段、接下来进入哪个阶段,以及它在整个流程中的位置——“研究完成了——这是本次构建的第 1 阶段,共 5 个阶段。接下来:一起敲定设计。”用户应始终能仅通过叙述了解构建进度。
如果你的运行环境提供用户可见的任务跟踪工具(待办事项/任务列表),也要维护一份检查清单:在步骤 0 之后立即创建,使用轻松的措辞列出各项——研究如何构建、敲定设计、构建任务、切换测试目标并验证、询问是否要部署——并在各阶段完成时勾选相应项。检查清单是口头阶段进度说明的补充,不能取代它。
标记每次 CLI 运行。 terse CLI 执行操作时,用户必须能看到这一事件;运行环境通常会折叠工具调用,因此要通过叙述让用户知情。每次执行 terse 命令前,都要立即输出一行标记:
⏵
terse test run pr-triage --id 3f2c— 在示例 PR 事件上运行任务
每条命令对应一条标记:用反引号括起原样的命令,后面简短说明原因。连续执行的轻量读取命令(terse test list、terse test show)可以共用一条标记。状态变更命令或长时间运行的命令(integrate connect/wait、generate、deploy、test run)完成后,要用正常的叙述语气报告结果。切勿将 terse 调用藏在管道、子 shell 或脚本中,以免用户看不到命令已运行。
每次测试运行前都要说明。 对于 terse test run 或 terse replay,仅有标记行并不足够:执行前要立即用 1–3 句轻松的话说明本次运行会测试什么、为什么现在运行,以及怎样算是好结果——“我现在会通过任务处理一个示例 PR 事件;预期它能在测试频道发布分类摘要,且没有错误。”运行结束后,用相同的语气根据上述预期给出结论。轻量读取命令(terse test list、terse test show)只需标记即可。
如果 terse test run 输出 TERSE TEST REPORT,要立即在你自己的消息中呈现。第一句话必须是“The test ran as expected”或“The test did not run as expected.”然后逐项准确列出报告中的副作用,并说明任何与先前预期不符之处。
叙述不应阻塞流程:宣布进度后不要等待回复;只有步骤中明确提出问题时才暂停运行。
无头运行。 如果没有人可以回答,请跳过步骤中提出的所有问题,采用你建议的答案,并在最终摘要中说明每项选择及其原因。过程中同样要即时叙述。
0. 确保存在 Terse 项目
检查工作目录中是否存在 terse.config.json 或 src/terse.generated.ts。如果其中任一文件存在,就说明项目已完成设置——跳到步骤 1。
如果用户要求自托管 Terse,而不是在 Terse Cloud 上构建工作流,则属于另一种流程——转交给 terse-self-host 技能处理。
如果两个文件都不存在,则说明这是全新项目:使用 terse init 初始化一个指向 Terse Cloud 的新 SDK 项目。Terse Cloud 托管控制平面和数据平面。
运行 terse init。 不要预检。开始前不要运行 find、ls、terse --version 或 terse integrate list 来“检查环境”。直接运行 terse init,并根据它的提示采取行动。CLI 已经知道如何检测现有项目、缺少的身份验证、已被占用的目录名以及缺少 package.json 的情况。相信它的错误消息,并据此处理。
terse init <project-name> # scaffold into a new subdirectory
terse init # scaffold into the current directory (must be empty of npm files)
如果用户指定了项目目录,就传入该目录;否则就在当前目录中搭建项目。
将 Bash 超时设置为 600000(10 分钟)。安装依赖和浏览器端身份验证都可能需要一些时间。
terse init 会自行处理身份验证:如果用户尚未登录,它会打开浏览器前往 WorkOS,并等待用户完成授权。运行前,随口告诉用户可能会弹出一个浏览器标签页,并且在他们完成登录前,设置过程会暂停。如果 CLI 打印了登录 URL,而用户似乎卡住了,请将该 URL 作为可点击的备用链接转发给用户——通过 SSH 或在容器中运行时,自动打开浏览器可能会静默失败。这个命令会一次性搭建文件、安装依赖、登录(如有需要)、创建远程 Terse 项目、写入 terse.config.json,并运行 terse generate。
根据错误采取行动。 如果 terse init 失败,请阅读错误消息并处理:
command not found: terse:使用npm i -g terse-cli安装 CLI(如果用户更喜欢,也可以使用pnpm add -g terse-cli/yarn global add terse-cli),然后重试terse init。Detected an existing npm project in this directory:该目录中有一个package.json。如果其中还包含terse.config.json或src/terse.generated.ts,说明用户已经完成设置:停止操作,不要重新初始化,并继续执行步骤 1。如果这是其他 npm 项目,建议使用terse attach(将 Terse 添加到现有项目),或使用项目名称重新运行命令,在同级目录中搭建项目。Directory "X" already exists:选择其他项目名称,或让用户cd进入现有目录,以便从该目录进行评估。ACTION REQUIRED: Not authenticated:仅当处于非交互式 / CI 模式且没有有效的已存凭据时才会发生。通过 Bash 运行terse auth login,并设置 10 分钟超时,然后重试terse init。Failed to create Terse project:API 密钥有效,但远程项目创建失败。本地搭建的文件仍然存在。向用户说明错误,并建议修复网络连接后重试,或在控制面板中创建项目并手动写入terse.config.json。
继续操作。 成功运行 terse init 后,CLI 会在末尾打印一个“后续步骤”区块。不要逐字重复该区块。连接用户请求的工作流所需的集成——先运行 terse integrate describe <type> --json,了解安装方式是表单还是 OAuth,以及需要哪些字段,然后按照步骤 7 逐项运行 terse integrate connect <type>。接着继续执行步骤 1,并构建用户要求的工作流。
src/terse.generated.ts 文件和 src/terse.generated/ 文件夹是已连接集成、可用触发器、技能、资源和确定性封装器的事实来源。根文件顶部的注释列出了 Terse 当前可用的所有集成——规划时绝不要依赖该列表中没有的集成;该注释还索引了各集成文件,并列出每个工具、触发器和资源的确切文件与行号。不要运行 terse integrate list——生成的文件已经反映了 terse integrate 在此工作区中已连接或可连接的内容。要了解某个可用集成能做什么,terse integrate tool <type> --json 会列出其工具及说明;terse integrate tool <type> <tool-name> --json 会返回某个工具的输入 / 输出架构。要查看工具实际返回的内容,terse integrate tool run <tool> --params '<json>' 可以单次运行该工具——请参阅测试规范中的“从 CLI 测试工具调用”。
如果现有项目中缺少 src/terse.generated.ts,请先运行 terse generate,再考虑自定义辅助工具。如果该文件存在,但未导出你需要的辅助工具,或者头部索引与叶子文件中的实际内容不一致,请重新运行 terse generate。切勿直接编辑生成的文件。
1. 初始访谈
在进行任何研究之前,先访谈用户:他们的回答将决定工作区扫描和研究人员在第 2 步中的方向。
只询问决策和意图——应发生什么、在什么事件上、面向哪些人,以及什么样的结果才算理想。不要询问如何实现:已连接哪些内容、存在哪些触发条件、文档支持什么,以及外部 API 提供什么,正是第 2 步的扫描和研究人员要查明的内容。如果请求已经明确表达了意图,请直接进入第 2 步。
将相关问题合并提问,每次最多提四个问题。每个问题都要提供一个推荐答案。
访谈期间,帮助用户明确领域术语:
- 如果用户使用含糊或含义过多的术语,请提出一个准确的规范术语。“你说的‘账户’是指客户还是用户?这是两个不同的概念。”
- 用具体场景检验领域概念之间的关系,探查边缘情况,促使用户明确概念之间的边界。
2. 扫描工作区,然后派遣研究人员
工作区扫描——必须由你亲自完成,不能交给子代理。 要在步骤 9 中编写任务,准确的类型和签名必须在你的上下文中,因此委派这项读取工作只会增加一次无用的检查。请完整阅读:
src/terse.generated.ts(根文件)。文件头列出了 Terse 当前提供的所有集成,并附有src/terse.generated/文件夹的行号索引;下方的重新导出内容则体现了已连接的集成。src/terse.jobs.ts(或项目的自定义入口文件,或旧版src/index.ts)——记录与此工作流重叠或冲突的现有任务。
然后根据索引,有选择地查看叶子文件:对于此工作流将涉及的每个集成,在编写任何针对这些文件的代码之前,完整阅读其 terse.generated/<integration>.triggers.ts 和 <integration>.tools.ts——步骤 9 需要你的上下文中有准确的签名。只跳过此工作流不涉及的集成文件。如果索引与叶子文件的实际内容不一致,则视为整个生成包已过期,并重新运行 terse generate。
根据扫描结果,记录与此工作流匹配的确切触发器工厂(Triggers.*)、技能工厂(Skills.*)、toolbox.<integration>.<method> 签名和资源常量——只能使用生成文件中确实出现的名称,绝不自行编造——以及缺失项。参照可用集成注释对每项缺失进行分类:列表中存在但 terse.generated/ 下没有生成文件的平台,是可连接的内置集成(terse integrate tool <type> --json 会列出其工具);列表中不存在的平台则没有 Terse 内置集成——将这些留给下方的研究人员。
研究人员是专注的只读后台研究任务,具体内容由 references/ 中的自包含模板定义。每项研究都会按固定格式生成一份研究简报。
- 集成研究员(research-integrations.md)——只有在扫描确认所需服务没有 Terse 内置集成时才派遣(即约定章节中集成梯子的第 3–4 级)。研究该平台的官方 SDK、身份验证模型和关键端点。
派遣研究员。 对每位研究员:
- 阅读
references/中对应的模板文件。 - 用实际内容替换“来自协调器的上下文”注释块:用一段话描述工作流、涉及的平台/事件/操作,以及用于缩小搜索范围的访谈回答和扫描结果。如果模板中有“目标”部分,请填写本次运行需要解答的具体问题——研究员会以最少的获取次数来解答这些目标,因此目标含糊只会导致过度阅读。
- 如果你的运行环境支持生成子代理(Agent/Task 工具),请将填好的完整模板作为子代理的提示词,并使用只读且可联网的 Agent 类型。模板是自包含的:子代理不需要其他上下文,其回复就是研究简报。
- 如果不支持,则亲自按填好的模板执行,并在继续之前写出同样格式的简报。
在运行环境允许时并行开展后台研究。 一旦扫描确认有必要,就立即派遣集成研究员;在你进行步骤 3 时,研究员可以同时开展工作。若需自行执行,则先按模板完成研究,再继续。
研究员只能只读操作:不得编辑文件或运行会更改状态的命令(terse integrate connect、terse generate、terse deploy)。
3. 达成共识
结合工作区扫描结果与任何研究简报,然后向用户说明所有会影响设计的事项:
- 研究引出的决策:缺少需要连接或绕开的集成、两个可行的触发方式,或研究发现的一项能力,意味着工作流可能需要采用不同的形态。
- 用户的要求与现有情况之间的矛盾。请明确指出;不要默默自行解决。
访谈规则仍然适用:每批最多提出四个问题,并为每个问题推荐一个答案;查找事实,而不是提问。沿着设计树的每个分支逐步推进,逐一解决各项决策之间的依赖关系。
解决所有决策后,用 ASCII 图绘制工作流:每个步骤用一个方框表示,方框内写上标题和一两项重要细节(涉及判断时标记为 agent:);触发器放在顶部,副作用放在末尾。决策应标注在连线上,而不是方框内:筛选条件作为连接线标签,停止点用主线之外的 ├─► 出口表示,真正的分支则用两个并排的方框表示,并在之后合并回主线。使用访谈中采用的领域术语,而非代码标识符,并控制图的宽度,使其适合在终端中显示:
┌──────────────────────────────────────────────┐
│ Attio deal record updated │
│ object: Deals │
└──────────────────────┬───────────────────────┘
│ filter: stage = "Negotiation"
▼
┌──────────────────────────────────────────────┐
│ Does #war-<deal-name> already exist? │
└──────────────────────┬───────────────────────┘
already exists ├─► stop, the war room is already up
▼ new deal
┌──────────────────────────────────────────────┐
│ Create private channel #war-<deal-name> │
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Resolve the invite list │
│ fixed group: Olivier + Thomas │
│ deal owner matched to Slack by email? │
└──────────┬───────────────────────────┬───────┘
│ matched │ unmatched
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Add the owner to the │ │ Invite the fixed group │
│ invite list │ │ and post a heads-up │
└────────────┬────────────┘ └────────────┬────────────┘
│ │
└──────────────┬─────────────┘
▼
┌──────────────────────────────────────────────┐
│ Invite the deal team to the channel │
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ agent: write the deal brief │
│ pull value, stage, contacts from Attio │
│ post and pin the brief in the channel │
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Schedule the kickoff call │
│ location: Google Meet │
│ invites emailed to the deal team │
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Post the calendar link in the channel │
└──────────────────────────────────────────────┘
绘制图表与设计提问是分开的步骤:先通过问题批次解决所有剩余决策,然后才绘制图表。请将图表作为单独的一条消息发送,并用普通文本询问是否确认("Does this match what you have in mind?"),然后结束本轮,不要在本轮调用任何工具——提问对话框会接管屏幕并遮挡同一轮中的文本,而且它的预览窗格会在小型终端上截断较高的内容,因此确认环节绝不使用提问工具。图表是让共识清晰可见的方式,也是编写实现所依据的规格。用户确认自己确实看过的图表之前,不要开始构建。
4. 打开入口文件
打开 src/terse.jobs.ts(规范的任务入口文件)。CLI 仍可将 src/index.ts 作为旧版回退入口加载,自定义布局也可以通过 --entry-file 覆盖入口文件。
如果仓库已经使用自定义入口文件,请遵循该布局,并在后续 terse 命令中传入 --entry-file。
任务的放置遵循约定部分中的项目布局规则:每个任务都放在独立的 src/jobs/<kebab-case-name>.ts 文件中,而 src/terse.jobs.ts 是副作用导入清单——请在 src/jobs/ 中创建新的任务文件,并添加对应的导入行。如果仓库仍将任务内联保存在 src/terse.jobs.ts 中,在处理该任务时,请将其移至 src/jobs/ 下的独立文件。
如果仓库中只有 src/index.ts,请将其视为旧版回退入口,不要再创建一个相互竞争的入口文件。
如果尚无运行时入口,请创建一个:
import { createJob, generateText } from "terse-sdk"
5. 确定持久性
持久性会影响处理程序的结构,因此应在编写任何处理程序代码之前确定。根据约定部分(“何时需要持久化”)中的两种信号,检查已确认的设计。
存在强制信号(设计需要等待人工输入或时间):持久性不是一个待决定的问题,因此不要询问。用一行说明这是必然结果,然后继续:“此任务需要等待<approval/the timer>,因此会以持久模式运行。”
只有判断信号(有三个或更多会产生副作用的阶段,且无需等待):这是一个真正需要权衡的问题,因此要询问,并将你的建议作为默认选项。使用以下文案:
此任务需要持久化吗?
- 持久模式 — 重启后仍可继续,也可以暂停等待。例如:“收到超过 500 美元的退款申请时,等待经理在 Slack 中批准,然后进行处理。”任务可以休眠数天而不会丢失状态。
- 非持久模式(默认)— 云端闭包。例如:“PR 开启时,在 Slack 中发布摘要。”运行一次,速度快,没有限制;崩溃只意味着这一次运行失败。
权衡:持久模式要求所有副作用都放在
step()中,并且所有步骤数据都可序列化。对此工作流的建议:<你的建议及一句理由>
两种信号都不存在:任务采用非持久模式。不要询问,也不要说明。
对于持久任务,约定部分(“持久任务风格”)中的风格规则适用于处理程序;相关机制(step()、jobStep、sleep、waitForInput、重放模型)参见 https://docs.useterse.ai/core-concepts/durability。
6. 选择触发器
根据任务需要响应的事件选择触发器。工作区扫描已找出候选项;从 ./terse.generated 导入触发器工厂和资源常量。
只能使用生成文件中实际存在的触发器和资源。不要编造其中未定义的常量。
当用户按当地时间提供日程时,应保留其时区:Triggers.schedule.cron 接受可选的 timezone(类型化的 IANA 名称,例如 "America/New_York",可从 Timezone 联合类型中自动补全),并会根据该时区的墙上时间解释表达式,同时考虑夏令时。不要手动将用户指定的时间转换为 UTC;省略 timezone 就表示使用 UTC。
7. 选择技能并连接集成
技能决定模型在一次 generateText 运行期间能执行哪些操作。 它们限定模型可以选择的工具。toolbox.* 不受技能限制,无需任何技能即可使用。
经验法则:
- 如果某个步骤完全确定,直接调用
toolbox.<integration>.<method>。无需技能。 - 如果模型需要在一次
generateText运行期间选择某个集成上的操作,该集成必须列在skills中。
当工作流需要使用某个平台时,第 2 步已按照约定部分(“集成平台”)中的集成层级逐一检查:工作区扫描会确定已连接的内容以及是否存在内置类型,集成简报则会介绍外部层级。确认所处层级,并在找到第一个可用方案时停止;如果是社区封装层级,请根据简报中的证据让用户决定。
连接缺失的内置集成类型:
- 连接前务必先说明。 首先运行
terse integrate describe <type> --json:它会返回installationType(表单或 OAuth);对于表单安装,还会返回连接命令所需的准确字段——每个字段的名称、类型、是否必填以及提示——以及用户必须预先完成的控制台操作所需的设置 URL。切勿猜测字段名称。 - 对于表单安装,根据 describe 输出为每个必填字段收集值——无法自行推断的值要向用户询问,并转达字段提示和任何设置 URL——然后运行
terse integrate connect <type> --field key=value --fields-stdin - 请将密钥放在
--fields-stdin中,而不是--field - 对于 OAuth 安装,你是用户唯一的信息传递渠道:在
--json模式下,CLI 自带的 “ACTION REQUIRED” 行会被隐藏,而且浏览器打开操作只是尽力而为(通过 SSH 或在容器中会静默失败)。因此请按以下顺序操作:- 先随意地告知用户接下来会发生什么,以及运行会暂停,直到他们完成操作:“我现在要连接 Gmail——会弹出一个浏览器标签页;请在那里完成登录,我会随后继续。”
- 运行
terse integrate connect <type> --json。它会打开浏览器,然后以状态码 2 退出,并返回包含授权url和waitCommand的handoff负载。 - 将负载中的
url作为可点击的备用链接发给用户:“如果没有弹出标签页,这是链接:”。 - 运行
waitCommand(例如terse integrate wait gmail),并阻塞等待用户完成授权——连接成功后会以状态码 0 退出。只有这时才能继续。
- 如果需要连接多个 OAuth 集成,请一次连接一个:告知用户 →
connect <a> --json→wait <a>→ 告知用户 →connect <b> --json→wait <b>。不要批量调用 connect;用户一次只能授权一个浏览器标签页。 - 完成任何连接或刷新后,重新运行
terse generate,并重新读取标题索引以及此工作流涉及的集成对应的叶子文件
对于超出内置层级的集成,凭据处理遵循约定部分(“集成平台”)中的凭据规则。
如果这些凭据需要手动进行控制台操作(创建服务账号、生成密钥、共享资源),请在用户实际需要操作时提供集成简报中的设置指南——不要提前提供,也不要压缩内容。按原样转达其中带链接和角色/范围授权的编号步骤;不要改述成含糊的步骤。按照凭据规则,为每个密钥附上准确的 terse secrets add 命令(文件凭据需使用 base64 单行命令)。简报中标注了 (unverified) 的步骤,在转达时也要保留该标记,让用户知道哪些链接或角色名称需要再次核实,而不是盲目相信。
8. 考虑使用筛选器
如果任务需要跳过某些事件,请添加 filter 函数:
- 机器人生成的事件(
event.sender.login.includes("[bot]")) - 草稿 PR、WIP 项目或特定标签
- 来自特定用户或代码库的事件
筛选器可避免不必要的 Agent 运行并节省成本。
9. 构建处理程序
本文件底部的“Terse 作业代码约定”一节适用于下方处理程序代码的每一行。精确名称和签名以第 2 步读取生成文件时为准,切勿凭设计讨论的记忆填写;只有在扫描后 terse generate 再次运行(第 7 步会在连接集成后再次运行)时,才重新打开这些文件。
约定一节中的示例(持久化和非持久化)展示了目标结构。
工作所属的原语:
-
确定性工作——将每个已知操作映射到
toolbox(不使用 Agent):import { toolbox, SlackChannel } from "./terse.generated" await toolbox.slack.sendMessage({ channelId: SlackChannel.Engineering.channelId, message: `New PR: ${event.pullRequest.title}`, thread_ts: "", blocks: "", }) -
Agent 式工作——凡是需要判断的地方(总结、分类、起草),都使用
generateText。通过event.formatForAgentRunner()纳入完整的事件上下文;编写具体的提示词,并传入outputSchema(Zod 架构),以获取经过验证的类型化对象。只有在需要通过run()流式处理,或在多次调用之间复用同一个 Agent 实例时,才使用TerseAgent.create()。 -
如果用户的请求完全确定(例如“当 Y 发生时,将 X 发布到 Slack”),则不要创建 Agent。
**测试目标。**首次运行测试前,如果作业会写入外部目标,请询问用户一次:“测试运行时应使用哪个频道/仓库?(最后我会切换到真实目标。)”如果涉及的 API 会收费或产生客户可见的影响,也要在同一条询问中确认构建期间是否有可用的测试模式密钥(使用 terse secrets add <NAME>)。构建期间,将会产生副作用的调用指向这些测试资源。如果用户表示要使用真实资源,则按真实环境执行。本文件底部的“测试安全约定”一节适用于每次测试运行,包括真实数据、测试密钥、生产环境写入和探测操作。
在本地运行作业。terse test 会将已存储的触发事件重放给真实处理程序:
terse test list "<job-name>" --json
terse test show <id> "<job-name>" --json
terse test run "<job-name>" --id <id>
terse test list 会列出已存储的示例事件,terse test show 会打印其中一个。terse test run --id <id> 会运行该事件;--event-file <path> 或 --event <json> 则会运行你提供的负载。如果存在多个作业,请明确传入作业名称——非交互式作业加载无法提示输入。仅在带有 TTY 的手动会话中使用不带参数的 terse test。
如果示例缓冲区为空(新 webhook 尚无已存储的投递记录),请手动编写事件文件:如果结构不匹配,terse test run 会打印预期的信封结构和确切的验证问题——根据该错误输出修正文件,不要猜测字段。
退出代码为 0 仅表示处理程序已运行。如果 Agent 生成了文本,请阅读输出,并判断它是否符合提示词的意图。
如果构建偏离了已确认的设计,请说明变更内容及原因。不要等待回复;如果有清单正在执行,请勾选相应项目。
10. 切换到真实目标
列出第 9 步中指向的所有测试资源和测试模式密钥,并逐一切换回真实资源——明确列出每一项,以免遗漏。
询问用户是否希望再针对真实生产数据运行一次,还是直接部署工作流。
这次运行会触发一次真实的副作用;运行前请告知用户。
11. 最终检查
核对容易出错的事项:
pnpm exec tsc --noEmit能通过(如果项目使用npx tsc --noEmit,则使用该命令)。- 生成的文件中存在所有
Triggers.<integration>.…、Skills.<integration>(…)、资源常量和工具调用。凭空编造不存在的常量是最常见的错误。 - 已知调用使用
toolbox;只有需要判断的步骤才使用generateText。仅在需要流式传输或重复使用 Agent 时,才出现TerseAgent.create()。 skills列出了模型在一次generateText运行期间必须调用的所有集成。- Agent 提示词通过
event.formatForAgentRunner()包含完整的事件上下文。 - 导入均能解析到
terse-sdk和./terse.generated中实际导出的内容。 - 代码遵循约定部分的规范:没有多余的类型转换、穷尽式 switch、自定义错误类;遵循 stepdown 排序;类型放在底部。
- 持久化任务:所有副作用都放在步骤中;分支辅助函数位于同一文件中、任务定义下方;跨步骤边界传递的只能是可序列化数据。
- 第 9 步中的每个测试目标都已替换回真实资源。
12. 部署前先询问
不要自动运行 terse deploy。编写并验证任务后,询问用户现在是否要部署。
示例提示:
工作流已在本地准备就绪。要使用
terse deploy将其部署到生产环境吗?(这会同步项目中的所有任务——已删除的任务也会从远程删除。)
- 如果用户回答是,则运行
terse deploy并报告结果。 - 如果用户回答否或希望继续修改,则停止部署,并提醒他们准备好后可以运行
terse deploy。
测试安全约定
这些约定适用于每一次本地任务执行——terse test run、terse replay 和临时工具调用(terse integrate tool run)。本地运行会使用真实凭据执行实际处理程序:除非将目标指向安全位置,否则测试运行并不处于沙盒环境中。
输入使用真实事件,输出使用测试目标
示例事件(terse test list、terse replay)是生产环境中的真实数据;将它们用作输入正是目的所在——真实的负载能发现真实的错误。关键在于副作用:测试运行绝不能对真实用户或真实渠道产生写入。邮件、消息、工单更新、CRM 写入——在测试运行期间,这些都必须发送到用户指定的测试目标,绝不能发送给事件中的客户、渠道或记录。
唯一允许的例外是:切换到真实目标后进行一次验证运行,并在执行前告知用户。
测试 API 密钥
如果工作涉及会产生费用或对客户可见的外部 API(Stripe、Resend,等等),请在询问测试目标时一并询问用户一次:平台是否提供测试或沙盒密钥:“这个平台有我在开发期间应该使用的测试模式密钥吗?如果有,请使用 terse secrets add <NAME> 添加。”每次本地运行都使用测试密钥;最终切换到真实目标时,再换成正式密钥。
密钥值只能写入,不能读取:切勿尝试读取已存储的密钥来确认它是测试密钥还是正式密钥。应通过询问来确认,而不是检查。
没有测试密钥:读取无需许可,写入需先询问
没有测试密钥时,本地运行使用与生产环境相同的凭据:
- 读取无需许可。 在测试运行期间列出、获取和查询生产数据始终可以进行。
- 每个写入目标都要询问。 在首次进行会写入生产环境目标(真实代码仓库、正式受众、客户记录)的本地运行前,明确询问用户是否允许操作该目标。针对同一目标的后续运行无需再次询问;新的目标则需要重新询问。
从 CLI 测试工具调用
要了解外部 API 返回的内容——记录的结构、状态字段有哪些选项、存在哪些频道——请从 CLI 运行工具:
terse integrate tool run slack.listChannels
terse integrate tool run attio.records --params '{"request":{"action":"query","objectSlug":"deals","filter":null,"limit":5,"offset":null}}'
- 使用工具的线缆名称(
attio_records)或点分形式(attio.records)指定工具;名称错误时会显示有效名称列表。 --params接受工具线缆格式的 JSON,也就是terse integrate tool <type> <tool-name> --json打印的确切输入架构。请阅读该架构,而不是生成的toolbox.*签名,后者可能与线缆格式不同。参数较大时,请通过 stdin 传入,而不是使用--params。- 连接会自动从项目固定的连接(
terse integrate use <type> <connection-id>)或工作区的唯一连接中解析;如果有多个连接且未固定,请使用terse integrate connections <type> --json列出 ID,然后使用--integration <id>重试,或固定其中一个连接。 - 结果以原始 JSON 的形式输出到 stdout;失败时以非零状态码退出,并将错误输出到 stderr。
发现期间请保持只读:查询、列出、获取。写入操作应放在作业中,并遵循上述各节的规定。
对于没有 toolbox 工具且调用需要填充密钥的 API,请在现有作业中执行读取操作:
- 在作业处理程序顶部插入一个连续代码块,执行读取并记录结果,最后以
return结束——其下方的代码不会执行。 - 使用
terse test run运行,这样密钥的填充方式就与实际作业完全一致。 - 记录发现的内容,然后删除该代码块,并使用
git diff确认处理程序已逐字节恢复到之前的状态。
切勿为此创建单独的临时作业,也切勿在保留该代码块时部署——terse deploy 会同步项目中的每个作业。
发现的内容应以命名且显式指定类型的常量形式写入作业代码——请参阅代码约定中的“发现的值是有类型的常量”。
Terse 作业代码约定
这些约定适用于你在项目作业文件(src/terse.jobs.ts 和 src/jobs/)中编写或修改的每一行作业代码。优先级:关于事实(API 签名、CLI 标志、可用性),以 Terse 实时文档为准;关于风格,以本文档为准。
本会话中用户给出的覆盖指令优先于本文档。
类型规范
为所有内容指定类型;很少进行类型断言。 几乎不要使用 as 或 any。类型断言通常意味着方法不对:应在更高层重构、修正第三方依赖的封装方式,或询问用户该怎么做。优先使用类型守卫、泛型和 satisfies。
在信任边界使用 Zod。 数据跨越你无法控制的边界时,必须在边界处使用 zod 架构(并用 z.infer 获取静态类型):
- 未经官方 SDK 提供类型的外部 API 响应
- webhook 触发器载荷
jobStep的inputSchema/outputSchemagenerateText的outputSchema
由你自行构造的内部结构使用普通类型。官方 SDK 的类型可直接信任;不要在运行时重新验证。
从架构派生静态类型,确保只有一个事实来源:
const Classification = z.object({
severity: z.enum(["critical", "routine"]),
reason: z.string(),
})
type Classification = z.infer<typeof Classification>
显式返回类型。 每个函数声明和命名箭头函数都要显式指定返回类型,包括辅助函数。内联回调——map / filter lambda,以及以内联形式传给 createJob 的 filter 和 onTrigger 函数——保持类型推断。
发现的值是有类型的常量。 通过探测外部状态发现的值(如受众 ID、已验证域名、频道 ID)应以命名且显式指定类型的常量形式保存,绝不能直接以内联字符串字面量的形式传入调用。SDK 中有对应类型时使用该类型,否则使用范围较窄的别名:
const WAITLIST_AUDIENCE_ID: AudienceId = "78261eea-8f8b-4381-83c6-79fa7120f1cf"
type AudienceId = string
控制流
穷尽式判别联合。 使用 switch 根据判别字段进行分派,并在 default 分支末尾加上 throw x satisfies never。绝不使用内联三元表达式进行分派。
switch (classification.severity) {
case "critical":
return escalateCritical(event, classification)
case "routine":
return fileRoutine(event, classification)
default:
throw classification.severity satisfies never
}
不要嵌套使用 try/catch。 如果 catch 代码块需要自己的错误处理逻辑,就将其提取到辅助函数中,并在 catch 中调用该函数。
时间取自事件。 根据 event.triggeredAt 推导日期和时间窗口,绝不使用 Date.now() 或 new Date(),因为队列延迟、重试和重放都会导致时间偏移。窗口计算是普通的同步代码,不需要步骤或辅助函数。
函数式迭代。 转换数据时优先使用 map/filter/reduce,执行同步副作用时使用 forEach;尽量少用 for 循环。如果循环体中有 await,则使用 Promise.all(items.map(...)) 并行处理,或使用 for...of 按顺序等待——绝不要向 forEach 传入异步回调,因为它不会等待回调完成,还会吞掉拒绝。
优先使用 async/await,而不是 .then。 使用 async/await 和 Promise 组合器(Promise.all、Promise.allSettled、Promise.race),而不是 .then() 链。
错误应使用自定义类,并 extend Error,同时设置 this.name:
class MissingSecretError extends Error {
constructor(secretName: string) {
super(`Missing required secret: ${secretName}. Add it with \`terse secrets add ${secretName}\`.`)
this.name = "MissingSecretError"
}
}
对于不应缺失的值,缺失时应报错。 如果契约表明某个值必然存在——SDK 调用的错误已处理,或者 schema 将字段标记为必填——就不要用 ?. 或 ?? null 将缺失值偷偷当作数据传递;应抛出自定义错误,指出违反了哪项不变量。合法的领域状态缺失(例如查找未命中或确实可选的字段)没有问题,但应显式建模:将类型设为 | null,以便每个调用方都能进行分支处理。
const { data, error } = await resend.emails.send({ from, to, subject, html })
if (error) throw new ResendApiError("emails.send", error.message)
if (!data) throw new ResendApiError("emails.send", "no data in response")
return { emailId: data.id }
文件结构
逐层展开规则。 将高层逻辑放在顶部(createJob 及其 onTrigger),辅助函数放在下方,让文件像报纸文章一样阅读:先讲全貌,再讲细节。
类型、接口和 zod schemas 应放在文件底部,位于实现代码之后。处理程序和辅助函数引用的 schemas 只有在作业运行时才会读取,因此放在底部是安全的。
尽量减少注释。 仅在某个选择不明显、看起来奇怪或属于有意妥协时添加注释。
项目布局
始终为每个作业单独建一个文件。 每个作业都应放在 src/jobs/ 中独立的文件里,并以 kebab-case 命名,名称取自作业本身(src/jobs/triage-bug-reports.ts);src/terse.jobs.ts 则是仅包含副作用导入的清单——即使项目只有一个作业,也要如此:
import "./jobs/triage-bug-reports"
import "./jobs/weekly-digest"
createJob() 通过副作用进行注册,因此,如果入口文件从未导入某个作业文件,该作业就不会运行或部署。src/jobs/ 中的每个文件都必须在清单中有对应的导入行。
作业文件通过 ../terse.generated 导入生成的辅助函数。多个作业共用的不含步骤的纯辅助函数可以放在共享模块中;包含步骤的辅助函数应放在其所属作业的文件中(参见下方“将分支提取为辅助函数”),如果多个作业需要该函数,则在各作业文件中分别实现。
库
处理常见问题时优先使用库,而不是自行实现(例如重试、日期计算、解析和验证)。选择广受欢迎且维护良好的库;采用前先检查下载量和近期发布情况。
与平台集成
按此阶梯逐级尝试,并在找到能完成任务的方案时停止:
-
内置的 Terse 集成,已连接 —
src/terse.generated.ts及其terse.generated/文件夹中的所有内容(toolbox.*、Skills.*、Triggers.*)。 -
内置集成类型,尚未连接 — 使用
terse integrate connect连接,然后重新运行terse generate。 -
没有内置集成 — 使用平台的官方 TypeScript SDK,但须先验证其官方身份:由供应商的 npm 组织发布,或由供应商的官方开发者文档 / GitHub 组织链接提供(例如
@slack/web-api、octokit、@linear/sdk、stripe)。充分利用其内置类型。 -
没有官方 SDK — 调研领先的社区封装,并让用户在以下方案中选择:
- 社区封装,并提供具体证据:GitHub 星标数、维护年限、最近一次发布的日期、每周 npm 下载量、维护者声誉
- 手动编写带类型的 fetch 客户端,在响应边界使用 zod 模式,并根据平台的 REST API 文档构建
切勿在未告知用户的情况下采用社区封装。
凭据:超过第 2 级的所有凭据都必须通过项目机密处理:使用 terse secrets add <NAME> 存储,在作业顶部读取 process.env.<NAME>,若缺失则立即抛出自定义错误。
Terse 自身的两个凭据不需要你设置。运行中的作业代码通过 TERSE_PROJECT_KEY 进行身份验证;平台会将其注入每次运行,而 terse run / terse test 会在本地生成它,因此绝不要将它写入 .env 或机密中。TERSE_API_KEY 是 CLI 用于部署、生成和管理集成的用户令牌;它绝不能出现在数据平面的环境中,也绝不能将项目密钥填入其中。terse secrets 会拒绝任何以 TERSE_ 开头的名称,因此,如果你发现自己想要使用这些名称之一,说明设计有误。
标量凭据(API 密钥、令牌)按原样存储。文件形式的凭据——例如 Google 服务账号 JSON、PEM 密钥或任何多行内容——必须使用 base64 编码后存储在以 _B64 结尾的名称下,绝不要直接粘贴原始内容:原始 JSON 会破坏交互式提示,并让 shell 引号处理变成雷区,而 base64 会将该值变为一个安全的单一令牌。请用户提供文件路径,并直接从文件编码,这样明文就不会出现在对话中:
base64 -i service-account.json | terse secrets add GOOGLE_SERVICE_ACCOUNT_B64 --value-stdin
(在 GNU coreutils 中,添加 -w 0 可禁用自动换行。)
在作业顶部解码,并在边界处验证作业所用的字段:
const encoded = process.env.GOOGLE_SERVICE_ACCOUNT_B64
if (!encoded) throw new MissingSecretError("GOOGLE_SERVICE_ACCOUNT_B64")
const parsed = serviceAccountSchema.safeParse(JSON.parse(Buffer.from(encoded, "base64").toString("utf8")))
if (!parsed.success) throw new InvalidSecretError("GOOGLE_SERVICE_ACCOUNT_B64", parsed.error)
对于 _B64 机密,请在缺少机密的错误消息中附上完整的单行编码命令,以便用户可以直接复制粘贴来修复。
持久化作业风格
这些规则适用于设置了 durable: true 的作业。相关机制(重放模型、step()、jobStep、sleep、waitForInput)见 https://docs.useterse.ai/core-concepts/durability;以该文档中的说明为准。
何时启用持久化。 两种信号的处理方式不同:
- 强制信号 — 人工输入或审批(
waitForInput),或定时等待(sleep)。这些原语仅在持久化模式下存在,因此只要出现,就必须设置durable: true;无需让用户决定,只需说明这一结果。 - 判断信号 — 存在三个或更多会产生副作用的阶段,且运行中途失败会留下明显的未完成工作。这需要权衡:建议设置
durable: true,由用户决定。
两种信号都不存在时,默认使用非持久化模式。
默认使用内联 step()。 直接包装每个外部调用 — await step(client.method(args)) — 让处理程序呈现为顺序执行的代码块。简洁的 SDK 调用(toolbox.*、generateText、state.get/state.set)本身就是持久化步骤,保持原样即可。
只有数据可以跨越步骤边界。 参数和解析后的值会被记录到日志中,因此必须可序列化:不能包含闭包、回调、流或实时对象。被调用函数必须定义在模块作用域中。对于多调用单元或接受回调的 API,编写一个模块作用域函数,并包装对它的调用:await step(sendWithRetry(args))。
jobStep({ inputSchema, outputSchema, run }) 仅用于信任边界 — 即跨越持久化边界的值需要运行时验证时;或者某个代码块必须作为一个整体记录到日志中,且无法表示为模块作用域函数调用时。
将分支提取为辅助函数。 当条件分支中包含步骤(step()、jobStep、toolbox.*、generateText、sleep、waitForInput)时,才将其提取为具名辅助函数。仅计算值的条件分支保持内联。辅助函数放在同一文件中、作业定义之后:step() 只会在调用 createJob() 的文件中进行转换,因此将辅助函数移到其他文件会导致它悄无声息地失效。这样,处理程序便可自上而下阅读,只在需要了解某个分支时再展开查看。
根据日志记录的数据进行分支判断。 选择分支的条件必须基于触发事件或步骤结果,这样每次重放都会走相同的路径。
步骤外的代码会在每次重放时重新运行。 让它保持纯粹且开销低;所有副作用都必须放在步骤中。步骤外直接调用 console.log 会在每次重放时打印一次;对于只应打印一次的内容,请使用 terse-sdk 中的 await log(...)(一个记录到日志中的步骤)。
示例
两个示例中的方法名和常量名均来自项目生成的文件;切勿自行编造。
持久化
下面的作业展示了目标持久化结构:顶部的处理程序按顺序呈现代码块,通过穷尽式 switch 分发到会产生副作用的分支辅助函数,并将模式和类型放在底部。
import { createJob, generateText, slack, sleep, waitForInput } from "terse-sdk"
import { z } from "zod"
import { Triggers, LinearTeam, SlackChannel, toolbox, type LinearIssueCreatedTrigger } from "./terse.generated"
createJob({
name: "Triage inbound bug reports",
triggers: [Triggers.linear.onIssueCreated({ team: LinearTeam.Support })],
durable: true,
filter: async event => !event.issue.title.startsWith("[test]"),
onTrigger: async event => {
const classification = await generateText({
prompt:
`Classify this bug report as critical (data loss, security, outage) or routine. ` +
`Explain your reasoning in one sentence. Context: ${event.formatForAgentRunner()}`,
skills: [],
outputSchema: Classification,
})
switch (classification.severity) {
case "critical":
return escalateCritical(event, classification)
case "routine":
return fileRoutine(event, classification)
default:
throw classification.severity satisfies never
}
},
})
async function escalateCritical(event: LinearIssueCreatedTrigger, classification: Classification): Promise<void> {
await toolbox.slack.sendMessage({
channelId: SlackChannel.OnCall.channelId,
message: `Critical bug: ${event.issue.title} — ${classification.reason}`,
thread_ts: "",
blocks: "",
})
const approval = await waitForInput({
via: slack({ channel: SlackChannel.OnCall.channelId }),
prompt: "Page the on-call engineer?",
details: { issue: event.issue.title, reason: classification.reason },
options: [
{ id: "page", label: "Page now" },
{ id: "hold", label: "Hold until morning" },
],
})
if (approval.choice === "hold") {
await sleep("8h")
}
await toolbox.linear.updateIssue({ issueId: event.issue.id, priority: 1 })
}
async function fileRoutine(event: LinearIssueCreatedTrigger, classification: Classification): Promise<void> {
await toolbox.linear.createComment({
issueId: event.issue.id,
body: `Auto-triaged as routine: ${classification.reason}`,
})
}
const Classification = z.object({
severity: z.enum(["critical", "routine"]),
reason: z.string(),
})
type Classification = z.infer<typeof Classification>
非持久化
一个完整的非持久化作业:确定性发布、Agent 式摘要、确定性线程回复。
import { createJob, generateText } from "terse-sdk"
import { Triggers, Skills, Repos, SlackChannel, toolbox, type GithubPROpenedTrigger } from "./terse.generated"
createJob({
name: "Summarize PR and notify Slack",
triggers: [Triggers.github.onPROpened({ repo: Repos.MyOrg.MyRepo })],
filter: async (event: GithubPROpenedTrigger) => {
return !event.sender.login.includes("[bot]")
},
onTrigger: async (event: GithubPROpenedTrigger) => {
// Deterministic: fixed channel, fixed opener — use toolbox, no agent needed
const message = await toolbox.slack.sendMessage({
channelId: SlackChannel.Engineering.channelId,
message: `New PR from ${event.sender.login}: ${event.pullRequest.title}`,
thread_ts: "",
blocks: "",
})
// Agentic: only the summary needs judgment
const summary = await generateText({
prompt:
`Summarize the changes in this PR. ` +
`Focus on what changed, why it matters, and what reviewers should look at first. ` +
`Keep it concise. Context: ${event.formatForAgentRunner()}`,
skills: [Skills.github({ repos: [Repos.MyOrg.MyRepo] })],
})
// Deterministic: post the result back in thread
await toolbox.slack.sendMessage({
channelId: SlackChannel.Engineering.channelId,
message: summary,
thread_ts: message.message_ts,
blocks: "",
})
},
})