# API reference — Annulo

> Every platform primitive Annulo gives a project: local functions and ctx, tables, schedules, tasks, plugins, page endpoints and the CLI. Start here to write a template or plugin, or to see what the assistant can do.

API reference

Basics

[Overview](#overview)

Local functions

[Local functions](#functions) [ctx at a glance](#ctx) [Reading and writing tables](#db) [Network](#fetch) [Browser](#browser) [Shell, keys, MCP, models](#exec)

Cloud and phone

[Cloud and remote functions](#cloud)

Project files

[Tables](#tables) [Schedules](#schedules) [Tasks, prompts and notes](#tasks) [Manifest and user/](#manifest) [Plugins](#plugins)

Pages and CLI

[Page endpoints](#pages) [The annulo CLI](#cli) [Assistant tools](#agent) [Capability versions](#versions)

[Guide](/en/help.md)

# API reference

Every platform primitive Annulo gives a project: local functions and ctx, tables, schedules, tasks, plugins, page endpoints and the CLI. Start here to write a template or plugin, or to see what the assistant can do.

Capability version33 [Source](https://github.com/annulo/annulo)

Basics

## Overview

Annulo only provides **primitives**: general capabilities that let the assistant (or you) build business features in a project and keep them running. The business itself (how a platform posts, what a table stores) lives in the project; Annulo knows no specific platform.

A project is a folder, and primitives are used through these files and endpoints:

| Path | Primitives |
| --- | --- |
| `local/*.ts` | [Local functions](#functions) and [ctx](#ctx): tables, network, browser, shell, models, MCP, keys |
| `tables/<table>.json` | [Tables](#tables) |
| `schedules/<id>.json` | [Schedules](#schedules) |
| `tasks/`, `prompts/`, `skills/`, `ANNULO.md` | [Tasks and notes](#tasks) |
| `annulo.json`, `user/` | [Project manifest](#manifest) |
| `plugins/<id>/` | [Plugins](#plugins) |
| `pages/` | [Page endpoints](#pages) |

Capability version

The current capability version is **33**. Each new primitive bumps it ( [history](#versions)). If a template uses a primitive from a given version, set `"min_annulo_api": <version>` in `annulo.json`: older Annulo builds then ask the user to update before upgrading, and refuse the merge.

Old names

After the Shuttle → Annulo rename, the old names still work: `shuttle.json` / `SHUTTLE.md` / `/_shuttle/` / `X-Shuttle` / the `shuttle` command / `SHUTTLE_*` env vars.

Online and offline

Offline projects (default) keep data in local SQLite; online projects (creght connected) keep it on creght. `ctx.db` has the same names and parameters in both. Items marked **\[online\]** only work in online projects.

Local functions

## Local functions

Deterministic, repeatable logic goes into local functions. Page buttons, schedules and the assistant call them directly, without the model.

Writing one

- Put files in `local/<file>.ts`; names match `^[a-z0-9][a-z0-9_-]*$`. Files starting with `_` (like `local/_api.ts`) hold shared code, imported by relative path;
- Export `export function name(input, ctx)`, optionally async. It's called as `file.function`; in a plugin, `<plugin>/<file>.<function>`;
- `throw new Error(msg)`: the message is shown to the user as is; the stack goes to the log;
- Return something JSON-serializable.

```
// 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
}
```

Runtime

- Bundled by esbuild to CommonJS (ES2017) and run in an embedded JS engine. **No npm \`require\`**; `talizen` is type-only;
- Globals: `fetch`, `console`, `URL` (common fields and `searchParams.get/has`), `btoa` / `atob`, `TextEncoder` / `TextDecoder`;
- No `setTimeout`; use `await ctx.sleep(ms)`;
- A run lasts at most 60 minutes.

Calling

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

Progress goes to stderr, the result to stdout. From pages, see [Page endpoints](#pages). Logs: `annulo logs --fn leads.stats`.

Local functions

## ctx at a glance

The function's second argument. The "Cloud" column says whether it works in [cloud functions](#cloud).

| Member | Signature | What it does | Cloud |
| --- | --- | --- | --- |
| `ctx.db` | see [Reading and writing tables](#db) | query / aggregate / get / insert / update / delete | ✓ |
| `fetch` | `await fetch(url, { method, headers, body })` | Global fetch; no localhost or private networks. See [Network](#fetch) | ✓ |
| `ctx.fetchAll` | `await ctx.fetchAll([url | { url, method, headers, body }])` | Up to 4 in parallel, results in input order | ✗ |
| `ctx.html` | `ctx.html(text)` → `.find(css)` / `.text()` / `.markdown()` | Parse HTML | ✗ |
| `ctx.browser` | see [Browser](#browser) | Drive local Chrome with your signed-in sessions | ✗ |
| `ctx.exec` | `await ctx.exec(cmd, args, { cwd?, input?, timeout?, env? })` | Run command-line tools. See [Shell](#exec) | ✗ |
| `ctx.secrets.get` | `(name) → string | null` | Read Settings → Keys, or an env var | ✗ |
| `ctx.oauth` | `await ctx.oauth('google', { account? }) → access_token` | Accounts authorized in Settings → Connections; `ctx.oauth.accounts('google')` lists them | ✗ |
| `ctx.mcp` | `await ctx.mcp(server, tool, args?)` | Call a connected MCP tool; JSON results parsed. `ctx.mcp.servers()` shows status | `creght` only |
| `ctx.llm` | `await ctx.llm(prompt)` or `ctx.llm({ system, prompt }) → string` | One answer from the current model; no tools, not in any chat | ✗ |
| `ctx.llm.providers` | `() → [{ id, name, kind, models, … }]` | Providers in Settings → Models, without keys | ✗ |
| `ctx.llm.fetch` | `await ctx.llm.fetch(providerId, path, init) → Response` | Call a provider's API; Annulo fills in the base URL and auth (streamable) | ✗ |
| `ctx.agent.current` | `() → { id, name, provider, model, ready, error }` | The assistant's current model and whether it can run | ✗ |
| `ctx.progress` | `({ done, total, message })` | Report progress to the page and CLI | no-op |
| `ctx.log` | `(...args)` | Write to the run log | no-op |
| `ctx.sleep` | `await ctx.sleep(ms)` | Up to 60 s | no-op |
| `ctx.locale` | `'zh' | 'en'` | UI language | ✓ |
| `ctx.workspace` | object | `project_id`, `site_id`, `offline`, `machine: { id, name }`, `logs_dir`, `annulo_api`… | `undefined` |
| `ctx.chat_id` | string | The chat id when the assistant runs it in a chat; `''` for buttons and schedules | `''` |

Use `ctx.llm` only for short, unattended judgments (like scoring each new inquiry). Long writing a user asks for with a button belongs in a [task](#tasks).

Local functions

## Reading and writing tables

`ctx.db` reads and writes only [declared tables](#tables). Calls are synchronous.

| Method | Returns |
| --- | --- |
| `query(table, { where?, filter?, order_by?, limit?, offset?, cursor? })` | `{ total, list, limit, next_cursor }`; limit defaults to 20, 1–1000 |
| `aggregate(table, { where?, filter?, group_by?, metrics, order_by?, limit?, timezone? })` | `{ list, truncated }`; up to 10,000 groups |
| `get(table, id)` | a row or `null` |
| `insert(table, data)` | the new row (with `id`) |
| `update(table, id, data)` | `{ ok: true }`; shallow merge at top level, `null` removes a field |
| `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 })
```

Conditions

- **where**: field equality, `{ channel_id: 'x' }`. A scalar also matches an item in an array field; `where.id` reads by record key;
- **filter**: `[{ field, op, value }]`, or `{ match: 'and', conditions: [{ fieldId, operator, value }] }`; conditions are ANDed;
- **Operators**: `eq` (default), `neq`, `in`, `gt`, `gte`, `lt`, `lte`, `between` (value `[from, to]`, inclusive). Numbers compare numerically, strings lexically; mismatched types never match. Mistakes throw instead of silently not filtering;
- **order\_by**: `'date desc'`, comma-separated; newest first by default;
- **cursor**: for reading a whole table. Pass `''` first, then the previous `next_cursor`; empty means done. Can't be combined with order\_by or offset.

Aggregation

- **group\_by**: `['field', { field, trunc: 'day' | 'week' | 'month' | 'year', as }]`; weeks start on Monday;
- **metrics**: `[{ op: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'first' | 'last', field, as, order_by }]`; first / last follow the metric's order\_by within the group;
- Output columns default to `<op>_<field>`, and `count` for count; avg keeps 6 decimals;
- **timezone** defaults to `Asia/Shanghai`.

System fields: `id`, `created_at`, `updated_at`. The assistant's `db_query` / `db_aggregate` tools take the same parameters.

Local functions

## Network

fetch

Global `fetch(url, { method, headers, body })`. The Response has:

- `ok`, `status`, `statusText`, `url`, `headers.get(name)`, `headers.forEach(fn)`;
- `text()`, `json()`, `arrayBuffer()`;
- `body.getReader()` for streaming; `read()` returns `{ done, value: Uint8Array }`;
- `timing: { ttfb_ms, total_ms }`, `truncated`.

Limits: no localhost or private networks; bodies up to 10 MB (beyond that `text()` truncates and `truncated` is true); up to 2 minutes for headers, then the connection drops only after a full minute without data. No total time limit.

ctx.fetchAll

Send several requests at once, up to 4 in parallel, results in input order. A failed item is `{ ok: false, status: 0, url, error }` and doesn't fail the batch.

ctx.html

`ctx.html(text)` parses HTML: `.find(css)` returns `[{ tag, text, attrs }]`, `.text()` gives plain text, `.markdown()` converts to Markdown. Invalid selectors throw.

Local functions

## Browser

`ctx.browser` drives Chrome on this computer. Each profile is its own signed-in session, stored locally. Social publishing and collecting rely on it: same device, same network, just like doing it yourself.

```
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` needs `show: true`;
- `ctx.browser.profile(name) → { id } | null`: whether the profile exists.

| Method | What it does |
| --- | --- |
| `goto(url, { wait?, timeout? })` | Open a URL |
| `waitFor(sel, { visible?, timeout? })` | Wait for an element |
| `exists(sel)` | Whether an element exists |
| `click(sel | { text }, { timeout? })` | Click |
| `type(sel, text, { clear?, timeout? })` | Type |
| `press(key)` | Press a key |
| `upload(sel, files)` | Upload: URLs (streamed, up to 2 GB), `/_annulo/uploaded/…`, `'local:<name>'`, or a screenshot's `{ file }` |
| `eval(fn | string)` | Run in the page; return JSON-serializable values |
| `text(sel?)` / `html(sel?)` / `url()` | Read text, HTML, current URL |
| `listen(pattern)` / `responses(pattern, { min?, timeout? })` | Record and fetch matching API responses `[{ url, status, json, text }]` |
| `screenshot({ selector?, fullPage? }) → { file }` | Screenshot |
| `snapshot({ label }) → { dir }` | Save the current state on purpose |
| `setContent(html)` | Put HTML into the page |
| `close()` | Close |

Each action waits up to 30 s by default. When goto / waitFor / click / type / upload / responses fail, Annulo saves a snapshot (screenshot, actionable elements, HTML) and the error carries `err.snapshot`, which the assistant uses to fix the script.

Local functions

## Shell, keys, MCP, models

ctx.exec

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

- No shell: command and arguments are separate. For pipes or redirects use `ctx.exec('sh', ['-c', …])`;
- Commands resolve on the login shell's PATH; `cwd` is relative to the project root and can't leave it;
- `timeout` defaults to 30 s, max 10 minutes; stdout and stderr keep 10 MB each;
- A non-zero exit code doesn't reject; check `code`. It rejects only for a missing command, a timeout or a bad cwd.

ctx.secrets

`ctx.secrets.get(name)` reads a value from Settings → Keys (falling back to an env var of the same name), or `null`. Keys stay on this computer and the assistant can't read them.

ctx.oauth

`await ctx.oauth('google', { account? })` returns an access token for an account authorized in Settings → Connections (Search Console, GA4, read-only). With several accounts, pass `account`; `ctx.oauth.accounts('google')` lists them, oldest first.

ctx.mcp

`await ctx.mcp(server, tool, args?)` calls a tool from Settings → MCP; JSON text results are parsed, and tool errors reject. `ctx.mcp.servers()` returns `[{ name, status }]` with status `connected` / `needs_auth` / `connecting` / `failed` / `disabled`.

ctx.llm

- `await ctx.llm('prompt')` or `ctx.llm({ system, prompt })`: one answer from the current model, as a string;
- `ctx.llm.providers()`: providers `[{ id, name, kind: 'api' | 'cli', api, base_url, enabled, ready, models: [{ id, model, name, web_search }] }]`, without keys;
- `ctx.llm.fetch(providerId, path, init)`: call a provider's API directly; Annulo fills in the base URL and auth, and the body can be streamed. Only for `kind === 'api'` providers.

Cloud and phone

## Cloud and remote functions

**\[online\]** By default, local functions can only be called inside Annulo. Once a project is connected to creght, its back office also opens on a phone or another computer. Where do the functions run then? You declare it in the function file:

- `export const cloud = [...]`: **runs on creght**, even with your computer off. Tables only;
- `export const remote = [...]`: **runs on your computer**, which must be on. Everything local works (browser, keys, shell).

Example: checking Xiaohongshu stats on the go

You're on the subway and open the back office's Social page on your phone:

1. **The page shows likes over the last 30 days.** That's just reading and summing a table, which creght can do on its own, so it doesn't matter that your computer at home is off → `cloud`;
2. **The numbers look stale, so you tap "Collect now".** Collecting means opening the Xiaohongshu profile with your signed-in account, and only your computer has that → `remote`: creght relays the call to Annulo running at home, which opens the browser, reads the numbers and writes them to the table, streaming progress back to your phone;
3. **When nobody taps anything**, a schedule collects every 6 hours on the computer.

One file, two functions, each declaring where it runs:

```
// local/xhs.ts
export const remote = ['collect']   // needs the browser's signed-in session: only on your computer
export const cloud = ['summary']    // reads tables only: runs on creght, even with the computer off

/** Open the profile, read each note's likes, record today's numbers */
export async function collect(input: { channel_id: string }, ctx) {
  const ch = ctx.db.get('social_accounts', input.channel_id)
  ctx.progress({ message: `Opening "${ch.name}"…` })   // shown on the phone's button too

  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) => ({   // selectors follow the platform's page
      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 }
}

/** Total likes per day over the last N 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: when nobody taps, the computer collects every 6 hours
{ "name": "Collect Xiaohongshu", "fn": "xhs.collect", "every": "6h", "input": { "channel_id": "<account id>" } }
```

Then run `annulo push` once: `summary` is bundled into a site function on creght; `collect` needs no bundling, since Annulo reports it when it connects.

Where it runs

| Called by | `xhs.summary` (cloud) | `xhs.collect` (remote) |
| --- | --- | --- |
| A button inside Annulo | this computer | this computer |
| A button on the phone | creght, even with the computer off | relayed by creght → your computer (Annulo open) |
| A schedule | — | this computer |
| The assistant via `annulo run` | this computer | this computer |

Pages don't have to care: inside Annulo they call `local/run`; outside it, `cloud` functions are called as the site function `local/<file>`, and `remote` ones go through the site function `shuttle.call` to your computer. The template's `runLocal('xhs.summary', …)` wraps this, so the same line works in both places.

Rules for cloud

- Only project members can call them;
- Available: `ctx.db`, `ctx.locale`, `ctx.mcp('creght', …)` (read-only, owner only), `ctx.mcp.servers()`; `progress` / `log` / `sleep` are no-ops and `workspace` is `undefined`;
- Using `secrets`, `fetchAll`, `html`, `oauth`, `browser`, `llm`, `exec` or `agent` throws. That's why `collect` above can't be `cloud`.

Rules for remote

- Annulo must be open on the computer, with "Allow remote use of this computer" on in Settings → Remote access (off by default);
- Only the project owner can call them, and the computer only runs functions listed in `remote`;
- The function runs on the computer as usual, and `ctx.progress` updates stream back to the phone.

A function can't be in both `cloud` and `remote`.

Project files

## Tables

One file per table, `tables/<table>.json`; the file name is the table name ( `^[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"]
  }
}
```

- Declare it and it's readable and writable: offline projects need no setup; online projects create missing tables when the files change;
- Only declared tables can be used, by `ctx.db`, the page's `db/<table>`, and the assistant's tools alike;
- Plugin tables are prefixed: `plugins/<id>/tables/t.json` becomes `<id>_t`;
- System fields: `id`, `created_at`, `updated_at`;
- `json_schema` documents the table for people and the assistant; Annulo doesn't validate writes against it.

Project files

## Schedules

One file per schedule, `schedules/<id>.json`; the file name is the id.

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

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

| Field | Meaning |
| --- | --- |
| `fn` / `task` / `prompt` | Pick one: run a local function; run `tasks/<id>.md`; hand a prompt to the assistant in a new chat |
| `every` | `30m` / `6h` / `1d` / `7d`; at least 5 minutes |
| `at` | `HH:MM`, local time; wins over `every` when both are set |
| `input` | Arguments for the function or task |
| `name` | Display name |

- Runs only while Annulo is open; a missed run happens once the next time it opens;
- A task / prompt run lasts up to 30 minutes and only runs in the open project; other projects run `fn` schedules only;
- Plugin schedule ids are `<plugin>/<id>`, with fn / task written in full.

Project files

## Tasks, prompts and notes

Tasks `tasks/<id>.md`

Work for the assistant (articles, ideas, weekly reports). Frontmatter takes three single-line fields: `name`, `description`, `thinking` (off / minimal / low / medium / high / xhigh / max); the body is the instruction. Page buttons run it in a background chat via [local/tasks/<id>/run](#pages). Plugin task ids are `<plugin>/<task>`.

Prompts `prompts/<id>.md`

The part users care about ("how to write": structure, tone, length) lives in its own file: the template's default `prompts/<id>.md`, and the user's edits from the page in `user/prompts/<id>.md`, which wins when present. Rules for fetching data, saving and formats stay in the task file.

Skills `skills/<name>/SKILL.md`

Instructions for the assistant. Frontmatter has `name` (lowercase letters, digits, `-`, matching the folder) and `description`.

Project notes

- `ANNULO.md`: the project overview, added to the system prompt of every new chat, up to 8,000 characters;
- `INSTRUCTIONS.md`: the user's "Instructions for the assistant" from Settings → Projects; wins over the project notes on conflict.

Project files

## Manifest and 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": ["…"] }
  }
}
```

| Field | Meaning |
| --- | --- |
| `name` / `description` | Template name and description, a string or `{ zh, en }` |
| `min_annulo_api` | Required [capability version](#versions); older Annulo builds refuse to merge the upgrade |
| `plugins` | `{ "<id>": "<repo>#<dir>" }`, installed when a project is created |
| `assistant` | The assistant panel: `name`, `intro`, `suggestions` (each a string or `{ zh, en }`) |

To support Annulo builds below capability version 26, also ship a `shuttle.json`.

user/

The user's own customizations: `user/prompts/`, `user/annulo.json` (plugins the user installed or removed; `null` drops one the template brings), `user/plugins/<id>/…`. **Templates and plugins never write here**, so upgrades never conflict with them. To leave room for customization: ship a default, store the user's version under `user/`, and prefer it when present.

Template versions

A template is a directory with `annulo.json` in a git repo. Versions are semver tags ( `v1.2.0`; pre-releases aren't offered), and the tag message is the upgrade note. Upgrades are three-way merges in the project's git.

Project files

## Plugins

A plugin is a bundle of features any project can install, in `plugins/<id>/` (id matches `^[a-z][a-z0-9]{1,19}$`), upgraded separately from the template.

| File | Meaning |
| --- | --- |
| `plugin.json` | `{ name, description, min_annulo_api }`; name / description can be `{ zh, en }` |
| `PLUGIN.md` | Notes for the assistant |
| `local/`, `tables/`, `tasks/`, `prompts/`, `schedules/`, `skills/`, `components/` | Same as the project's folders |

- No pages; the template or the assistant builds those;
- Functions are `<id>/<file>.<fn>`, tables `<id>_<table>`, task and schedule ids `<id>/<name>`;
- User edits to prompts go to `user/plugins/<id>/prompts/<task>.md`;
- Publishing: a subdirectory with `plugin.json` in a repo, versioned by semver tags. Example: [annulo/plugins](https://github.com/annulo/plugins).

Pages and CLI

## Page endpoints

Each file under `pages/` is a route (react-router 7, client-side only), rendered by Annulo on your computer and refreshed on save. Pages use primitives through same-origin endpoints under `/_annulo/api/`, and **must send the header \`X-Annulo: 1\`**. Responses carry an `X-Annulo` header, which pages use to tell whether they're inside 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":…}}
```

| Endpoint | What it does |
| --- | --- |
| `GET local/functions` | `{ list: [{ name, file }] }` |
| `POST local/run` | `{ fn, input }`; returns SSE: `start`, `progress`, `log`, then `result { value, ms }` or `error { message }`. Keeps running if the page closes |
| `POST local/runs/<id>/abort` | Stop a run (id in the `x-annulo-run` header) |
| `GET local/logs?fn=&id=&limit=` | Run logs |
| `GET local/tasks`, `GET local/tasks/<id>` | Tasks: notes, prompt, running chats, last result |
| `PUT local/tasks/<id>` | `{ prompt }` edits the prompt; `{ reset_prompt: true }` restores the default |
| `POST local/tasks/<id>/run` | `{ input? }` → `{ chat_id }`; runs the task in a background chat; 409 if the same run is in progress |
| `POST local/ask` | `{ text, title? }` → `{ chat_id }`; hands a request to the assistant in a background chat |
| `GET agent/running` | Chats in progress; poll it to see when handed-off work is done |
| `GET local/schedules`, `POST local/schedules/<id>/run`, `…/toggle` | Schedules: list, run now, pause / resume |
| `GET db/<table>` | `{ list }`: the whole table, newest first |
| `POST db/<table>`, `PATCH db/<table>?id=`, `DELETE db/<table>?id=` | Create, merge-update, delete a row |
| `POST local/upload` | multipart field `file`, up to 200 MB → `{ url, … }` |
| `POST local/files` | Body is the file, header `X-Filename`, up to 8 GB → `{ ref: 'local:<name>', url }` (kept locally, for browser uploads) |
| `PUT local/secrets`, `GET local/secrets?names=A,B` | Write a key; check which are set (values never returned) |
| `POST fetch` | Proxy a request `{ url, method, headers, body }`; responses up to 5 MB; no private networks |
| `GET /_annulo/img?url=` | Image proxy (no Referer), up to 20 MB per image |

Talking to the Annulo shell

Pages send `window.parent.postMessage(msg, location.origin)`:

- `{ type: 'annulo:open-chat', chat_id }`: open a chat on the right;
- `{ type: 'annulo:navigate', view: 'settings' }`: open Annulo's settings;
- `{ type: 'annulo:reload-backend' }`: reload the back-office page.

The shell sends `shuttle:refresh` (the assistant changed data; reload quietly) and `shuttle:theme { theme }`.

**\[online\]** Outside Annulo (on a phone), pages call site functions instead: `cloud` functions run on creght, `remote` ones on your computer. See [Cloud and remote functions](#cloud).

Pages and CLI

## The annulo CLI

Commands talk to the Annulo running on this computer; the assistant uses them in its shell too.

| Command | What it does |
| --- | --- |
| `annulo run [<file.fn>] [--input JSON|@file]` | Run a local function; with no argument, list them |
| `annulo logs [--fn name] [--id runId] [--limit N]` | Run logs (up to 100) |
| `annulo upload <files…> [--json]` | Upload local files into the project and print their URLs |
| `annulo push [-m msg]` | Commit the project's git; **\[online\]** then merge remote changes, push to the creght preview and build cloud functions |
| `annulo template` | The project's template version and release notes |
| `annulo template upgrade [--to N]` | Three-way merge to the latest (or a given) version |
| `annulo mcp list` | Connected MCP servers and status |
| `annulo mcp add <name> <url>` | Add a remote MCP; `-H 'Authorization: Bearer ${KEY}'` adds a header ( `${key name}` is substituted) |
| `annulo mcp add <name> -- <cmd> [args…]` | Add a local MCP; `-e K=V` for env vars, `--force` to replace |
| `annulo mcp remove <name>` | Remove an MCP server |

The old `shuttle` command still works.

Pages and CLI

## Assistant tools

Annulo adds very few tools to the assistant: only things it does constantly where arguments are easy to get wrong. Everything else goes through the CLI ( `annulo run` and friends). With Claude Code / Codex as the assistant, the same tools come through Annulo's MCP.

| Tool | Parameters | What it does |
| --- | --- | --- |
| `db_query` | `{ table, where?, filter?, order_by?, limit?, fields?, cursor? }` | Read-only table query, limit up to 200 |
| `db_aggregate` | `{ table, where?, filter?, group_by?, metrics, order_by?, limit?, timezone? }` | Grouped aggregation, same as `ctx.db.aggregate` |
| `page_errors` | `{ reload?, path?, wait_seconds? }` | Check the back-office page for errors; used to verify page edits |
| `request_user_input` | `{ title?, description?, questions: [{ id, type, label, options?, … }] }` | Ask the user 1–6 questions (single / multi select, short / long text); the turn ends and the answers arrive as the next message |

Connected MCP tools are named `mcp__<server>__<tool>`.

Pages and CLI

## Capability versions

| Version | Added |
| --- | --- |
| 33 | Offline upload URLs without a port, `/_annulo/uploaded/…`; `annulo upload` |
| 32 | `ctx.chat_id` |
| 31 | `thinking` in task frontmatter |
| 30 | `ctx.mcp.servers()`, `ctx.agent.current()`, provider `kind` |
| 29 | `ctx.exec` |
| 28 | Plugins |
| 27 | `assistant` in `annulo.json` |
| 26 | Annulo names (old names still accepted) |
| 25 | `web_search` flag on models |
| 24 | Streaming fetch bodies, TextEncoder / TextDecoder, arrayBuffer; `ctx.llm.providers` / `ctx.llm.fetch`; `ctx.fetch` removed |
| 23 | Multi-account `ctx.oauth`, `ctx.oauth.accounts` |
| 22 | The assistant on a phone; offline projects, `workspace.offline` |
| 21 | `export const remote` |
| 20 | `export const cloud` |
| 19 | `ctx.db` filter syntax, `offset`, `total` |
| 18 | Pages rendered locally, react-router |
| 17 | `ctx.browser.profile` |
| 16 | One file per table and schedule |
| 15 | `workspace.machine` |
| 14 | `where.id`; run logs, `annulo logs` |
| 13 | `keep_open` for `browser.open` |
| 12 | `local/files` and `'local:<name>'` |
| 11 | Browser failure snapshots, `b.snapshot` |
| 10 | Streamed URL downloads in `b.upload` |
| 9 | GA4 in the Google connection |
| 8 | `prompts/` and `user/prompts/`; `local/ask` |
| 7 | Tasks `tasks/<id>.md`; schedules can run tasks |
| 6 | `ctx.locale` |
| 5 | `local/upload` and `local/secrets` for pages |
| 4 | Prompt schedules; the `open-chat` message |
| 3 | filter / order\_by / cursor in query; `ctx.db.aggregate` |
| 2 | `ctx.browser`, `ctx.oauth`, schedules |
| 1 | Local functions and `ctx.db`, fetch, html, secrets, llm, mcp; tables; `run` / `push`; template upgrades |

> Full page index: [/llms.txt](/llms.txt)
