mcpField
T
terse-create

terse-create

github.com/terseai生产力39 次安装
代码仓库

关于 terse-create

terse-create 是用于构建 Terse 自动化工作流的技能,将工作流定义为 job。它引导 Agent 查阅生成的集成定义、确定设计、实现和测试任务,并按需部署。

主要功能

  1. 01引导设计并实现工作流
  2. 02以生成的集成定义为准
  3. 03提供集成配置指导
  4. 04涵盖测试与部署步骤
  5. 05说明进度并报告测试结果

适用场景

  1. 01为指定自动化需求构建 Terse job
  2. 02查看可用的集成触发器和工具
  3. 03用示例事件测试工作流
  4. 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 平台:

  1. 项目本身中的代码(如果存在):(src/terse.jobs.ts, src/jobs/)
  2. 生成的文件应作为首要事实来源,并说明 Terse 的工作方式:src/terse.generated.ts 是入口文件——其文件头列出了所有可用的集成,并附有 src/terse.generated/ 文件夹的行号索引。该文件夹中,每种集成和类型各有一个文件(<integration>.triggers.ts 用于触发器工厂和载荷类型,<integration>.tools.ts 用于类型化工具方法、资源和技能,attio.schemas.ts 等工作区架构文件,以及 common.ts 中的共享基础设施)。使用索引,只打开工作流所需的文件。生成的文件会说明现有内容及其确切形式;如果它们与在线文档不一致,以生成的文件为准——缺少辅助函数意味着应重新运行 terse generate,绝不要依据生成文件未支持的文档说明编写代码。
  3. terse CLI:(terse --help)
  4. 最后才使用在线 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 中编写任务,准确的类型和签名必须在你的上下文中,因此委派这项读取工作只会增加一次无用的检查。请完整阅读:

  1. src/terse.generated.ts(根文件)。文件头列出了 Terse 当前提供的所有集成,并附有 src/terse.generated/ 文件夹的行号索引;下方的重新导出内容则体现了已连接的集成。
  2. 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、身份验证模型和关键端点。

派遣研究员。 对每位研究员:

  1. 阅读 references/ 中对应的模板文件。
  2. 用实际内容替换“来自协调器的上下文”注释块:用一段话描述工作流、涉及的平台/事件/操作,以及用于缩小搜索范围的访谈回答和扫描结果。如果模板中有“目标”部分,请填写本次运行需要解答的具体问题——研究员会以最少的获取次数来解答这些目标,因此目标含糊只会导致过度阅读。
  3. 如果你的运行环境支持生成子代理(Agent/Task 工具),请将填好的完整模板作为子代理的提示词,并使用只读且可联网的 Agent 类型。模板是自包含的:子代理不需要其他上下文,其回复就是研究简报。
  4. 如果不支持,则亲自按填好的模板执行,并在继续之前写出同样格式的简报。

在运行环境允许时并行开展后台研究。 一旦扫描确认有必要,就立即派遣集成研究员;在你进行步骤 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 或在容器中会静默失败)。因此请按以下顺序操作:
    1. 先随意地告知用户接下来会发生什么,以及运行会暂停,直到他们完成操作:“我现在要连接 Gmail——会弹出一个浏览器标签页;请在那里完成登录,我会随后继续。”
    2. 运行 terse integrate connect <type> --json。它会打开浏览器,然后以状态码 2 退出,并返回包含授权 url 和 waitCommand 的 handoff 负载。
    3. 将负载中的 url 作为可点击的备用链接发给用户:“如果没有弹出标签页,这是链接:”。
    4. 运行 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,请在现有作业中执行读取操作:

  1. 在作业处理程序顶部插入一个连续代码块,执行读取并记录结果,最后以 return 结束——其下方的代码不会执行。
  2. 使用 terse test run 运行,这样密钥的填充方式就与实际作业完全一致。
  3. 记录发现的内容,然后删除该代码块,并使用 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 / outputSchema
  • generateText 的 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 导入生成的辅助函数。多个作业共用的不含步骤的纯辅助函数可以放在共享模块中;包含步骤的辅助函数应放在其所属作业的文件中(参见下方“将分支提取为辅助函数”),如果多个作业需要该函数,则在各作业文件中分别实现。

库

处理常见问题时优先使用库,而不是自行实现(例如重试、日期计算、解析和验证)。选择广受欢迎且维护良好的库;采用前先检查下载量和近期发布情况。

与平台集成

按此阶梯逐级尝试,并在找到能完成任务的方案时停止:

  1. 内置的 Terse 集成,已连接 — src/terse.generated.ts 及其 terse.generated/ 文件夹中的所有内容(toolbox.*、Skills.*、Triggers.*)。

  2. 内置集成类型,尚未连接 — 使用 terse integrate connect 连接,然后重新运行 terse generate。

  3. 没有内置集成 — 使用平台的官方 TypeScript SDK,但须先验证其官方身份:由供应商的 npm 组织发布,或由供应商的官方开发者文档 / GitHub 组织链接提供(例如 @slack/web-api、octokit、@linear/sdk、stripe)。充分利用其内置类型。

  4. 没有官方 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: "",
        })
    },
})

评论

支持 Markdown