# API 参考 — Annulo

> Annulo 给项目的全部平台原语：本机函数和 ctx、业务表、定时任务、任务、插件、页面接口和命令行。写模板、写插件，或者想知道助手能做什么，从这里查。

API 参考

开始

[概览](#overview)

本机函数

[本机函数](#functions) [ctx 一览](#ctx) [业务表读写](#db) [联网](#fetch) [浏览器](#browser) [命令行、密钥、MCP、模型](#exec)

云端和手机

[云端和远程函数](#cloud)

项目文件

[业务表](#tables) [定时任务](#schedules) [任务、写法和说明](#tasks) [项目文件和 user/](#manifest) [插件](#plugins)

页面和命令行

[页面能调的接口](#pages) [命令行 annulo](#cli) [助手的工具](#agent) [能力版本历史](#versions)

[帮助手册](/help.md)

# API 参考

Annulo 给项目的全部平台原语：本机函数和 ctx、业务表、定时任务、任务、插件、页面接口和命令行。写模板、写插件，或者想知道助手能做什么，从这里查。

能力版本33 [源码](https://github.com/annulo/annulo)

开始

## 概览

Annulo 只提供 **原语**：让助手（或你自己）能在项目里做出业务功能、并让它稳定运行的通用能力。具体的业务（某个平台怎么发帖、某张表存什么）写在项目里，Annulo 不认识任何具体平台。

一个项目是一个目录，原语都通过这几类文件和接口用到：

| 位置 | 用到的原语 |
| --- | --- |
| `local/*.ts` | [本机函数](#functions) 和 [ctx](#ctx)：表、联网、浏览器、命令行、模型、MCP、密钥 |
| `tables/<表>.json` | [业务表](#tables) |
| `schedules/<id>.json` | [定时任务](#schedules) |
| `tasks/`、 `prompts/`、 `skills/`、 `ANNULO.md` | [任务和说明](#tasks) |
| `annulo.json`、 `user/` | [项目文件](#manifest) |
| `plugins/<id>/` | [插件](#plugins) |
| `pages/` | [页面能调的接口](#pages) |

能力版本

当前能力版本是 **33**。每加一个原语，版本 +1（ [历史](#versions)）。模板用到了某个版本才有的原语，就在 `annulo.json` 里写 `"min_annulo_api": <版本>`：Annulo 低于它时，升级卡片提示先更新 Annulo、拒绝合并。

新旧名字

Shuttle 改名 Annulo 后，旧名字都还能用： `shuttle.json` / `SHUTTLE.md` / `/_shuttle/` / `X-Shuttle` / `shuttle` 命令 / `SHUTTLE_*` 环境变量。

在线和离线

离线项目（默认）的数据在本机 SQLite；在线项目（连了 creght）的数据在 creght 上。两者的 `ctx.db` 同名同参数。标着 **\[在线\]** 的只有在线项目能用。

本机函数

## 本机函数

确定的、反复跑的逻辑写成本机函数。页面按钮、定时任务、助手都直接调用它，不经过模型。

怎么写

- 文件放在 `local/<文件>.ts`，文件名匹配 `^[a-z0-9][a-z0-9_-]*$`；以 `_` 开头的文件（如 `local/_api.ts`）放共用代码，用相对路径 import；
- 导出函数 `export function name(input, ctx)`，可以是 async。调用名是 `文件.函数`；插件里的是 `<插件>/<文件>.<函数>`；
- `throw new Error(msg)`：message 原样显示给用户，stack 进日志；
- 返回值要能转成 JSON。

```
// local/leads.ts
export async function stats(input: { days?: number }, ctx) {
  const since = new Date(Date.now() - (input.days ?? 30) * 864e5).toISOString()
  const { list } = ctx.db.aggregate('leads', {
    filter: [{ field: 'created_at', op: 'gte', value: since }],
    group_by: [{ field: 'created_at', trunc: 'day', as: 'day' }],
    metrics: [{ op: 'count' }],
  })
  return list
}
```

运行环境

- esbuild 打成 CommonJS（ES2017）后在内嵌的 JS 引擎里跑， **不能 require npm 包**； `talizen` 只能做类型导入；
- 全局有： `fetch`、 `console`、 `URL`（常用字段和 `searchParams.get/has`）、 `btoa` / `atob`、 `TextEncoder` / `TextDecoder`；
- 没有 `setTimeout`，要等待用 `await ctx.sleep(ms)`；
- 一次运行最长 60 分钟。

怎么调

```
annulo run                                  # 列出所有函数 / list functions
annulo run leads.stats --input '{"days":7}'
annulo run leads.stats --input @input.json
```

进度打到 stderr，结果打到 stdout。页面里调见 [页面能调的接口](#pages)。运行日志： `annulo logs --fn leads.stats`。

本机函数

## ctx 一览

函数的第二个参数 `ctx`。「云端」列是 [cloud 函数](#cloud) 里能不能用。

| 成员 | 签名 | 说明 | 云端 |
| --- | --- | --- | --- |
| `ctx.db` | 见 [业务表读写](#db) | query / aggregate / get / insert / update / delete | ✓ |
| `fetch` | `await fetch(url, { method, headers, body })` | 全局 fetch，不能访问本机和内网，见 [联网](#fetch) | ✓ |
| `ctx.fetchAll` | `await ctx.fetchAll([url | { url, method, headers, body }])` | 最多 4 个并发，顺序和输入一致 | ✗ |
| `ctx.html` | `ctx.html(text)` → `.find(css)` / `.text()` / `.markdown()` | 解析 HTML | ✗ |
| `ctx.browser` | 见 [浏览器](#browser) | 用本机 Chrome 和你的登录状态操作网页 | ✗ |
| `ctx.exec` | `await ctx.exec(cmd, args, { cwd?, input?, timeout?, env? })` | 跑命令行工具，见 [命令行](#exec) | ✗ |
| `ctx.secrets.get` | `(name) → string | null` | 读 设置 → 密钥 里的值，或环境变量 | ✗ |
| `ctx.oauth` | `await ctx.oauth('google', { account? }) → access_token` | 设置 → 连接 里授权的账号； `ctx.oauth.accounts('google')` 列出账号 | ✗ |
| `ctx.mcp` | `await ctx.mcp(server, tool, args?)` | 调接好的 MCP 工具，JSON 结果已解析； `ctx.mcp.servers()` 看连接状态 | 只有 `creght` |
| `ctx.llm` | `await ctx.llm(prompt)` 或 `ctx.llm({ system, prompt }) → string` | 用当前模型答一次，不带工具、不进对话 | ✗ |
| `ctx.llm.providers` | `() → [{ id, name, kind, models, … }]` | 设置 → 模型 里的服务商，不含 key | ✗ |
| `ctx.llm.fetch` | `await ctx.llm.fetch(providerId, path, init) → Response` | 直接调服务商接口，Annulo 补地址和鉴权（可流式） | ✗ |
| `ctx.agent.current` | `() → { id, name, provider, model, ready, error }` | 助手当前用的模型、能不能跑 | ✗ |
| `ctx.progress` | `({ done, total, message })` | 推进度到页面和命令行 | 不生效 |
| `ctx.log` | `(...args)` | 写运行日志 | 不生效 |
| `ctx.sleep` | `await ctx.sleep(ms)` | 最长 60 秒 | 不生效 |
| `ctx.locale` | `'zh' | 'en'` | 界面语言 | ✓ |
| `ctx.workspace` | 对象 | `project_id`、 `site_id`、 `offline`、 `machine: { id, name }`、 `logs_dir`、 `annulo_api`… | `undefined` |
| `ctx.chat_id` | string | 助手在对话里跑时是那段对话的 id，按钮和定时任务是 `''` | `''` |

`ctx.llm` 只用在没人盯着的短判断（比如给每条新询盘打分）。用户点按钮、要 AI 写长内容的，做成 [任务](#tasks)。

本机函数

## 业务表读写

`ctx.db` 只能读写项目里 [声明过的表](#tables)。读写是同步的。

| 方法 | 返回 |
| --- | --- |
| `query(table, { where?, filter?, order_by?, limit?, offset?, cursor? })` | `{ total, list, limit, next_cursor }`；limit 默认 20，1–1000 |
| `aggregate(table, { where?, filter?, group_by?, metrics, order_by?, limit?, timezone? })` | `{ list, truncated }`；最多 10000 组 |
| `get(table, id)` | 一行或 `null` |
| `insert(table, data)` | 新的一行（带 `id`） |
| `update(table, id, data)` | `{ ok: true }`；顶层浅合并，值写 `null` 删掉这个字段 |
| `delete(table, id)` | — |

```
const { total, list, next_cursor } = ctx.db.query('articles', {
  where: { status: 'draft' },
  filter: [{ field: 'words', op: 'gte', value: 800 }],
  order_by: 'updated_at desc',
  limit: 50,
})
const row = ctx.db.insert('articles', { title: 'Hello', status: 'draft' })
ctx.db.update('articles', row.id, { status: 'published', draft_note: null })
```

条件

- **where**：字段相等， `{ channel_id: 'x' }`。标量也匹配数组字段里的一项； `where.id` 按记录主键读；
- **filter**： `[{ field, op, value }]`，或 `{ match: 'and', conditions: [{ fieldId, operator, value }] }`，条件之间都是 AND；
- **运算符**： `eq`（默认）、 `neq`、 `in`、 `gt`、 `gte`、 `lt`、 `lte`、 `between`（value 写 `[起, 止]`，两端都含）。数字按数值比、字符串按字典序，类型不同的不参与。写错直接报错，不会悄悄变成不过滤；
- **order\_by**： `'date desc'`，多个用逗号分隔；默认新建的在前；
- **cursor**：读完全表用。第一页传 `''`，之后传上一页的 `next_cursor`，它为空就读完了；不能和 order\_by、offset 同时用。

汇总

- **group\_by**： `['field', { field, trunc: 'day' | 'week' | 'month' | 'year', as }]`， `week` 从周一算；
- **metrics**： `[{ op: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'first' | 'last', field, as, order_by }]`，first / last 是组内按 order\_by 排序后的首尾值；
- 输出列名默认 `<op>_<field>`，count 是 `count`；avg 保留 6 位小数；
- **timezone** 默认 `Asia/Shanghai`。

系统字段： `id`、 `created_at`、 `updated_at`。助手的 `db_query` / `db_aggregate` 工具和这里同参数。

本机函数

## 联网

fetch

全局 `fetch(url, { method, headers, body })`，返回的 Response 有：

- `ok`、 `status`、 `statusText`、 `url`、 `headers.get(name)`、 `headers.forEach(fn)`；
- `text()`、 `json()`、 `arrayBuffer()`；
- `body.getReader()`：流式读， `read()` 返回 `{ done, value: Uint8Array }`；
- `timing: { ttfb_ms, total_ms }`、 `truncated`。

限制：不能访问本机和内网；响应体最多 10MB（超出时 `text()` 截断、 `truncated` 为 true）；等响应头最多 2 分钟，之后连续 1 分钟没数据才断开，不限总时长。

ctx.fetchAll

一次发多个请求，最多 4 个并发，结果顺序和输入一致。失败的那一项是 `{ ok: false, status: 0, url, error }`，不会让整批失败。

ctx.html

`ctx.html(text)` 解析 HTML： `.find(css)` 返回 `[{ tag, text, attrs }]`， `.text()` 取纯文本， `.markdown()` 转成 Markdown。选择器写错直接报错。

本机函数

## 浏览器

`ctx.browser` 用这台电脑上的 Chrome 操作网页。每个 profile 是一份独立的登录状态（存在本机），社媒发布、采集就靠它：和你自己操作一样，同一台设备、同一个网络。

```
const b = await ctx.browser.open({ profile: 'x-main', url: 'https://x.com/home' })
await b.waitFor('[data-testid="tweetTextarea_0"]')
await b.type('[data-testid="tweetTextarea_0"]', input.text)
await b.click({ text: 'Post' })
await b.close()
```

- `ctx.browser.open({ profile, url?, show?, offscreen?, keep_open? }) → b`： `keep_open` 要配 `show: true`；
- `ctx.browser.profile(name) → { id } | null`：这个 profile 在不在。

| 方法 | 说明 |
| --- | --- |
| `goto(url, { wait?, timeout? })` | 打开网址 |
| `waitFor(sel, { visible?, timeout? })` | 等元素出现 |
| `exists(sel)` | 元素在不在 |
| `click(sel | { text }, { timeout? })` | 点击 |
| `type(sel, text, { clear?, timeout? })` | 输入 |
| `press(key)` | 按键 |
| `upload(sel, files)` | 上传：网址（流式下载，最多 2GB）、 `/_annulo/uploaded/…`、 `'local:<name>'`、截图的 `{ file }` |
| `eval(fn | string)` | 在页面里执行，返回值要能转成 JSON |
| `text(sel?)` / `html(sel?)` / `url()` | 读文字、源码、当前地址 |
| `listen(pattern)` / `responses(pattern, { min?, timeout? })` | 记录并取回匹配的接口响应 `[{ url, status, json, text }]` |
| `screenshot({ selector?, fullPage? }) → { file }` | 截图 |
| `snapshot({ label }) → { dir }` | 主动存一份现场 |
| `setContent(html)` | 把一段 HTML 放进页面 |
| `close()` | 关闭 |

单个操作默认最多等 30 秒。goto / waitFor / click / type / upload / responses 失败时自动存一份现场（截图、可操作的元素、源码），报错里带 `err.snapshot`，助手修脚本时照着它改。

本机函数

## 命令行、密钥、MCP、模型

ctx.exec

`await ctx.exec(cmd, [args…], { cwd?, input?, timeout?, env? }) → { code, stdout, stderr, truncated }`

- 不经过 shell，命令和参数分开传；要管道、重定向就 `ctx.exec('sh', ['-c', …])`；
- 命令按登录 shell 的 PATH 找； `cwd` 相对项目根目录，不能出项目；
- `timeout` 默认 30 秒，最多 10 分钟；stdout、stderr 各保留 10MB；
- 退出码不是 0 不会 reject，自己看 `code`；找不到命令、超时、cwd 不对才 reject。

ctx.secrets

`ctx.secrets.get(name)` 读 设置 → 密钥 里存的值（没有就读同名环境变量），没有返回 `null`。密钥只存在本机，助手读不到。

ctx.oauth

`await ctx.oauth('google', { account? })` 拿 设置 → 连接 里授权的 access token（Search Console、GA4 只读）。连了多个账号时用 `account` 指定， `ctx.oauth.accounts('google')` 列出账号，最早连的在前。

ctx.mcp

`await ctx.mcp(server, tool, args?)` 调 设置 → MCP 里接好的工具，结果是 JSON 文本时已解析；工具报错时 reject。 `ctx.mcp.servers()` 返回 `[{ name, status }]`，status 是 `connected` / `needs_auth` / `connecting` / `failed` / `disabled`。

ctx.llm

- `await ctx.llm('prompt')` 或 `ctx.llm({ system, prompt })`：用当前模型答一次，返回字符串；
- `ctx.llm.providers()`：服务商列表 `[{ id, name, kind: 'api' | 'cli', api, base_url, enabled, ready, models: [{ id, model, name, web_search }] }]`，不含 key；
- `ctx.llm.fetch(providerId, path, init)`：直接调服务商的接口，Annulo 补地址和鉴权，可以流式读；只对 `kind === 'api'` 的服务商。

云端和手机

## 云端和远程函数

**\[在线\]** 本机函数默认只在 Annulo 里能调。项目连了 creght 以后，手机、别的电脑也能打开后台，这时页面要调的函数在哪跑？由你在函数文件里声明：

- `export const cloud = [...]`： **在 creght 上跑**，电脑关着也能用。只能读写业务表；
- `export const remote = [...]`： **转到你的电脑上跑**，要电脑开着。本机能用的都能用（浏览器、密钥、命令行）。

例子：出门在外，看小红书数据

你在地铁上，用手机打开后台的「社媒」页：

1. **页面先显示近 30 天的点赞趋势。** 这只是读表、算一下，在 creght 上就能做，家里电脑关着也没关系 → `cloud`；
2. **你觉得数据旧了，点「立即采集」。** 采集要打开小红书主页、用你登录过的账号去读，这些只有你的电脑上有 → `remote`：creght 把这次调用转给家里开着的 Annulo，它在电脑上开浏览器读数、写进表，进度一路推回你的手机；
3. **没人点的时候**，定时任务每 6 小时在电脑上采一次。

同一个文件，两个函数，各自声明在哪跑：

```
// local/xhs.ts
export const remote = ['collect']   // 要用浏览器的登录状态：只能在你的电脑上跑
export const cloud = ['summary']    // 只读表：在 creght 上跑，电脑关着也能用

/** 打开账号主页，读每篇笔记的点赞，记一行当天的数 */
export async function collect(input: { channel_id: string }, ctx) {
  const ch = ctx.db.get('social_accounts', input.channel_id)
  ctx.progress({ message: `正在打开「${ch.name}」的主页…` })   // 手机上的按钮也会显示这句

  const b = await ctx.browser.open({ profile: ch.profile, url: ch.home_url })
  const notes = await b.eval(() =>
    [...document.querySelectorAll('section.note-item')].map((el) => ({   // 选择器按平台页面写
      id: el.getAttribute('data-id'),
      likes: Number(el.querySelector('.count')?.textContent ?? 0),
    })),
  )
  await b.close()

  const today = new Date().toISOString().slice(0, 10)
  for (const n of notes) ctx.db.insert('social_post_daily', { channel_id: ch.id, post_id: n.id, date: today, likes: n.likes })
  return { notes: notes.length }
}

/** 近 days 天每天的点赞合计 */
export function summary(input: { channel_id: string; days?: number }, ctx) {
  const since = new Date(Date.now() - (input.days ?? 30) * 864e5).toISOString().slice(0, 10)
  return ctx.db.aggregate('social_post_daily', {
    where: { channel_id: input.channel_id },
    filter: [{ field: 'date', op: 'gte', value: since }],
    group_by: ['date'],
    metrics: [{ op: 'sum', field: 'likes', as: 'likes' }],
  }).list
}
```

```
// schedules/xhs-collect.json：没人点的时候，电脑每 6 小时采一次
{ "name": "采集小红书", "fn": "xhs.collect", "every": "6h", "input": { "channel_id": "<账号 id>" } }
```

写完 `annulo push` 一次： `summary` 被打包成 creght 上的站点函数； `collect` 不用打包，Annulo 连上平台时自己报上去。

在哪运行

| 谁调的 | `xhs.summary`（cloud） | `xhs.collect`（remote） |
| --- | --- | --- |
| 电脑上，Annulo 里点按钮 | 这台电脑 | 这台电脑 |
| 手机上点按钮 | creght 上，电脑关着也行 | creght 中转 → 你的电脑（Annulo 要开着） |
| 定时任务 | — | 这台电脑 |
| 助手在对话里 `annulo run` | 这台电脑 | 这台电脑 |

页面不用管这些：在 Annulo 里调 `local/run`；在 Annulo 外， `cloud` 函数调站点函数 `local/<文件>`， `remote` 函数经站点函数 `shuttle.call` 转给电脑。模板的 `runLocal('xhs.summary', …)` 已经包好了这一层，同一行代码在两边都能用。

cloud 的规则

- 只有项目成员能调；
- 能用： `ctx.db`、 `ctx.locale`、 `ctx.mcp('creght', …)`（只读、只给所有者）、 `ctx.mcp.servers()`； `progress` / `log` / `sleep` 不生效， `workspace` 是 `undefined`；
- 用到 `secrets`、 `fetchAll`、 `html`、 `oauth`、 `browser`、 `llm`、 `exec`、 `agent` 会报错。所以上面的 `collect` 放不进 `cloud`。

remote 的规则

- 电脑上 Annulo 要开着，并且 设置 → 远程访问 里打开了「允许远程调用这台电脑」（默认关）；
- 只有项目所有者能调，电脑只执行声明在 `remote` 里的函数；
- 函数照常在电脑上跑， `ctx.progress` 的进度会推回手机。

同一个函数不能既在 `cloud` 又在 `remote`。

项目文件

## 业务表

一张表一个文件 `tables/<表>.json`，文件名就是表名（匹配 `^[a-z][a-z0-9_]{1,40}$`）。

```
{
  "name": "询盘",
  "desc": "网站表单和邮件进来的询盘",
  "json_schema": {
    "type": "object",
    "properties": {
      "email":   { "type": "string", "description": "客户邮箱" },
      "score":   { "type": "integer", "description": "意向分 0-100" },
      "tags":    { "type": "array" }
    },
    "required": ["email"]
  }
}
```

- 声明了就能读写：离线项目不用建表；在线项目在文件变了时自动补建缺的表；
- 只有声明过的表能读写： `ctx.db`、页面的 `db/<表>`、助手的工具都受这条限制；
- 插件的表名自动加前缀： `plugins/<id>/tables/t.json` 在项目里是 `<id>_t`；
- 系统字段： `id`、 `created_at`、 `updated_at`；
- `json_schema` 是给人和助手看的说明，Annulo 不按它校验写入。

项目文件

## 定时任务

一个任务一个文件 `schedules/<id>.json`，文件名就是 id。

```
// schedules/collect.json
{ "name": "采集社媒数据", "fn": "social.collect", "every": "6h" }

// schedules/weekly.json
{ "name": "写周报", "task": "weekly-report", "every": "7d", "at": "09:00" }
```

| 字段 | 说明 |
| --- | --- |
| `fn` / `task` / `prompt` | 三选一：跑本机函数；跑 `tasks/<id>.md`；把一段话交给助手（新开一段对话） |
| `every` | `30m` / `6h` / `1d` / `7d`，最短 5 分钟 |
| `at` | `HH:MM`，本机时区；和 `every` 都写时按 `at` |
| `input` | 传给函数或任务的参数 |
| `name` | 显示名 |

- 只在 Annulo 开着时跑；错过的那次下次打开时补跑一次；
- task / prompt 一轮最长 30 分钟，只在当前打开的项目里跑；别的项目只跑 `fn`；
- 插件的定时任务 id 是 `<插件>/<id>`，里面的 fn / task 写全名。

项目文件

## 任务、写法和说明

任务 `tasks/<id>.md`

交给助手的活（写文章、出选题、写周报）。frontmatter 只认三个单行字段： `name`、 `description`、 `thinking`（off / minimal / low / medium / high / xhigh / max），正文是给助手的说明。页面按钮通过 [local/tasks/<id>/run](#pages) 在后台开一段对话去跑。插件的任务 id 是 `<插件>/<任务>`。

写法 `prompts/<id>.md`

任务里用户关心的「怎么写」（结构、语气、长度）单独放一个文件：模板给默认的 `prompts/<id>.md`，用户在页面上改了存到 `user/prompts/<id>.md`，有就优先用它。取数、存表、格式这些规则留在任务文件里。

skill `skills/<名字>/SKILL.md`

写给助手的做事说明，frontmatter 有 `name`（小写字母、数字、 `-`，和目录名一致）和 `description`。

项目说明

- `ANNULO.md`：项目的总说明，每段新对话都放进助手的系统提示，最多 8000 字；
- `INSTRUCTIONS.md`：用户在 设置 → 项目 里写的「给助手的说明」，和项目说明冲突时以它为准。

项目文件

## 项目文件和 user/

annulo.json

```
{
  "name": { "zh": "自媒体工作台", "en": "Creator studio" },
  "description": { "zh": "…", "en": "…" },
  "min_annulo_api": 33,
  "plugins": { "social": "https://github.com/annulo/plugins#social" },
  "assistant": {
    "intro": { "zh": "…", "en": "…" },
    "suggestions": { "zh": ["…"], "en": ["…"] }
  }
}
```

| 字段 | 说明 |
| --- | --- |
| `name` / `description` | 模板的名字和介绍，字符串或 `{ zh, en }` |
| `min_annulo_api` | 需要的 [能力版本](#versions)，Annulo 低于它时拒绝升级合并 |
| `plugins` | `{ "<id>": "<仓库>#<目录>" }`，新建项目时自动装 |
| `assistant` | 右侧助手面板： `name`、 `intro`、 `suggestions`（每项字符串或 `{ zh, en }`） |

模板要兼容能力版本低于 26 的 Annulo，还要带一份 `shuttle.json`。

user/

用户自己的定制： `user/prompts/`、 `user/annulo.json`（用户装、卸的插件；写 `null` 表示不要模板带的那个）、 `user/plugins/<id>/…`。 **模板和插件都不往这里写**，所以升级永远不会和用户的定制冲突。要给用户留可定制的地方：模板给默认，用户的存到 `user/` 下，有就优先用。

模板版本

模板是 git 仓库里的一个目录（有 `annulo.json`），版本是 semver tag（ `v1.2.0`，预发布版不提供），tag 的说明就是用户看到的升级说明。升级时 Annulo 在项目的 git 里做三方合并。

项目文件

## 插件

插件是一组能装进任何项目的功能，放在 `plugins/<id>/`（id 匹配 `^[a-z][a-z0-9]{1,19}$`），和模板分开升级。

| 文件 | 说明 |
| --- | --- |
| `plugin.json` | `{ name, description, min_annulo_api }`，name / description 可写 `{ zh, en }` |
| `PLUGIN.md` | 给助手的说明 |
| `local/`、 `tables/`、 `tasks/`、 `prompts/`、 `schedules/`、 `skills/`、 `components/` | 和项目里的同名目录一样 |

- 不带页面，页面由模板或助手做；
- 函数调用名 `<id>/<文件>.<函数>`，表名 `<id>_<表>`，任务和定时任务 id `<id>/<名字>`；
- 用户改过的写法在 `user/plugins/<id>/prompts/<任务>.md`；
- 发布：仓库里一个有 `plugin.json` 的子目录，版本是 semver tag。示例： [annulo/plugins](https://github.com/annulo/plugins)。

页面和命令行

## 页面能调的接口

`pages/` 下每个文件是一条路由（react-router 7，只在客户端渲染），Annulo 在本机渲染，保存就刷新。页面通过同源接口用原语：前缀 `/_annulo/api/`， **必须带请求头 \`X-Annulo: 1\`**。响应都带 `X-Annulo` 头，页面靠它判断自己在不在 Annulo 里。

```
const res = await fetch('/_annulo/api/local/run', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-Annulo': '1' },
  body: JSON.stringify({ fn: 'leads.stats', input: { days: 7 } }),
})
// SSE：data: {"type":"progress",...} … data: {"type":"result","data":{"value":…,"ms":…}}
```

| 接口 | 说明 |
| --- | --- |
| `GET local/functions` | `{ list: [{ name, file }] }` |
| `POST local/run` | `{ fn, input }`，返回 SSE： `start`、 `progress`、 `log`，最后 `result { value, ms }` 或 `error { message }`。页面关掉也会跑完 |
| `POST local/runs/<id>/abort` | 停止一次运行（id 在响应头 `x-annulo-run`） |
| `GET local/logs?fn=&id=&limit=` | 运行日志 |
| `GET local/tasks`、 `GET local/tasks/<id>` | 任务列表：说明、写法、正在跑的、上次结果 |
| `PUT local/tasks/<id>` | `{ prompt }` 改写法， `{ reset_prompt: true }` 恢复默认 |
| `POST local/tasks/<id>/run` | `{ input? }` → `{ chat_id }`，后台开一段对话跑任务；同样参数正在跑时 409 |
| `POST local/ask` | `{ text, title? }` → `{ chat_id }`，后台开一段对话交给助手 |
| `GET agent/running` | 正在跑的对话，页面轮询它看交出去的活跑完没有 |
| `GET local/schedules`、 `POST local/schedules/<id>/run`、 `…/toggle` | 定时任务：列表、立即运行、暂停 / 恢复 |
| `GET db/<表>` | `{ list }`：全表，新的在前 |
| `POST db/<表>`、 `PATCH db/<表>?id=`、 `DELETE db/<表>?id=` | 新建、合并更新、删除一行 |
| `POST local/upload` | multipart 字段 `file`，单个最多 200MB → `{ url, … }` |
| `POST local/files` | 请求体是文件，头 `X-Filename`，最多 8GB → `{ ref: 'local:<name>', url }`（存本机，给浏览器上传用） |
| `PUT local/secrets`、 `GET local/secrets?names=A,B` | 写密钥；查哪些设了（不返回值） |
| `POST fetch` | 代发请求 `{ url, method, headers, body }`，响应最多 5MB，不能访问内网 |
| `GET /_annulo/img?url=` | 图片代理（不带 Referer），单张最多 20MB |

和 Annulo 外壳通信

页面发 `window.parent.postMessage(msg, location.origin)`：

- `{ type: 'annulo:open-chat', chat_id }`：在右侧打开一段对话；
- `{ type: 'annulo:navigate', view: 'settings' }`：打开 Annulo 的设置；
- `{ type: 'annulo:reload-backend' }`：整页刷新左侧页面。

外壳会发 `shuttle:refresh`（助手改了数据，静默重拉）和 `shuttle:theme { theme }`。

**\[在线\]** 页面不在 Annulo 里打开时（手机），改调站点函数： `cloud` 函数直接在 creght 上跑， `remote` 函数转到电脑上跑，见 [云端和远程函数](#cloud)。

页面和命令行

## 命令行 annulo

命令调的是本机正在跑的 Annulo，助手在 bash 里也用这些命令。

| 命令 | 说明 |
| --- | --- |
| `annulo run [<文件.函数>] [--input JSON|@file]` | 跑本机函数；不带参数列出所有函数 |
| `annulo logs [--fn name] [--id runId] [--limit N]` | 看运行日志（最多 100 条） |
| `annulo upload <文件…> [--json]` | 把本机文件传进当前项目，打印地址 |
| `annulo push [-m msg]` | 提交项目的 git； **\[在线\]** 再合并远端、推到 creght 预览、生成云端函数 |
| `annulo template` | 看当前项目的模板版本和更新说明 |
| `annulo template upgrade [--to N]` | 三方合并升级到最新版（或指定版本） |
| `annulo mcp list` | 已接的 MCP 和状态 |
| `annulo mcp add <name> <url>` | 接远程 MCP； `-H 'Authorization: Bearer ${KEY}'` 加请求头（ `${密钥名}` 会替换） |
| `annulo mcp add <name> -- <cmd> [args…]` | 接本机 MCP； `-e K=V` 加环境变量， `--force` 替换同名 |
| `annulo mcp remove <name>` | 删掉一个 MCP |

老命令 `shuttle` 也能用。

页面和命令行

## 助手的工具

Annulo 给助手加的工具很少，每个都是对话里高频、又容易写错参数的事。别的事助手用命令行（ `annulo run` 等）做。用 Claude Code / Codex 当助手时，通过 Annulo 的 MCP 拿到同一套工具。

| 工具 | 参数 | 说明 |
| --- | --- | --- |
| `db_query` | `{ table, where?, filter?, order_by?, limit?, fields?, cursor? }` | 只读查表，limit 最多 200 |
| `db_aggregate` | `{ table, where?, filter?, group_by?, metrics, order_by?, limit?, timezone? }` | 分组汇总，和 `ctx.db.aggregate` 同参数 |
| `page_errors` | `{ reload?, path?, wait_seconds? }` | 看左侧页面有没有报错，改完页面用它验证 |
| `request_user_input` | `{ title?, description?, questions: [{ id, type, label, options?, … }] }` | 弹一组问题（1–6 个，单选 / 多选 / 短文本 / 长文本）让用户回答，这一轮结束，回答作为下一条消息 |

接好的 MCP 工具名是 `mcp__<server>__<tool>`。

页面和命令行

## 能力版本历史

| 版本 | 加了什么 |
| --- | --- |
| 33 | 离线上传地址改成不带端口的 `/_annulo/uploaded/…`； `annulo upload` |
| 32 | `ctx.chat_id` |
| 31 | 任务 frontmatter 的 `thinking` |
| 30 | `ctx.mcp.servers()`、 `ctx.agent.current()`、服务商的 `kind` |
| 29 | `ctx.exec` |
| 28 | 插件 |
| 27 | `annulo.json` 的 `assistant` |
| 26 | Annulo 新名字（旧名字都还认） |
| 25 | 模型的 `web_search` 标记 |
| 24 | 流式 fetch body、TextEncoder / TextDecoder、arrayBuffer； `ctx.llm.providers` / `ctx.llm.fetch`；去掉 `ctx.fetch` |
| 23 | 多账号 `ctx.oauth`、 `ctx.oauth.accounts` |
| 22 | 手机上用助手；离线项目、 `workspace.offline` |
| 21 | `export const remote` |
| 20 | `export const cloud` |
| 19 | `ctx.db` 的 filter 写法、 `offset`、 `total` |
| 18 | 页面在本机渲染、react-router |
| 17 | `ctx.browser.profile` |
| 16 | 表、定时任务改成一个一个文件 |
| 15 | `workspace.machine` |
| 14 | `where.id`；运行日志、 `annulo logs` |
| 13 | `browser.open` 的 `keep_open` |
| 12 | `local/files` 和 `'local:<name>'` |
| 11 | 浏览器失败现场、 `b.snapshot` |
| 10 | `b.upload` 传网址时流式下载 |
| 9 | Google 连接加 GA4 |
| 8 | `prompts/` 和 `user/prompts/`； `local/ask` |
| 7 | 任务 `tasks/<id>.md`；定时任务可以跑任务 |
| 6 | `ctx.locale` |
| 5 | 页面能调 `local/upload`、 `local/secrets` |
| 4 | 定时任务可以是 prompt； `open-chat` 消息 |
| 3 | query 的 filter / order\_by / cursor； `ctx.db.aggregate` |
| 2 | `ctx.browser`、 `ctx.oauth`、定时任务 |
| 1 | 本机函数和 `ctx.db`、fetch、html、secrets、llm、mcp；业务表； `run` / `push`；模板升级 |

> 全站页面清单：[/llms.txt](/llms.txt)
