# overblast

A unified SDK + CLI for the Overblast platform: workspaces, social posts/DMs,
**webchat, email, phone calls, conversation memory, todos, and webhooks**.

Works as a Node.js CLI **or** as an ESM library you import from your own code.

```
npm install overblast
```

---

## Quick start

### Use it as a Node.js library

```ts
import { Overblast } from 'overblast';

const ob = new Overblast({ apiKey: process.env.OVERBLAST_API_KEY! });

// One-call workspace inventory: every channel this key can speak on.
// Returns connected social accounts (Instagram, WhatsApp, …) +
// outbound email (workspace agent address + SMTP if configured) +
// phone (extension on the shared inbound, shared outbound pool,
// private lines).
const summary = await ob.accounts.summary();
//   summary.connectedAccounts     → social accounts with health
//   summary.email.agentAddress    → the advertised "<cid>@agent.0-0.chat"
//   summary.email.agentAddresses  → that + the look-alike domains (typo insurance)
//   summary.email.smtp            → custom SMTP if set + verified
//   summary.phone.extension       → 5-digit IVR ext on shared line
//   summary.phone.sharedInboundNumbers
//   summary.phone.privateLines    → workspace-owned numbers

// List all conversations across a workspace's social inbox
const { conversations } = await ob.conversations.list();

// Reply to a webchat conversation
await ob.webchat.reply(computerId, {
  conversationId: 'webchat_abc123',
  content: 'Hi! Yes, we are open until 6pm today.',
});

// Print a QR for "Table 18" — scanning opens a webchat with the
// position label bound to the conversation. Tasks created during
// that chat carry the position context (assets near the table,
// pricing items relevant to the spot).
const link = await ob.webchat.createLink({
  computerId,
  label: 'Table 18',
  // Optional: assetIds + pricingItemIds the agent should scope to
});
console.log(link.qrUrl); // PNG you can paste into print signage

// Time-bounded invite: signed URL the worker rejects past expiry.
// No DB column — the signature itself is the expiry mechanism, so
// expired links never reach the chat.
const invite = await ob.webchat.createLink({
  computerId,
  label: 'Delivery — order #4421',
  expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
});
console.log(invite.url);          // signed `?signed=<token>` URL
console.log(invite.unsignedUrl);  // never-expires `?link=<id>` URL

// Place a call
const { call } = await ob.calls.create({
  computerId,
  phoneE164: '+15551234567',
  fromPhoneE164: '+15557654321',
  goal: 'Confirm Tuesday\'s 3pm appointment.',
});

// Trigger a smart-staleness sync of upstream resources into Firestore.
// First call paginates fully; subsequent calls within 30s short-circuit
// as 'fresh'; concurrent callers wait on the in-flight refresh via a
// D1-backed lock. Used by the Flutter app on screen-open.
await ob.refresh.resource('comments');
await ob.refresh.resource('reviews');
await ob.refresh.resource('contacts');
await ob.refresh.resource('posts');

// Subscribe to incoming messages in real time
await ob.webhooks.create(computerId, {
  eventType: 'message.received',
  url: 'https://my-app.example.com/inbound',
});
```

### Use it as a CLI

```bash
export OVERBLAST_API_KEY=ob_live_...

# One-call channel inventory — what can this key speak through?
overblast accounts summary

# What is on sale and at what price, as published
overblast addons prices

overblast conv list
overblast webchat reply <computerId> webchat_abc123 "Yes, we're open until 6pm."
overblast email send <computerId> a@b.com "Re: your enquiry" "Thanks for…" --domain 0-0.chat
overblast call <computerId> +15551234567 "Confirm Tuesday's 3pm" --from +15557654321
overblast webhook create <computerId> --event message.received --url https://my-app.example.com/inbound

# Position QR / chat-link — for ordering or chat tied to a place
overblast webchat link create "Table 18"
overblast webchat link bulk-create "Table {n}" --start 1 --end 24 --group dining-room
overblast webchat link qr <linkId>                       # PNG image URL
overblast webchat link create "Delivery #4421" \
  --expires-at 2026-05-10T22:00:00Z                      # signed expiring URL

# Smart-staleness refresh (worker polls upstream → writes Firestore)
overblast refresh posts | comments | reviews | contacts

# A local folder (or file) to a live website, in one step; again later to update it
overblast deploy ./site --site acme
overblast deploy ./site
```

`overblast --help` prints the full command reference.

---

## Authentication

The SDK and CLI both authenticate with a workspace API key. Pass it three ways
(in priority order):

1. `--api-key ob_live_...` flag (CLI only)
2. `OVERBLAST_API_KEY` environment variable
3. `new Overblast({ apiKey: '...' })` constructor (library)

API keys are scoped to a single computer (workspace). Mint, scope, and revoke
keys in the **Overblast app** or the **developer console** — key management is
not available via API key. Inspect the key you're currently using with:

```bash
overblast api-keys me
```

The default base URL is `https://brain.deployd.network/v1`. (The old
`/social` prefix still works as a permanent alias, but prefer `/v1`.) Override
via `--base-url` (CLI) or `baseUrl` (library) when targeting staging/preview.

### Scope catalog

Each API key carries a list of scopes the worker checks on every
request. Scopes follow `category:action[:platform]`:

- `*` — full access
- `category:*` — every action in a category (e.g. `kb:*`)
- `category:action` — exact action (e.g. `post:create`)
- `category:action:platform` — platform-qualified (e.g. `post:create:instagram`)

The authoritative list is `SCOPE_CATEGORIES` in the worker
(`worker/src/social/scopes.ts`) — this table mirrors it.

| Category | Actions | Covers |
|---|---|---|
| `post` | create, read, delete | Social posts (publish/list/unpublish) |
| `dm` | send, read | Direct messages on social platforms |
| `comment` | read, reply | Inbox comments + private replies |
| `analytics` | read | Per-platform analytics |
| `contacts` | read, **manage** | CRM contacts, per-contact memory, and a conversation's saved addresses. `manage` is the write half. |
| `todo` | create, read, update, delete | Tasks pipeline (uses `skipAiExtraction` shortcut) |
| `task-template` | create, read, update, delete | Task templates |
| **`skill`** | publish, read | Publishing skills to the 00 skills store. `publish` submits (and resubmits) a skill tarball for review; `read` lists this workspace's submissions with their review notes. Approval is not a scope — it is ours. |
| `document-template` | create, read, update, delete | Document templates |
| **`document`** | create, read, delete | Generated documents (filled from a document template) |
| `asset` | create, read, update, delete | Workspace assets (rooms, equipment, vehicles) |
| `catalog` | create, read, update, delete | Pricing catalog (subset of business-data) |
| **`kb`** | create, read, update, delete | Knowledge-base files in R2 |
| **`business-data`** | read, update | Full business doc (profile + hours + payments + …) |
| **`branding`** | read, update | Brand profile (logo, colors, voice) |
| **`team`** | read, **escalate** | The team directory (`read`) and ringing it (`escalate`). Separate on purpose: looking up who is on the team should not imply being able to page them at 3am. |
| **`ai-search`** | query | AutoRAG semantic search across the workspace |
| `calls` | read, initiate | Call inbox + outbound dial |
| `email` | read, send | Email thread inbox + outbound send |
| **`webchat`** | read, manage | Public chat widget — settings, invite tokens, position links |
| **`auto-reply`** | read, manage | The auto-reply agent's own config: per-channel rules, system prompt, agent settings. What the agent *is* — distinct from `business-data`, which is what it *knows*. |
| `billing` | read, manage | Allowance + credit balance (`read`); checkout (`manage`) |
| `stats` | read | Workspace activity stats (daily counters) |
| `calendar` | read, subscribe | ICS subscription URLs for tasks, and free-slot lookups |
| `payment` | read, collect, refund | In-person Tap-to-Pay. `refund` is separate from `collect` on purpose — ringing up a sale should not imply reversing one. |

Pick the scopes a key carries when you mint it in the **Overblast app** or the
**developer console** — per-category checkboxes, per-platform optional.

---

## What you can do

The SDK exposes every conversational channel the platform serves, plus the
admin and bookkeeping primitives around them.

| Channel | Read messages | Send / reply | Real-time | Notes |
|---|---|---|---|---|
| **Social DMs** (Twitter, IG, FB, etc.) | `conversations.list` / `conversations.messages` | `conversations.sendDm` | `message.received` webhook | Per workspace |
| **Webchat** (website widget) | `webchat.info` (settings) | `webchat.reply` | `message.received` webhook | Conversation IDs are `webchat_<deviceId>` |
| **Email** (ticketed threads) | `email.threads` — one row per SUBJECT | `email.createThread` / `email.reply` | `message.received` webhook · `inbox.changed` (`kind: 'email'`) | Inbound goes to `{cid}@agent.0-0.chat` |
| **Phone calls** (Twilio + voice agent) | `calls.list` / `calls.conversation` / `calls.transcriptPdfUrl` / `calls.recordingUrl` | `calls.create` | `call.started` / `call.ended` webhooks | LiveKit-powered voice agent runs the call |
| **Comments / reviews** | `comments.inbox` / `comments.forPost` / `reviews.inbox` | `comments.reply` / `comments.privateReply` / `reviews.reply` | `comment.received` webhook | Per workspace |

Cross-cutting:

| Capability | Method |
|---|---|
| Workspace todo list (active by default) | `todos.list(cid)` |
| Conversation-scoped todos | `todos.list(cid, { conversationId })` |
| Archived/all todos | `todos.list(cid, { status: 'all' })` |
| Contact memory (markdown) | `contacts.memory(contactId)` |
| Bundled conversation context | `context.forConversation(...)` |
| Real-time event push | `webhooks.create / list / delete` |
| Share a file or folder for review, new versions, comments, links | `shareArtifact` / `updateArtifact` / `listComments` / `replyComment` / `pullArtifact` / `createLink` |

---

## Library reference

### Constructor

```ts
new Overblast({
  apiKey: string,        // required
  baseUrl?: string,      // default https://brain.deployd.network/v1
})
```

All methods return parsed JSON. Errors throw `OverblastError` with
`status`/`body` for inspection:

```ts
import { OverblastError } from 'overblast';

try {
  await ob.calls.create({ … });
} catch (e) {
  if (e instanceof OverblastError && e.status === 402) {
    console.warn('Out of credits:', e.body);
  } else throw e;
}
```

### `addons` — published prices, allowance, payment links

Prices, what a plan includes and which add-ons are on sale are the
platform's, not this SDK's: they come from the worker on every read, in
the currency the checkout will charge. Nothing in this package types a
price, so nothing here goes stale when the offer changes.

```ts
const offers = await ob.addons.offers();   // null from a worker that does not publish them yet
//   offers.purchasablePlans      → each interval: { plan, label, interval, priceCents?, currency?,
//                                   hours, creditsIncludedCents?, connectionsIncluded?, trialDays? }
//   offers.purchasableCloudHours → extra hours beside the plan
//   offers.liveAgentTiers        → always-on tiers
//   offers.connectionAddon       → { kind: 'connection', connections, priceCents?, currency?, interval? }
//   offers.seatAddon             → { kind: 'extra-seat', seats, priceCents?, currency?, interval? }
//   offers.creditPacks           → [ { pack, priceCents?, currency? } ]
//   offers.checkoutKinds         → the `kind`s `addons.checkout` sells today
```

`priceCents` is in the currency's minor unit. A price the worker could
not read is ABSENT (never zero): render "see checkout", never a guess.

What each workspace includes (`accountBase` connected accounts,
`seatBase` team seats) belongs to that workspace. Beyond it, the
workspace owner buys add-ons. Connecting a social account or inviting a
teammate past the allowance returns **HTTP 402** with
`{ slotType, used, allowance, extraNeeded, upgradeUrl }`; catch it with
`OverblastError` and route the user to the upgrade flow.

| Method | Endpoint | Notes |
|---|---|---|
| `addons.allowance()` | `GET /addons/allowance` | One-call snapshot: workspaces, slots, AI credit balance, and `offers` |
| `addons.offers()` | (the `offers` block of the allowance) | What is on sale and at what price |
| `addons.checkout({ kind, … })` | `POST /addons/checkout` | Mints a Stripe Checkout URL — **price + redirect URLs are server-side only** |
| `credits.balance()` | (alias of allowance) | Shortcut: returns `{ balanceCents, currency }` or null |
| `credits.topUp(pack)` | (alias of checkout) | Shortcut: `addons.checkout({ kind: 'credits', pack })` |

`addons.checkout` takes a `kind` from `offers.checkoutKinds` and the
knobs that kind uses: `quantity` (the recurring add-ons), `pack`
(credits), `plan` (a `purchasablePlans` / `purchasableCloudHours` slug)
and `computerId` (the workspace a connection or a plan is for). Only the
workspace owner can buy; anyone else is refused.

**Security model — DO NOT REGRESS:** the SDK signature deliberately
excludes `amountCents`, `successUrl`, and `cancelUrl`. Those come from
the worker only. A compromised SDK consumer cannot lower the price or
redirect to an attacker-owned site.

```ts
const a = await ob.addons.allowance();
//   a.totalAccountSlots = workspaceCount × accountBase + extraAccountSlots
//   a.usedAccountSlots  = currently-connected social accounts (all workspaces)
//   a.totalSeatSlots    = workspaceCount × seatBase + extraSeatSlots
//   a.usedSeatSlots     = team members across all workspaces
//   a.aiCredits         = { balanceCents, currency } | null
//   a.offers            = the published prices, as above

// Hand the checkout URL to the user (web/desktop). Never use on mobile
// for digital goods — the apps sell through the App Store / Google Play.
const checkout = await ob.addons.checkout({ kind: 'connection', quantity: 1, computerId });
console.log(checkout.url); // https://checkout.stripe.com/c/pay/...

// Top up AI credits with a pack (the price is the worker's)
const topUp = await ob.credits.topUp('medium');
console.log(topUp.url);
```

From the terminal: `overblast addons prices` prints the same offers
(`--json` for the raw answer), and `overblast addons allowance` prints
the allowance followed by them.

### `kb` / `aiSearch` — knowledge base + R2 AutoRAG semantic search

The workspace KB is folder-organised files in R2 (menus, FAQs,
policies, pricing PDFs). AutoRAG indexes everything and the auto-reply
agent uses it for retrieval. Files are also indexed alongside entity
mirrors — tasks, assets, contacts, templates — so `aiSearch.query`
returns matches across the whole workspace knowledge graph.

| Method | Endpoint | Scope |
|---|---|---|
| `kb.listFolders(cid)` | `GET /kb/computers/:cid/folders` | `kb:read` |
| `kb.createFolder(cid, { slug, name, visibility? })` | `POST /kb/computers/:cid/folders` | `kb:create` |
| `kb.updateFolder(cid, slug, patch)` | `PATCH /kb/computers/:cid/folders/:slug` | `kb:update` |
| `kb.deleteFolder(cid, slug)` | `DELETE /kb/computers/:cid/folders/:slug` | `kb:delete` |
| `kb.listFiles(cid, slug)` | `GET /kb/computers/:cid/folders/:slug/files` | `kb:read` |
| `kb.signUploadUrl(cid, { filename, contentType })` | `POST /kb/computers/:cid/staging/signed-url` | `kb:create` |
| `kb.moveFile(cid, { sourceKey, targetSlug })` | `POST /kb/computers/:cid/move` | `kb:update` |
| `kb.deleteFile(cid, key)` | `DELETE /kb/computers/:cid/files` | `kb:delete` |
| `kb.sync(cid)` | `POST /kb/computers/:cid/sync` | `kb:update` |
| `kb.signedGetUrl(cid, key)` | `GET /kb/computers/:cid/signed-get` | `kb:read` |
| `aiSearch.query(cid, { query, mode?, folderSlug?, maxResults? })` | `POST /kb/computers/:cid/search` | `ai-search:query` |

`mode: 'ai-search'` (default) returns an AI-summarised answer with
citations; `mode: 'search'` returns raw matching chunks (cheaper).

```ts
const r = await ob.aiSearch.query(cid, {
  query: 'What are the brunch hours on Sunday?',
});
console.log(r.answer);     // synthesised from menu + hours docs
console.log(r.citations);  // points to the source file chunks
```

### `businessData` / `assets` / `team` / `branding` — workspace knowledge

One R2 doc holds the workspace's structured data: profile, working
hours, pricing catalog, scheduling constraints, payments, branding.
Subset endpoints expose individual sections so an API key can hold a
narrow scope (branding-only, catalog-only).

| Method | Endpoint | Scope |
|---|---|---|
| `businessData.get(cid)` | `GET /business-knowledge/computers/:cid` | `business-data:read` |
| `businessData.update(cid, body)` | `PUT /business-knowledge/computers/:cid` | `business-data:update` |
| `businessData.getProfile(cid)` | `GET /business-knowledge/computers/:cid/profile` | `business-data:read` |
| `businessData.getHours(cid)` | `GET /business-knowledge/computers/:cid/hours` | `business-data:read` |
| `businessData.getBranding(cid)` | `GET /business-knowledge/computers/:cid/branding` | `branding:read` |
| `businessData.updateBranding(cid, branding)` | `PUT /business-knowledge/computers/:cid/branding` | `branding:update` |
| `businessData.getCatalog(cid)` → `{ catalog, version }` | `GET /business-knowledge/computers/:cid/catalog` | `catalog:read` |
| `businessData.updateCatalog(cid, catalog, { ifVersion? })` | `PUT /business-knowledge/computers/:cid/catalog` | `catalog:update` |

Pass the `version` you read as `ifVersion` and a catalogue someone changed
since is refused rather than overwritten: the SDK throws a
`CatalogVersionConflict` (an `OverblastError`, status 409, `code:
'version_conflict'`) carrying `catalog` and `version` as they are now.
Re-apply your change to that and write again. On the CLI:
`overblast business-data set-catalog --body '<json>' --if-version <v>`.
Every item must be priced in the catalogue's `defaultCurrency`; an item in
another currency is a 400 naming it.
| `assets.list(cid)` | `GET /business-knowledge/computers/:cid/assets` | `asset:read` |
| `team.list(cid)` | `GET /team/computers/:cid` | `team:read` |
| `team.escalate(cid, { message, … })` | `POST /team/escalate/computers/:cid` | `team:escalate` |

`businessData.update` is a **full replace**, not a patch: the body is the
new document, so read first and send the whole thing back. It is
validated as a whole through the Zod-backed `writeBusinessData` helper —
a bad shape returns 400 with the exact `issues` and nothing is written.
One exception protects clients whose model lags the schema: a short list
of fields (`arrivalKinds`, `scheduling`, `reports`, `taskSettings`,
`payments`, `profile.iceBreakers`, the branding's `iconUrl`, `appearance`
and `updatedAt`, and a few catalog-item and payments fields) keep their
stored value when the body leaves them out. To clear one, send `null` or
its empty value (`[]` for a list).

#### `team` — the directory, and ringing it

`team.list(cid)` returns `{ members, groups }`, where each group carries
its own `memberIds`. That inversion is the point: members reference a
group, groups carry no roster, so nothing downstream can compute
membership on its own. It is what lets you offer "the Bookings group (2
people)" instead of quoting a uuid — and what makes `escalate`'s
`groupId` answerable at all.

```ts
const { members, groups } = await ob.team.list(cid);

await ob.team.escalate(cid, {
  message: 'Customer is asking for a discount I can’t authorise on order #4421.',
  urgency: 'high',
  groupId: groups.find((g) => g.name === 'Bookings')?.id,   // omit → broadcast
  conversationId,
});
```

Same team, same event, same push as an escalation raised inside the app —
so it doesn't matter whether the agent runs in the worker or on the
operator's own machine. `groupId: 'all'` is accepted and means broadcast;
it is deliberately absent from `groups`, because it is not a team.

### `taskTemplates` — enumerate, then fill

A workspace's task templates are the structured intake forms behind
`todos.create({ templateId, templateData })`. Listing them returns two
derived views alongside each definition:

| Method | Endpoint | Scope |
|---|---|---|
| `taskTemplates.list(cid)` | `GET /task-templates/computers/:cid/task-templates` | `task-template:read` |
| `taskTemplates.get(cid, templateId)` | `GET /task-templates/computers/:cid/task-templates/:id` | `task-template:read` |

- **`fields`** — the flat leaf listing, with sections and repeatables
  already walked, so each entry's `path` is exactly what `templateData`
  must be keyed by. Each entry carries an `audience`: `"party"` (the
  default) is the customer's own question, `"internal"` is the
  workspace's — never shown on a customer-facing surface, never accepted
  from one, and never held against their submission. An operator-side
  caller fills both.
- **`jsonSchema`** — the same thing as a JSON Schema, if you'd rather
  validate than iterate.

### Writing one — `GET /docs/task-templates`

Creating or patching a template is `POST` / `PATCH` on the same paths
(`task-template:create` / `:update`), and the reference for what goes in
the body is **generated from the schema** and served, unauthenticated, at
`GET /docs/task-templates`. It carries the parts an editor UI would have
shown a human author:

- every field type with the exact shape an answer must take, and every
  older spelling still accepted for it;
- `audience`, and how a section's word is inherited by its fields;
- what the single `min`/`max` pair counts, per type — characters, value,
  entries, files, or nothing at all;
- the two save-time refusals (`invalid_field_type`,
  `invalid_field_config`) and what earns each;
- every output trigger, and **whether it actually fires** —
  `on_approval` is authorable and dormant, because the platform has no
  task-approval state to fire it on;
- and, for a process with MORE THAN ONE PARTY, the whole of it: playbook
  `roles` (each party's form, value namespace and invitation), the four
  on-enter effects with their configs, the `invite`/door pattern that gets a
  second party into the room, role namespaces and `variableMap` for the
  document at the end, and the `on_legs` trigger that waits for every side;
- and, for a document somebody has to SIGN: the three signature modes with
  the plain-language legal tier of each, the workspace/output policy whose
  effective set is their intersection, the consent act every capture is
  refused without, the external sign-and-return road and the one-way
  artifact-chain flip it causes, and the audit certificate a completed round
  produces;
- **plus a worked example** — one buyer-and-seller process written out end
  to end as a task template, a playbook and a document template, annotated
  with why each key is there. It is the fastest way to see how the pieces
  compose, and a test asserts it only names vocabulary that exists.

Use `list()` whenever you intend to fill a template in. `get()` returns
the stored definition only, and reconstructing the path convention from
it by hand gets it wrong.

```ts
const { templates } = await ob.taskTemplates.list(cid);
const booking = templates.find((t) => t.name === 'Booking');

await ob.todos.create(cid, {
  title: 'Booking — Maria, Thursday 09:00',
  templateId: booking.id,
  templateData: Object.fromEntries(
    booking.fields.map((f) => [f.path, answerFor(f)]),
  ),
});
```

**Date ranges.** A `date_range` answer is `{ start, end }`, the end not
before the start. A range picked with times of day (the stored field's
`withTime: true`, the app's "Date & time range"; `datetime_range` is
accepted at save as its alias; the field's `jsonSchema` description says
"with times of day") needs a time on both ends, like
`{ start: '2026-10-01T10:00', end: '2026-10-01T12:30' }`. A date alone
is refused for it.

**Advanced questions.** A field listed with `advanced: true` is one most
tasks do not need (the forms tuck it under "More options"). Skip it unless
the answer comes up; it is never a required one.

### `scheduling` / `savedLocations` — before you promise a time or an address

| Method | Endpoint | Scope |
|---|---|---|
| `scheduling.availableSlots(cid, { searchFrom, searchUntil?, durationMinutes?, memberId?, groupId?, assetId?, assetUnits?, maxResults? })` | `GET /scheduling/computers/:cid/available-slots` | `calendar:read` |
| `savedLocations.list(cid, conversationId)` | `GET /saved-locations/computers/:cid` | `contacts:read` |
| `savedLocations.save(cid, { conversationId, name?, address?, lat?, lng? })` | `POST /saved-locations/computers/:cid` | `contacts:manage` |

Ask for slots before proposing one. `todos.create` rejects a colliding
booking with `409` — but by then you have already said the time out loud
to a customer.

```ts
const { slots } = await ob.scheduling.availableSlots(cid, {
  searchFrom: new Date().toISOString(),
  durationMinutes: 90,
  groupId: bookingsGroupId,
});
// → [{ start, end, durationMinutes }, …]
```

`savedLocations` is the same idea for addresses: check what the
conversation already knows before asking the customer to repeat "my
place". `save` needs either an `address` or both `lat` and `lng`, and
returns a `locationId` usable immediately on the next task — no
round-trip through `list()` first.

### `accounts` — connected social accounts + workspace channel inventory

| Method | Endpoint |
|---|---|
| `accounts.list()` | the workspace's connected social accounts |
| `accounts.summary({ fresh? })` | `GET /accounts/summary` — the channel inventory below |
| `accounts.connect({ platform, platformUsername?, upstreamProfileId? })` | connect a social account to the key's workspace |
| `accounts.disconnect(accountId)` | disconnect one |

`accounts.summary()` is the one call that answers "what can this key
speak through?" — useful for AI-agent system prompts and admin UIs:

```ts
const s = await ob.accounts.summary();
//   s.connectedAccounts          → [ { id, platform, platform_username, … } ]
//   s.email.agentAddress         → "<cid>@agent.0-0.chat" (the one to advertise)
//   s.email.smtp                 → { fromEmail, fromName, verified, providerHint } | null
//   s.phone.extension            → "12345" — IVR ext on the shared inbound, or null
//   s.phone.sharedInboundNumbers → [ "+1…", "+44…" ] — agent's caller-id pool
//   s.phone.privateLines         → [ { phoneE164, countryIso, capabilities, status, … } ]
```

### `posts` / `comments` / `reviews` / `refresh` — local-first social reads

The Flutter app reads these from Firestore (real-time stream); SDK
consumers can either subscribe to Firestore directly OR use these HTTP
methods for one-shot reads. The `refresh` module triggers an upstream
poll → Firestore write so the stream catches up.

Everything here is served by the **workspace proxy** under `/w`. The
workspace comes from the API key's own binding, so no workspace id
appears in the path — or in the method signatures.

| Method | Endpoint |
|---|---|
| `posts.create(opts)` | `POST /w/posts` (queued for moderation if key is `manual`) |
| `posts.list({ limit?, page?, sortBy?, status? })` | `GET /w/posts` |
| `posts.get(postId)` | `GET /w/posts/:id` |
| `posts.delete(postId)` | `DELETE /w/posts/:id` |
| `posts.unpublish(postId, body?)` | `POST /w/posts/:id/unpublish` |
| `posts.bulkUpload(body)` | `POST /w/posts/bulk-upload` |
| `posts.retry(postId, body?)` | `POST /w/posts/:id/retry` |
| `posts.updateMetadata(postId, body)` | `POST /w/posts/:id/update-metadata` |
| `comments.inbox({ limit?, page? })` | `GET /w/inbox/comments` (posts grouped by comment counts) |
| `comments.forPost(postId, { accountId?, limit?, page? })` | `GET /w/inbox/comments/:postId` (threaded) |
| `comments.reply(postId, { accountId?, message, parentCommentId? })` | `POST /w/inbox/comments/:postId` |
| `comments.privateReply(postId, commentId, { accountId?, message })` | `POST /w/inbox/comments/:postId/:cid/private-reply` |
| `comments.delete(postId, { accountId?, commentId })` | `DELETE /w/inbox/comments/:postId` |
| `reviews.inbox({ limit?, page? })` | `GET /w/inbox/reviews` |
| `reviews.reply(reviewId, { accountId, message })` | `POST /w/inbox/reviews/:rid/reply` |
| `reviews.deleteReply(reviewId, { accountId })` | `DELETE /w/inbox/reviews/:rid/reply` (Google Business only) |
| `mentions.list({ limit?, page? })` | `GET /w/inbox/mentions` |
| `refresh.resource('posts'\|'comments'\|'reviews'\|'contacts')` | `POST /refresh/…/:resource` (workspace from the key) |

`refresh.resource` returns one of: `'fresh'` (recent refresh covered
this call), `'in_flight'` (another caller mid-refresh — wait for the
stream), `'refreshed'` (this caller did the work — `count` is the items
synced, `mode` is `'full'` on the first sync or `'incremental'` after).

### `webchat.link` — position QR codes / invite links

Workspace-defined position labels (Table 18, Room 5, Order #4421) get
their own chat URL + QR code. When a visitor scans, the conversation
carries the position context — auto-reply agents reason about it,
tasks created during the chat inherit it.

| Method | Endpoint |
|---|---|
| `webchat.createLink({ computerId, label, expiresAt?, sequenceGroup?, assetIds?, pricingItemIds? })` | `POST /webchat-links` |
| `webchat.bulkCreateLinks({ computerId, labelTemplate, sequence, sequenceGroup?, assetIds?, pricingItemIds? })` | `POST /webchat-links/bulk` |
| `webchat.listLinks(computerId, { sequenceGroup? })` | `GET /webchat-links` |
| `webchat.deleteLink(linkId)` | `DELETE /webchat-links/:id` |
| `webchat.linkUrl(computerId, linkId)` | pure helper — never-expires `?link=…` URL |
| `webchat.qrCodeUrl(chatUrl, size?)` | pure helper — PNG QR image URL |

Two URL shapes come back from `createLink`:
- `unsignedUrl` — `…?link=<id>`. Never expires; print on permanent signage.
- `url` — when `expiresAt` is supplied, this is the **signed** form
  `…?signed=<token>`. The token carries `{ id, cid, exp }` HMAC-signed
  with `WEBCHAT_LINK_SECRET`; the worker rejects scans past expiry
  without a DB lookup. Without `expiresAt`, `url` equals `unsignedUrl`.

`qrUrl` / `unsignedQrUrl` are PNG image URLs you can put straight into
`<img src=…>` or print signage.

### `conversations` — social inbox (DMs across platforms)

| Method | Endpoint |
|---|---|
No workspace id in these signatures either — the key names its own
workspace.

| Method | Endpoint |
|---|---|
| `conversations.unread({ computerId?, limit? })` | `GET /conversations/unread` |
| `conversations.markRead(conversationId, { computerId? })` | `POST /conversations/:id/read` |
| `conversations.list({ limit?, page?, archived? })` | `GET /conversations` |
| `conversations.get(conversationId)` | `GET /conversations/:id` |
| `conversations.messages(conversationId, { limit?, before? })` | `GET /conversations/:id/messages` |
| `conversations.send(conversationId, { text, … })` | `POST /conversations/:id/messages` |
| `conversations.sendDm(conversationId, { text, attachmentUrl?, attachmentType?, tmpKey?, dryRun? })` | `POST /w/dm/:id` |
| `conversations.sendReadReceipt(conversationId)` | `POST /w/dm/:id/read` |
| `conversations.sendTypingIndicator(conversationId)` | `POST /w/dm/:id/typing` |
| `conversations.start(body)` | `POST /w/conversations` |
| `conversations.update(conversationId, body)` | `PUT  /w/conversations/:id` |
| `conversations.deleteMessage(conversationId, messageId)` | `DELETE /w/dm/:id/messages/:mid` |
| `conversations.editMessage(conversationId, messageId, { text })` | `PATCH  /w/dm/:id/messages/:mid` |

One attachment per message: `attachmentUrl` + `attachmentType`, not an
`attachments[]` array. `dryRun: true` exercises auth and scope checks
and answers `{ dryRun: true, ok: true }` without reaching the platform.

`conversations.unread` is cross-channel: it also returns webchat, email, and
phone threads — each row's `channel` field says which surface to fetch and
reply on. Replying
resets the unread counter; `markRead` skips a thread without replying (useful
for agents polling the inbox when auto-reply is off).

`GET /conversations/listen` is the realtime alternative — an outbound
WebSocket for agents with no public URL (see the recipe in `SKILL.md`).
`GET /conversations/listen/status` answers `{ listening, configured }`:
`listening` tells you whether a socket is currently attached (one per
workspace — a new connection evicts the old one), and `configured: false`
means this deployment has no listener at all, which is a different
problem from an idle one.

### `webchat` — live website chat

| Method | Endpoint |
|---|---|
| `webchat.info(computerId)` | `GET /webchat/:cid/info` |
| `webchat.reply(computerId, { conversationId, content?, attachmentUrl?, attachmentType?, tmpKey? })` | `POST /webchat/:cid/reply` |
| `webchat.getSettings(computerId)` / `webchat.updateSettings(cid, settings)` | `GET / PUT /webchat/settings/:cid` |
| `webchat.createInviteToken(computerId)` | `POST /webchat/settings/:cid/token` |

`conversationId` is always `webchat_<deviceId>`.

### `website` — the workspace's website chat (contract §13)

The chat a workspace embeds on its own sites. A console-minted `ob_live_` key
cannot use `/api/computers/*`, so these are the `/v1/w/…` twins: the workspace
is the key's binding (`computerId` is only for a session; a key naming another
workspace is `403`). The menu and task templates are the key's workspace's own
path-scoped doors. Every refusal is an `OverblastError` whose `message` is the
worker's sentence and whose `body` keeps `code` and, on a `402`, `fixUrl`.

| Method | Endpoint | Scope |
|---|---|---|
| `website.links()` | `GET /w/website-apps` | `webchat:read` |
| `website.getOrCreate()` | `PUT /w/website-chat` (the snippet; makes the chat, no address, no plan) | `webchat:manage` |
| `website.app(appId)` | `GET /w/website-apps/:appId` | `webchat:read` |
| `website.origins(appId)` | `GET /w/website-apps/:appId/origins` | `webchat:read` |
| `website.setOrigin(appId, origin, 'allowed' \| 'blocked')` | `POST /w/website-apps/:appId/origins { origin, status }` (allowing needs the plan) | `webchat:manage` |
| `website.removeOrigin(appId, origin)` | `DELETE /w/website-apps/:appId/origins?origin=` | `webchat:manage` |
| `website.stats({ days?, appId? })` | `GET /w/site-stats` (needs the plan) | `webchat:read` |
| `website.visits({ appId?, from?, to?, limit? })` / `website.visit(sid)` | `GET /w/site-sessions[/:sid]` (needs the plan) | `webchat:read` |
| `website.inbox(appId, { kind?, since? })` | `GET /w/website-apps/:appId/inbox` | `webchat:read` |
| `website.reply(appId, mid, text)` | `POST /w/website-apps/:appId/inbox/:mid/reply { reply }` (one per item) | `webchat:manage` |
| `website.handled(appId, mid, handled)` | `POST /w/website-apps/:appId/inbox/:mid/handled { handled }` | `webchat:manage` |
| `website.hours()` | `GET /w/business-hours` → `{ hours, version }` | `business-data:read` |
| `website.setHours(hours, ifVersion)` | `PUT /w/business-hours { hours, ifVersion }` (`409 version_conflict` carries the current `hours` and `version`) | `business-data:update` |
| `website.menu(cid)` | `GET /auto-reply/computers/:cid/catalog` → `{ items, defaultCurrency }` | `catalog:read` |
| `website.menuAdd(cid, item)` / `menuEdit(cid, id, patch)` / `menuRemove(cid, id)` | `POST` / `PATCH` / `DELETE /auto-reply/computers/:cid/catalog/items[/:id]` | `catalog:create\|update\|delete` |
| `website.takeover(cid, conversationId, takenOver)` | `PUT /auto-reply/computers/:cid/conversations/:id/auto-reply-off { off }` | `auto-reply:manage` |
| `website.payments(cid, { since?, limit?, … })` / `paymentTotals(cid, { groupBy?, since? })` | `GET /payments/computers/:cid/customer-payments[/totals]` (read only) | `payment:collect` |
| `taskTemplates.update(cid, id, { visibility }, ifVersion?)` | `PATCH /task-templates/computers/:cid/task-templates/:id` | `task-template:update` |
| `taskTemplates.delete(cid, id, ifVersion?)` | `DELETE /task-templates/computers/:cid/task-templates/:id?ifVersion=` | `task-template:delete` |

### `email` — ticketed email threads

| Method | Endpoint |
|---|---|
| `email.threads({ computerId?, limit?, since? })` | `GET  /email/threads` (each row carries `authStatus`) |
| `email.messages(threadId, { computerId?, limit? })` | `GET  /email/threads/:id/messages` |
| `email.createThread({ computerId?, toAddr, toName?, receivedOnDomain?, subject, body, attachments?, tmpKey? })` | `POST /email/threads` |
| `email.reply(threadId, { body, attachments?, tmpKey? })` | `POST /email/threads/:id/reply` |
| `email.uploadAttachment({ data, filename, contentType? }, computerId?)` | `POST /email/attachments/signed-url` (+ direct PUT) |
| `email.quota(computerId?)` | `GET /email/quota` |

**One thread is one subject.** `conversations` keys email per SENDER, so everything one person ever
wrote about is a single row there with no subject and no thread id. `email.threads()` is the
per-thread view: `threadId`, `subject`, `ticket`, `messageCount`, the last message and
`lastMessageIsOutgoing` (skip those — the last word is already yours). `since` returns only what
moved, so a poller can ask for the delta instead of the inbox.

**`authStatus` says whether the sender is proved.** It is the server's verdict on the thread's
latest INBOUND mail: `'pass'` when an aligned DKIM signature verified, so the From is who it says;
`'fail'`, `'none'` (unsigned: a forged From reads exactly like this), `'temperror'`, `'permerror'`,
`'neutral'`, `'softfail'` are all unverified; `null` when the thread has no inbound mail yet (you
started it). The cloud's own auto-reply only answers `pass`; an agent reading this feed must apply
the same gate: anything but `pass` is shown to a person, never acted on automatically. A server
older than the field leaves it out. `overblast email threads` prints it on every row.

The `ticket` (`[#000019]` in the subject) is what re-attaches a reply whose subject the recipient
edited. Never strip it from an outgoing subject.

Unread email rows also carry `threadId`, `subject`, `ticket` and a `threads[]` array now, for
clients that must drive every channel off one feed — but the row is still per sender.

`receivedOnDomain` is optional — omit it and the workspace's primary agent
domain is used. Agent mail lives on `agent.` subdomains, so the From address is
`{computerId}@agent.0-0.chat`; the bare apex domains (`0-0.chat` and its
look-alikes) remain accepted inbound for threads created before that move, but
are never advertised.

**Attachments are R2 keys, not URLs** — the send route reads the bytes from the
platform's bucket, so a public link is silently ignored here (that's the DM
path). Upload first:

```ts
const att = await ob.email.uploadAttachment({
  data: await readFile('./spec.pdf'),
  filename: 'spec.pdf',
  contentType: 'application/pdf',
});
await ob.email.reply(threadId, { body: 'Spec attached.', attachments: [att] });
```

**The daily allowance.** `email.quota()` → `{ used, limit, remaining }` — 25 per workspace per UTC
day on the shared agent address. A workspace with its own **verified** SMTP sender is not capped. At
zero the send route answers 429; check before composing rather than discovering it after, and hold the
message for the reset instead of retrying in a loop.

`uploadAttachment` PUTs straight to storage with a one-off signed URL (the bytes
never pass through the API, so a large file isn't bounded by request limits), and
falls back to a proxied upload where storage credentials aren't configured. Max
15 MB per file. A key stays valid, so one upload can be attached to many sends.
An object already in the knowledge base can be referenced directly instead:
```ts
{ key: 'kb://my-folder/spec.pdf', filename: 'spec.pdf', contentType: 'application/pdf' }
```

Inbound mail is delivered by Cloudflare Email Routing to the agent address and
fired as a `message.received` webhook event — there is no polling read API.

### `calls` — phone calls + voice agent

| Method | Endpoint |
|---|---|
| `calls.create({ computerId?, phoneE164, fromPhoneE164, goal, scheduleAt?, contactName?, taskInstructions?, taskTemplateIds?, voice?, language?, skillId? })` | `POST /calls/` |
| `calls.list({ computerId?, status?, direction?, limit?, offset? })` | `GET /calls/` |
| `calls.listConversations({ computerId?, limit? })` | `GET /calls/conversations` |
| `calls.conversation(conversationId)` | `GET /calls/conversations/:id` |
| `calls.get(callId)` | `GET /calls/:id` |
| `calls.recordingUrl(callId)` | `GET /calls/:id/recording-url` |
| `calls.transcriptPdfUrl(callId)` | `GET /calls/:id/transcript-pdf` |
| `calls.reschedule(callId, scheduleAt)` | `PATCH /calls/:id/schedule` |
| `calls.setGoal(callId, goal)` | `PATCH /calls/:id/goal` |
| `calls.cancel(callId)` | `POST /calls/:id/cancel` |
| `calls.sharedNumbers()` | `GET /calls/shared-numbers` |

Notes:
- `fromPhoneE164` must be one of the workspace's `shared-numbers`.
- The platform refuses a call unless the workspace has 100 credits FREE — its
  balance minus what calls already running have reserved. Catch
  `OverblastError` with `status === 402` to surface this; the body carries
  `balanceCents`, `reservedCents` and `availableCents` so you can say which
  of the two ran out. A `503` with `error: 'credits_unavailable'` means the
  balance could not be read and the call was refused rather than risked —
  retry it.
- `goal` is the system prompt the agent runs with. `taskInstructions` adds
  free-form "how to do it" guidance separate from the goal.

### `todos` — workspace + per-conversation tasks

| Method | Endpoint |
|---|---|
| `todos.list(computerId, opts)` | `GET /todos` |
| `todos.get(todoId, { computerId? })` | `GET /todos/:id` |
| `todos.create(computerId, opts)` | `POST /todos` → 201 `{ todo, extraction }` |
| `todos.update(computerId, todoId, patch)` | `PATCH /todos/:id` |
| `todos.requestEdit(todoId, { action, reason, conversationId, contactId?, changes? })` | `POST /todos/:id/request-edit` |
| `todos.delete(computerId, todoId)` | `DELETE /todos/:id` |
| `todos.recordPayment(computerId, todoId, { deltaAmount, currency, note?, source? })` | `POST /todos/:id/payments` |
| `todos.estimate(computerId, todoId, { trackedDistanceMeters?, trackedDurationSeconds? })` | `POST /todos/:id/pricing/estimate` → `{ pricing, exact }` (read-only) |
| `todos.refreshPricing(computerId, todoId, { taskUpdatedAtIso?, taskPricing, … })` | `POST /todos/:id/refresh-pricing` → `{ refreshed, reason? }` |
| `todos.quote(computerId, todoId, { lines? \| total?, note?, expiresAt? })` | `PATCH /todos/:id/quote` → `{ ok, taskPricing }` |
| `todos.attachFiles(computerId, todoId, { files })` | `POST /todos/:id/files/attach` |
| `todos.removeFiles(computerId, todoId, { fileIds, kbUrls? })` | `POST /todos/:id/files/remove` |

#### A task's price: one number, with its tax

Every total is the worker's: each line priced once, tax per line (the
item's rate, else the price list's), `total = subtotal + taxAmount`. Do
not add up lines yourself; read the card, or ask for an estimate.

- **`todos.estimate`** prices the task's own lines against today's
  catalogue with the trip counters you give (one left out is the task's
  own), keeping quoted lines. Nothing is stored. `exact` is false while a
  line waits for a quote. An older worker answers `404` for the route:
  label your own figure as an estimate then.
- **`todos.refreshPricing`** re-prices from the catalogue only when the
  catalogue changed after `taskUpdatedAtIso`, and never a price somebody
  has been told: `{ refreshed: false, reason }` with `price-locked` (a
  payment link went out; `priceLockedAt` on the card), `has-payment`,
  `has-deposit` or `has-quote`. Send each line's `selectedModifiers`.
  A deliberate `todos.update` still re-prices.
- **`todos.quote`** prices the lines waiting for a quote: `lines` (a unit
  price each) or a stated `total`, which the quoted lines carry as
  tax-inclusive prices (with no line to carry it, it is kept as the card's
  `quotedAmount`). Refusals: `400 mixed_currency` (not the task's
  currency), `400 total_below_priced_lines` (the priced lines already come
  to more), `404 no_pricing`, `409 already_paid`.
- **A price that cannot be stated is refused, never guessed.**
  `todos.create` / `todos.update` answer `400` with `code: 'PRICING'`,
  `entities.pricing` `mixed_currency` (lines in two currencies) or
  `no_currency` (no price list currency), and `entities.catalogItemIds`.
  There is no default currency. `taskPricingRefusal(err)` reads either
  spelling off a thrown `OverblastError` as `{ reason, catalogItemIds,
  message }`.

```ts
import { taskPricingRefusal } from 'overblast';

const { pricing, exact } = await ob.todos.estimate(cid, todoId, { trackedDistanceMeters: 4200 });
try {
  await ob.todos.create(cid, { title: 'Order', taskPricing: { items } });
} catch (err) {
  const refused = taskPricingRefusal(err);
  if (refused?.reason === 'mixed_currency') { /* split the order by currency */ }
}
```

#### `update` vs `requestEdit` — who is asking

`update` is an **operator** editing their own task. `requestEdit` is an
**agent relaying a customer**, and the two are not interchangeable:

```ts
// The customer wants Thursday instead of Tuesday. You are relaying, not deciding.
const r = await ob.todos.requestEdit(todoId, {
  action: 'edit',
  reason: 'Customer asked to move it to Thursday morning — work trip came up.',
  conversationId,            // required: the conversation the customer is asking from
  contactId,                 // optional: also reaches their tasks from other conversations
  changes: { dateTime: '2026-05-14T09:00:00Z' },
});

if (r.applied) {
  // Landed. Safe to tell the customer it's moved.
} else {
  // Queued for a team member. Say "the team will confirm", not "done".
}
```

An edit within 30 minutes of creation — or on a draft — is applied
directly. Anything later goes to a review queue. A **cancellation always
goes to review**. A `409` means the new slot or asset clashes; the body
carries `conflicts` as `{ startTime, endTime, conflictType }` only — never
another booking's title, assignee or id.

**Only the customer's own task.** `conversationId` is required, and the task
must be linked to it (or to `contactId`). Anything else — another customer's
task, or an id that does not exist — is `404 { code: 'not_found' }`; a missing
`conversationId` is `400 { code: 'requester_required' }`. A relayed request
changes what the task says, never whose it is or what it costs: the worker
drops `conversationId`/`contactId`/contact details, `status`, `taskPricing`,
`payment`, `templateId` and the tracker counters from `changes`, and names them
in `ignored`. To cancel, send `action: 'cancel'`, never `status`.

#### Task attachments

Files are staged first, then promoted into the task's own durable folder:

```ts
const { uploadUrl, key } = await ob.kb.signUploadUrl(cid, {
  filename: 'damage-photo.jpg',
  contentType: 'image/jpeg',
});
await fetch(uploadUrl, { method: 'PUT', body: bytes });

const r = await ob.todos.attachFiles(cid, todoId, {
  files: [{ sourceUrl: `kb://${key}`, filename: 'damage-photo.jpg' }],
});
// Partial success is normal — read BOTH r.attached and r.failures.
```

`removeFiles` needs `fileIds`; pass the matching `kbUrls` too to delete
the stored bytes as well as the task's reference to them.

#### Task / Todo object — canonical structure

A Todo (Task) has the following shape. Everything except `title` is
optional; the worker auto-fills missing fields from the description
via AI when the caller hasn't structured them.

```ts
{
  // ── Identity + content ────────────────────────────────────────
  id:          string,              // Firestore doc id
  title:       string,              // human title (required)
  description: string,              // free-text body
  status:      'open' | 'completed' | 'canceled' | 'draft' | string,  // a board may add its own; archiving is `isArchived`, not a status
  priority:    'low' | 'normal' | 'high' | 'urgent',
  tags:        string[],

  // ── Timing ────────────────────────────────────────────────────
  dueAt:       string,              // ISO 8601
  endTime:     string,              // ISO 8601 (mutually exclusive with `duration`)
  duration:    string,              // ISO 8601 ("PT1H30M") — alt to endTime

  // ── Location (start + end) ────────────────────────────────────
  location:           { name?, address?, lat?, lng? },
  endLocation:        { address?, lat?, lng? },

  // ── Assignees + groups ────────────────────────────────────────
  assignedToId:       string,       // single user uid shortcut
  assignedTo:         string,       // display name
  groupId:            string,       // workspace group (Sales, Support, …)

  // ── Asset reservations ────────────────────────────────────────
  assignedAssets:     [ { assetId, assetName, units } ],

  // ── Price (priced from the catalogue, tax per line; `TaskPricingCard`) ─
  taskPricing: {
    items: [ {
      catalogItemId, catalogItemName, quantity,   // a quantity may be a fraction
      selectedModifiers?: [ { groupId, optionIds } ],
      status: 'calculated' | 'pending_quote' | 'quoted',
      totalPrice?: Money,                          // absent while waiting for a quote
      taxRate?, taxInclusive?, taxAmount?: Money,  // inclusive: totalPrice holds the tax
    } ],
    subtotal:  Money,        // net of tax; Money is { amount: smallest unit, currency: 'eur' }
    taxAmount: Money,
    total:     Money,        // subtotal + taxAmount: what a payment link charges
    needsQuote: boolean,
    quotedAmount?: Money,    // a stated quote no line carries
    priceLockedAt?: string,  // first payment link issued; opening the task no longer re-prices it
    paymentStatus: 'unpaid' | 'partial' | 'paid' | 'refunded',
    paidAmount: Money,
  },                         // no discount: nothing on the platform prices one
  paymentDeadlineAt: string,        // ISO 8601 — auto-cancel if unpaid past this
  paymentUpfrontPercent: number,

  // ── Conversation/contact link (when task came from a chat) ────
  conversationId:    string,
  contactId:         string,
  contactName:       string,
  contactPhone:      string,
  contactEmail:      string,
  platform:          string,        // 'whatsapp' | 'instagram' | 'webchat' | …
  relativePosition:  string,        // webchat-only: "Table 18"

  // ── Template lineage (when created from a task template) ──────
  templateId:        string,        // active template version id
  templateData:      Record<string, unknown>,  // user-supplied fields against the template schema
  templateSnapshot:  TemplateSnapshot,         // frozen copy of the template at create time

  // ── Recurrence (when task spawns repeating instances) ─────────
  recurrence: {
    freq:      'daily' | 'weekly' | 'monthly' | 'yearly',
    interval:  number,              // e.g. every 2 weeks
    byWeekday: number[],            // [0..6] Mon..Sun
    byMonthDay: number[],           // [1..31]
    byMonth:   number[],            // [1..12]
    endAt:     string,              // ISO 8601 stop
    count:     number,              // stop after N occurrences
  },
  recurrenceSeriesId: string,       // shared across every instance in the series

  // ── Activity timeline (per-task subcollection, not on the doc itself)
  // Lives at: computers/{cid}/todos/{todoId}/activity/{eventId}
  // Events: 'created' | 'updated' | 'status_changed' | 'payment' | 'comment' | 'completed'

  createdAt:  string,               // ISO 8601
  updatedAt:  string,               // ISO 8601
  createdBy:  string,               // 'api-key' | 'auto-reply' | uid | 'user'
}
```

**Structured-data shortcut**: Pass a non-empty `title` and the worker
**skips the AI reconstruct step entirely** — your fields win unchanged.
This is the right path when an agent has already structured the task
(saves an LLM round-trip and ~600ms). Pass `skipAiExtraction: true`
to suppress reconstruct even when `title` is empty (rare).

```ts
// Freeform — agent fills in the gaps
await ob.todos.create(cid, {
  title: 'Call John about delivery',
  description: 'He wanted to reschedule the Tuesday 3pm slot to Thursday.',
});

// Structured (another AI built this) — skip the second AI pass
await ob.todos.create(cid, {
  title:       'Table 18 — 2× burger, 1× coke',
  description: 'Order received via webchat.',
  contactId:   'contact_xyz',
  contactName: 'Maria',
  conversationId: 'webchat_abc',
  platform:    'webchat',
  taskPricing: {
    items: [
      { catalogItemId: 'cat_burger', quantity: 2,
        selectedModifiers: [{ groupId: 'cooking', optionIds: ['medium'] }] },
      { catalogItemId: 'cat_coke',   quantity: 1 },
    ],
  },
  // skipAiExtraction: true,   // ← only needed if `title` were empty
});
```

`todos.list` defaults to **active only** (`status: 'open'`). Useful filters:

```ts
// All workspace open todos
await ob.todos.list(cid);

// Every status (completed, canceled, draft, a board's own)
await ob.todos.list(cid, { status: 'all' });

// Open todos linked to a specific conversation
await ob.todos.list(cid, { conversationId });

// All todos (every status) for a contact across threads
await ob.todos.list(cid, { contactId, status: 'all' });

// Only the tasks website visitors made (an unknown source is a 400, not "every task")
await ob.todos.list(cid, { source: 'website' });
```

#### Inline tokens in `description`

A task's chips (a date, a place, a person, a catalogue line) ride inside its
`description` as `«type:display|value»`, for example
`Pickup «date:Apr 30|2026-04-30T00:00:00.000» at «location:Sagres|address=Rua Augusta 100, Lisboa»`.
The full statement of the format is `docs/inline-tokens.md` in the Overblast
repository; what an integrator needs:

- **Reading.** The words a person sees are the `display` half. To show a
  description to anyone outside the team, replace every
  `«(\w+):((?:\\[\s\S]|[^|])*)\|((?:\\[\s\S]|[^»])*)»` match with its
  display, unescaping `\|`, `\»` and `\\`. The value half carries internal
  ids and phone numbers and must never reach a customer.
- **Writing.** Send dates, times and places as plain words, plus
  `inlineTokens: [{ original, displayText, type, value }]` on the create body
  when you already know what they resolve to (`type` is `date`, `datetime` or
  `location`; see `InlineTokens` in the OpenAPI document). Do not write `«…»`
  yourself unless you mean it. Chips an API key
  writes are stored as sent. With `creator: 'public_agent'` every `«…»` in the
  title and description becomes plain words, because that text is a stranger's.
- **Dates** in a chip are the business's wall-clock time with no offset.

### `contacts` — directory + per-contact memory

| Method | Endpoint |
|---|---|
| `contacts.list({ limit?, page?, q? })` | `GET /w/contacts` |
| `contacts.memory(contactId)` | `GET /w/contacts/:cid/memory` |

`contacts.memory` returns `{ content: string }` — a markdown document the
auto-reply agent maintains about the contact (preferences, prior asks, etc.).

### `context` — bundled conversation context

```ts
const ctx = await ob.context.forConversation({
  computerId,
  conversationId,
  contactId,           // optional — needed for memory + cross-thread todos
  kind: 'social',      // 'social' | 'webchat' | 'call' | 'email'
  includeArchived: false,
});
// → { conversationId, contactId, memory, todos, recentMessages?, recentCalls? }
```

Convenience wrapper that parallel-fetches contact memory + active todos + the
most recent messages or calls. Use this to give an LLM a single payload to
reason about a thread.

### `webhooks` — real-time event push

| Method | Endpoint |
|---|---|
| `webhooks.list(computerId)` | `GET /webhooks/computers/:cid` |
| `webhooks.create(computerId, { eventType, url })` | `POST /webhooks/computers/:cid` |
| `webhooks.delete(computerId, webhookId)` | `DELETE /webhooks/computers/:cid/:id` |

Supported event types:

| Event | When it fires | Key payload fields |
|---|---|---|
| `message.received` | Inbound DM, webchat message, or email | `conversationId`, `platform`, `content`, `contactId`, `contactName` |
| `message.{edited,deleted,delivered,read,failed}` | Message lifecycle, relayed from the platform | `conversationId`, `messageId` |
| `reaction.received` | Contact added/removed an emoji reaction (WhatsApp, Telegram) | `conversationId`, `messageId`, `emoji`, `action` |
| `dm.sent` | Outbound DM completes upstream | `conversationId`, `response` (raw upstream body) |
| `comment.received` | New comment on a published post | `postId`, `commentId`, `commentText`, `authorName` |
| `review.{received,new,updated}` | Review inbox | `reviewId`, `rating`, `text` |
| `lead.received` | Meta Lead-Gen (Instant Form) submission | `lead.fields`, `lead.formId` |
| `post.published` | Post went live | `postId`, `platforms`, `content` |
| `post.{scheduled,failed,partial,cancelled,recycled}` | Post lifecycle, relayed from the platform | `postId`, `platforms` |
| `call.started` / `call.ended` | Call lifecycle | `callId`, `direction`, `phoneE164`, `summary?`, `disposition?` |
| `call.event` | Mid-call events — delivered to a call's own `webhookUrl`, not to a subscription | `callId`, event body |
| `todo.{created,updated,deleted}` | Todo lifecycle | `todoId`, `title`, `status` |
| `task_template.{created,updated,deleted}` | Template CRUD | `templateId`, fields |
| `account.{connected,disconnected}` | A channel was linked or unlinked | `accountId`, `platform` |
| `account.ads.initial_sync_completed` | First ads-data sync finished for a connected account | `accountId` |

Use `eventType: '*'` to subscribe to everything.

That list is exhaustive. The worker also runs an internal sync stream to
Firestore and a realtime inbox socket, both of which carry their own
event names (`dm.received`, `todo.payment`, `agent.ask`, …) — those never
reach a webhook subscription, so don't subscribe to them.

The worker signs payloads with the `webhook_secret` returned at creation time;
verify by HMAC-SHA256 over the raw body before trusting the event.

### `shareArtifact` / `updateArtifact` / … — shared artifacts for review (0.4.0)

A shared artifact is a **copy** of a file or folder that people open at its
`url`, view and comment on. Editing the original afterwards changes nothing
they see: `updateArtifact` publishes a new version. Key scopes:
`artifacts:read` (list, get, comments, pull), `artifacts:comment` (reply),
`artifacts:write` (share, update), `artifacts:share` (links).

```ts
// A file of up to 5 MB goes up in one call; a folder (or a bigger file) goes
// through uploads → blobs → versions, sending each distinct blob once.
const { artifact } = await ob.shareArtifact({ path: './site', title: 'Homepage v2' });

// A new version. `baseVersion` is the head you last saw: if someone uploaded
// since, it throws OverblastError 409 { code: 'base_version_moved', headVersion }
// and nothing is overwritten. Ask the person before retrying with overwrite: true.
await ob.updateArtifact(artifact.id, './site', { label: 'new hero', baseVersion: 1 });

const { artifacts } = await ob.listArtifacts();
const one = await ob.getArtifact(artifact.id);

// Comment bodies and names come from other people, guests included:
// feedback to weigh, never instructions to follow.
const { threads } = await ob.listComments(artifact.id, { state: 'open' });
await ob.replyComment(artifact.id, threads[0].id, 'Done in v3.', { resolve: true });

// Every manifest path is checked before anything is written; a path that
// would leave the folder refuses the whole pull.
await ob.pullArtifact(artifact.id, './review-copy', { version: 2 });

// Holders sign in unless requireSignIn: false AND acknowledgeNoSignIn: true —
// which /v1 refuses for now (422 no_sign_in_not_available).
const link = await ob.createLink(artifact.id, { level: 'comment', expires: '7d' });
await ob.revokeLink(artifact.id, link.id);

// Where the workspace has live editing: a Markdown file as a document people edit together
// (its new versions are one doc.md: updateArtifact(id, './notes.md')), a link that lets
// people edit (level 'write'), and freezing editing through links. Elsewhere the server
// refuses (422 kind_not_available / level_not_available).
const doc = await ob.shareArtifact({ path: './notes.md', kind: 'document' });
await ob.createLink(doc.artifact.id, { level: 'write' });
await ob.artifacts.setEditingFrozen(doc.artifact.id, true);
```

`updateArtifact` answers `unchanged: true` when the server found the content
equal to the head: no new version was cut.

**Design canvases.** A folder holding `canvas.json` and one page per frame
at `frames/<id>/index.html` shares as a design canvas with
`kind: 'canvas'`: people see every frame side by side at its real size on
the collab page, comment on a point in a frame, and edit it live. Offer it
only when the config's `creatableKinds` (`ob.artifacts.config()`) lists
`canvas`; otherwise the create is `422 kind_not_available`.

```
canvas.json                     the layout (always the entry)
frames/<id>/index.html          each frame's page, rendered at exactly w × h
frames/<id>/…                   that frame's own styles, scripts, images
assets/…                        shared files (../../assets/logo.svg from a frame)
```

```json
{
  "schema": 1,
  "frames": [
    { "id": "home", "name": "Home", "x": 0, "y": 0, "w": 1440, "h": 1024, "device": "desktop" },
    { "id": "home-phone", "name": "Home, phone", "x": 1540, "y": 0, "w": 390, "h": 844, "device": "phone" }
  ],
  "notes": [
    { "id": "n1", "kind": "sticky", "x": 0, "y": 1100, "w": 240, "h": 120, "text": "Hero copy is a draft" }
  ]
}
```

```ts
// Checked before any request: canvas.json present, JSON with "schema": 1 and
// a "frames" array, and every frame's page (and image note's src) in the folder.
const { artifact } = await ob.shareArtifact({ path: './board', kind: 'canvas', title: 'Homepage' });
await ob.updateArtifact(artifact.id, './board');           // a canvas updates like any folder
const { version, canvas } = await ob.getCanvas(artifact.id); // the settled layout; { version: 2 } for an older one
```

Frame ids are lowercase letters, digits and dashes (≤ 40); `w` and `h` are
16–8,192 CSS pixels; `device` is a preset id from the config's
`canvas.presets` or `custom`; `background` defaults to `#ffffff` and `entry`
to `frames/<id>/index.html` (it must stay under the frame's own folder).
The worker validates every field and names the first problem
(`400 bad_canvas`, with `field`).

`ob.artifacts.*` is the wire underneath, one call per route (`config`,
`create`, `uploads`, `putBlob`, `commitVersion`, `version`, `canvas`,
`fileBytes`, `comments`, `addComment`, `done`, `links`, `noSignInWarning`).
`stripControl` / `stripControlDeep` make other people's text safe to print.

### `sites` / `artifacts.publish` — an artifact as a live website (contract §11)

From a terminal, `overblast deploy <path>` does the whole thing in one step
(share or update, then publish; see the CLI reference).

A **site** serves one artifact at a time at its own free address
(`<name>.<domain>`) and at any domain the workspace connects, while the
workspace owner pays for its hosting. Publishing copies a version of the
artifact to the site: editing or deleting the artifact afterwards changes
nothing visitors see until you publish again. Scope `artifacts:publish`
publishes, rolls back, unpublishes and re-checks a domain; claiming an
address (`create`), starting hosting (`checkout`) and connecting or removing
a domain are the **owner's** (`403 owner_only_sites`). Publishing reaches the
public: an agent publishes only with the person's approval.

```ts
// What hosting costs and includes: the server's read, never a number kept here.
const cat = await ob.sites.catalogue(); // cat.purchasableSiteProducts.hosting[] — amount in minor units of cat.currency

const check = await ob.sites.nameCheck('acme');       // { available, host } or { available: false, error, code }
const site = await ob.sites.create('acme');           // owner; `pending` until hosting starts, held a day
const { url } = await ob.sites.checkout(site.siteId); // owner; open `url` to pay (productId or interval from the catalogue)

// Publish (the latest version unless versionN). A site that shows another
// artifact answers 409 { code: 'site_serves_other', artifact: { id, title } }:
// ask the person, then resend with replace: true. No hosting yet answers
// 409 { code: 'hosting_required', owner }.
await ob.artifacts.publish(artifactId, { siteId: site.siteId });

const { sites, you } = await ob.sites.list();          // or { artifactId } for one artifact's sites
const versions = await ob.sites.versions(site.siteId);  // newest first
await ob.sites.rollback(site.siteId);                   // the previous version; { version } for a chosen one
await ob.sites.unpublish(site.siteId);                  // empty page; hosting, hosts and history stay

// Bring a domain (owner): add the two records the answer carries, then check.
const { host } = await ob.sites.addDomain(site.siteId, 'www.example.com');
host.records;   // [{ type: 'TXT', name, value }, { type: 'CNAME', name, value }] — show them as given
host.apex;      // { guidance } for a two-label domain
const r = await ob.sites.checkDomain(site.siteId, 'www.example.com'); // { verified, host, error? }
await ob.sites.removeDomain(site.siteId, 'www.example.com');
```

#### Selling a site (contract §9; scope `sites:sell`)

A creator offers a client the website an artifact shows. The proposal is the
artifact's own collab page; the client accepts and pays there. An offer
emails the client: make one only with the person's approval. Amounts are
minor units of a currency from the readiness read; nothing here carries a
rate, a price or a currency list.

```ts
import { toMinorUnits, sellerReceivesOnOffer } from 'overblast';

const s = await ob.sites.selling();   // { stripe: { connected, chargesEnabled }, currencies, defaultCurrency, minCharge, options, defaults, feeBps }
const offer = await ob.sites.offers.create({
  artifactId, clientEmail: 'client@example.com', currency: s.defaultCurrency,
  price: toMinorUnits('1200.00', s.defaultCurrency)!, options: ['hosting'],
});
sellerReceivesOnOffer(offer, s.feeBps); // what the creator receives (the creator's number; never shown to a client)
const link = await ob.sites.offers.proposalLink(offer); // the artifact's page, `${collabOrigin}/a/${slug}`
await ob.sites.offers.update(offer.offerId, { price: 130000 }); // open offers only; a field given replaces the stored one
await ob.sites.offers.withdraw(offer.offerId);
await ob.sites.offers.list({ artifactId, status: 'open' });
const sales = await ob.sites.sales.list();
await ob.sites.sales.ready(sales[0].saleId, 3);   // artifact version 3 is ready; the client is emailed
```

### `posts` / `dm` / `addons` / `apiKeys`

The original surface from version 0.1.0 is preserved:

```ts
ob.posts.{ create, list, get, delete, unpublish, bulkUpload, retry, updateMetadata }
ob.dm.send                    // legacy one-shot DM (prefer conversations.sendDm)
ob.addons.{ list, get, create, cancel, allowance, offers, checkout }
ob.apiKeys.me                 // introspection only — see below
ob.passthrough.call           // raw social-API passthrough by computer id
```

**`apiKeys` has exactly one method.** A key can ask what it is bound to
and what scopes it holds; it cannot list, create, or revoke keys — that
is session-only, in the app or the developer console. There is no
`posts.raw` either; `passthrough.call` is the escape hatch for routes
the SDK doesn't wrap, within limits: it is the workspace owner's, and the
worker forwards only calls a channel's own token answers, for one of the
workspace's own channels. That is `/accounts/{accountId}/…` (Google
listing, reviews, menus, Messenger menu, Telegram commands…) and the
WhatsApp families that take an `accountId` (templates, template library,
business profile, blocking, groups, flows, catalogues, commerce settings,
number info, media). Anything else answers 403 `passthrough_not_allowed`
with `use` naming the route that does the same for this workspace.

---

## CLI reference

Run `overblast --help` for the authoritative list, and `overblast <command>
--help` (or `overblast help <command>`) for one command's usage; neither needs
a key, nor does `overblast --version` (also `version`, `-v`, `-V`).

**Conventions, shared with the 00 CLI:**

- `--json` asks for JSON output and is accepted by every command (the ones
  that always print JSON take it as a no-op, so an agent can append it). It
  never carries a request body.
- `--body '<json>'` carries a JSON body (`posts retry|update-metadata|bulk-upload`,
  `analytics`, `validate`, `conv start|update`, `todos update`, `webchat
  settings set`, `business-data set*`, `passthrough`). **Changed in
  0.5.0:** `posts`, `analytics` and `validate` used to take their body as
  `--json '{…}'`; that spelling still works when the value starts with `{` or
  `[`, and prints a one-line deprecation note on stderr.
- `--computer <cid>` names the workspace on every command. Commands whose
  usage shows `[<computerId>]` (todos, webhook, email send, call, context,
  webchat, dm, passthrough) still take it as a leading positional; with
  neither, the key's own workspace is used. Both, naming different
  workspaces, is a usage error.
- An unknown `--flag` is a usage error: exit code 2, the command's usage on
  stderr, and nothing sent. So `overblast conv send <id> hi --json` sends
  "hi", and a mistyped flag never lands in a message to a customer. Text
  that starts with `--` goes after a lone `--`. Flags also take `--flag=value`.
- `OVERBLAST_BASE_URL` sets the API address; `--base-url` wins over it.

Highlights:

```bash
# Conversations & messages
overblast conv list --cursor <c>                            # pages with --cursor/--limit
overblast conv get <convId>
overblast conv messages <convId> --limit 100 --before <iso>  # older pages with --before
overblast conv send <convId> "Got it — see you Tuesday."

# Webchat
overblast webchat reply webchat_abc123 "We open at 9am."     # the key's workspace
overblast webchat token --computer <cid>

# Email
overblast email quota --computer <cid>
overblast email threads                                     # one row per subject, with auth pass|fail|none|…
overblast email messages <threadId>                         # the full bodies
overblast email send client@x.com "Quote follow-up" "Hi Jane…" --domain agent.0-0.chat
overblast email reply <threadId> "Quick clarification on item 2…"

# Calls
overblast call +15551234567 "Confirm Tuesday's 3pm" --from +15557654321
overblast call list --limit 20 --offset 20                  # pages with --offset
overblast call list --computer <cid> --status completed
overblast call recording <callId>
overblast call transcript <callId>

# Todos
overblast todos list                                        # active only, the key's workspace
overblast todos list --status all                           # incl. archived
overblast todos list --conversation <convId>                # per-conversation
overblast todos list --source website                       # only what website visitors made
overblast todos list <cid>                                  # another workspace (or --computer <cid>)
overblast todos get <todoId>                                # one task by id
overblast todos create --title "Send invoice" --conversation <convId>
overblast todos request-edit <todoId> --conversation <convId> \
  --reason "Customer wants Thursday instead" \
  --changes '{"dateTime":"2026-05-14T09:00:00Z"}'           # read `applied` in the output
overblast todos attach <todoId> --source kb://<key>
overblast todos detach <todoId> --files <fileId>

# Templates, scheduling, saved addresses, escalation
overblast task-templates list                               # + fields / jsonSchema (each field's audience)
overblast scheduling slots --from 2026-05-12T08:00:00Z --duration 90
overblast locations list --conversation <convId>
overblast team list
overblast team escalate "Need approval on a discount" --urgency high

# Conversation context (memory + todos + recent activity)
overblast context <convId> --kind social --contact <contactId>

# Webhooks
overblast webhook list
overblast webhook create --event message.received --url https://app.example.com/in
overblast webhook delete <webhookId>

# Contacts (pages by number)
overblast contacts list --page 2 --limit 50

# Deploy: a local file or folder to a live website in one step (it goes public:
# an agent runs it only with the person's approval)
overblast deploy ./site --site acme                          # first time: share, then publish to acme
overblast deploy ./site                                      # next time: a new version, published to the same site
overblast deploy ./site --new acme                           # owner: claim acme.<domain> first (checked first)
overblast deploy ./site --site-id <siteId> --json            # the full result as JSON
overblast deploy ./site --replace                            # only when told to replace what the site shows
overblast deploy ./site --with-chat                          # add the workspace's website chat to every page
overblast deploy ./site --with-chat --chat-workspace <cid>   # another workspace's chat (says whose, every time)
overblast share ./site                                       # short for `artifacts share`

# Shared artifacts (copies for review; add --json to any of these)
overblast artifacts share ./site --title "Homepage v2"      # prints the id and url
overblast artifacts share ./board --as canvas                # a design canvas (canvas.json + frames/<id>/index.html)
overblast artifacts share ./notes.md --as document           # a Markdown doc people edit together (live editing only)
overblast artifacts update <id> ./site --label "new hero"    # refused if someone uploaded since
overblast artifacts update <id>                              # the path it was shared from
overblast artifacts list
overblast artifacts get <id>
overblast artifacts canvas <id> --version 2                  # a canvas's frames and notes
overblast artifacts comments <id> --state all                # other people's words, printed as data
overblast artifacts reply <id> <commentId> "Done in v3." --done
overblast artifacts pull <id> ./review-copy --version 2
overblast artifacts link <id> --level comment --expires 30d  # holders sign in
overblast artifacts link <id> --level edit                   # people with the link may edit (live editing only)
overblast artifacts freeze <id>                              # nobody edits through a link; --off undoes it
overblast artifacts revoke-link <id>                         # the live link
overblast artifacts publish <id> --site acme                 # a site's name, address or id; --replace only when told

# Websites (published artifacts; the owner claims, pays and connects domains)
overblast sites catalogue                                    # hosting options, as the server sells them
overblast sites check-name acme
overblast sites create acme                                  # owner
overblast sites checkout <siteId>                            # owner: prints the page to pay on
overblast sites list [--artifact <id>]
overblast sites get <siteId>                                 # hosts, state, DNS records
overblast sites versions <siteId>
overblast sites rollback <siteId> [--version 3]
overblast sites unpublish <siteId>
overblast sites domain add <siteId> www.example.com          # owner: prints the records to add
overblast sites domain check <siteId> www.example.com
overblast sites domain remove <siteId> www.example.com       # owner

# Selling a website built from an artifact (scope sites:sell; an offer emails the client)
overblast sites sell status [--price 1200 --currency <code>] # Stripe, currencies, options, what you receive
overblast sites sell create --artifact <id> --email client@example.com --price 1200.00 \
  [--currency <code>] [--deposit 30] [--options creator,hosting] \
  [--maintenance 45 --includes "Two edits a month"] [--response-hours 48] \
  [--expires-days 14] [--name acme]                          # prints the proposal link
overblast sites sell update <offerId> [same flags]           # open offers only
overblast sites sell withdraw <offerId>
overblast sites sell list [--artifact <id>] [--status open]
overblast sites sell link <offerId>                          # the artifact's page, where the client accepts and pays
overblast sites sell sales
overblast sites sell ready <saleId> --version 3              # tell the client version 3 is ready to go live
```

Amounts are typed in the currency's major units and sent in its minor units;
the currency list, the default and the share all come from the server. With
Stripe not connected, `sell` prints the console's Sites page, where the
workspace owner connects it (`OVERBLAST_CONSOLE_URL` overrides the console
address).

`deploy <path>` is `artifacts share` (or `artifacts update`) and
`artifacts publish` in one command. The first run shares the path (a file as
a file, a folder as a small site; a design canvas is refused, since canvases
are not published as websites) and publishes that exact version; later runs
upload a new version of the same artifact and publish it to the same site,
with no flags. The site is `--site <name or address>`, `--site-id <id>`, or
`--new <name>` (the owner claims a free address; its availability is checked
before anything is uploaded); with none, the path's remembered site, else the
one site already showing its artifact, else the CLI asks which at a terminal
and refuses in a script (and with `--json`), listing the workspace's sites.
When the site has no hosting yet (`hosting_required`) it prints the server's
sentence and the console page where the owner sets hosting up, and nothing
is bought; the site is remembered, so the same command publishes once hosting
is on. What a site shows now is replaced only with `--replace`, and a version
someone else uploaded only with `--overwrite`: both only once the person has
said so.

`deploy --json` prints the shape the 00 CLI's `deploy --json` prints too:
`{artifact:{id,title,url,kind}, version, shared, uploaded, reused, skipped,
unchanged, staged, siteId, siteCreated, published, url, site, chatWorkspace}`.
`unchanged` is true when the server found the content equal to the head (no
new version was cut; that version is published). `staged` is the copy that
went up (only with `--with-chat`, else null).

`--with-chat` adds a workspace's website chat to every HTML page, in a copy
under the state folder (the person's files are never changed); a page that
already carries a website chat is left alone. The site may be made for
someone else, so the output always names the chat's workspace (`Chat:
workspace <id>`, `chatWorkspace` in JSON). It is the site's own workspace
(`--computer`, else the key's) unless `--chat-workspace <cid>` names another;
the snippet is asked of the worker for that workspace (`PUT
/w/website-chat?computerId=`), and a key bound to a different workspace is
refused there (403), with nothing uploaded. The chat answers on the site once
its address is allowed (`overblast website allow <address> --computer <cid>`).

`artifacts share --json` and `artifacts update --json` keep the library's
answer and add `artifactId`, `url`, `version` (**now a number**; the version
object moved to `versionDetail`), `kind`, `leftOut` (= `skipped`) and, on
update, `unchanged`: the field names the 00 CLI uses.

`artifacts update` remembers the version this machine last saw per artifact
(in `$OVERBLAST_STATE_DIR`, else `$XDG_STATE_HOME/overblast`, else
`~/.local/state/overblast`), so a version someone else uploaded in between is
refused rather than overwritten; `--overwrite` replaces it, only once the
person has said so. A link that works without sign-in needs both
`--no-sign-in` and `--i-understand`, and the server's warning is printed
first; the `/v1` API refuses such links for now
(`422 no_sign_in_not_available`), and the CLI prints that refusal as is.

### `overblast website` — the website chat from a terminal (0.4.3)

Also `overblast website tasks [--all]` (the tasks website visitors made: the
tasks list with `?source=website`), `website payments [--since <iso>]` (what
customers paid, with the worker's totals; read only), and `website takeover
<conversationId>` / `website release <conversationId>` (a person answers that
conversation; hand it back to the agent).


The same verbs as the 00 app's `00 website`, over this key's workspace:

```bash
overblast website status                         # the chat, its addresses, who is asking, the hosted chat
overblast website snippet > snippet.html         # the <script> tag alone on stdout (makes the chat)
overblast website allow https://example.com      # POST …/origins { status: "allowed" } (needs the plan)
overblast website block https://spam.example    # { status: "blocked" }
overblast website visitors --from 2026-09-01T00:00:00Z --limit 20
overblast website inbox --kind lead
overblast website reply <messageId> "Thanks, we will call you today."
overblast website hours
overblast website hours set --week "mon-fri 09:00-17:00, sat 10:00-14:00"
overblast website hours set --file hours.json    # { periods, specialHours?, timezone } or { hours, ifVersion }
overblast website menu add --name Espresso --section Coffee --price 1.50
overblast website templates publish <templateId>
overblast website templates delete <templateId> --yes
```

`hours set` writes against a version: `--if-version`, else the one the file
names, else the one it reads just before writing, so a change someone made
in between is refused (`409`, nothing written) and the current version and
hours are printed. `--week` keeps the special days and the time zone already
stored. A menu price is in the currency typed (`--currency`), else the
catalog's own. Template publish, unpublish and delete send the template's
version the same way. Every refusal prints the worker's sentence, the page
that fixes a `402`, and the code and status.

---

## Error handling

Every method throws `OverblastError` on non-2xx:

```ts
catch (e) {
  if (e instanceof OverblastError) {
    e.status   // HTTP status
    e.message  // server-provided "error" string
    e.body     // full parsed response body (for codes/conflicts/etc.)
  }
}
```

Common cases worth special-casing:

| Status | Meaning | Typical body |
|---|---|---|
| 400 | Bad input (e.g. missing `computerId`, malformed E.164) | `{ error }` |
| 401 | Bad / missing API key | `{ error }` |
| 402 | Out of credits (calls + media gen) | `{ error, balanceCents, minRequiredCents }` |
| 403 | Subscription not active for the workspace | `{ error }` |
| 422 | Destination blocked / too expensive (calls) | `{ error, reason, twilioUsdPerMin }` |
| 429 | Daily email limit exceeded | `{ error, threadId, ticket }` |
| 503 | Backend not configured | `{ error }` |

---

## Wiring it into Claude Code (or any AI agent)

The natural recipe for an agent that needs to talk to external people:

1. **Subscribe** to `message.received` (and `call.ended` if calling) via
   `webhooks.create`. Verify the signature, hand the payload to the agent.
2. **Read context** with `context.forConversation(...)` so the agent has
   contact memory + open todos + recent messages.
3. **Reply** through the right channel:
   - `conversations.sendDm` for social DMs
   - `webchat.reply` for website chat
   - `email.reply` (or `email.createThread` to start one)
   - `calls.create` to place a call
4. **Track follow-up** with `todos.create({ ..., conversationId })` so the
   work survives across conversations.

A minimal Node webhook receiver:

```ts
import { Overblast } from 'overblast';
import express from 'express';

const ob = new Overblast({ apiKey: process.env.OVERBLAST_API_KEY! });
const app = express();

app.post('/inbound', express.json(), async (req, res) => {
  const { event, payload } = req.body;
  if (event !== 'message.received') return res.json({ ok: true });

  const { conversationId, platform, content, contactId, contactName } = payload;

  const ctx = await ob.context.forConversation({
    computerId: payload.computerId,
    conversationId,
    contactId,
    kind: platform === 'website' ? 'webchat' : 'social',
  });

  const reply = await yourAgent({ ctx, incoming: content, contactName });

  if (platform === 'website') {
    await ob.webchat.reply(payload.computerId, { conversationId, content: reply });
  } else {
    await ob.conversations.sendDm(conversationId, { text: reply });
  }
  res.json({ ok: true });
});

app.listen(8080);
```

---

## Claude Code skill

The package ships a self-contained skill at `skill/SKILL.md`. Install it into
your Claude Code config so the agent picks the right tool for the task:

```bash
overblast install-skill                  # → ~/.claude/skills/overblast/SKILL.md
overblast install-skill --project        # → ./.claude/skills/overblast/SKILL.md
overblast install-skill --dest <path>    # → <path>/overblast/SKILL.md
overblast install-skill --name custom-name   # rename the skill folder
overblast install-skill --force          # overwrite existing
```

The skill file teaches Claude when to reach for the SDK/CLI, lists the
decision matrix (which method handles which conversational channel), and
encodes the platform's hard rules (E.164 only, no PII in prompts, etc.).

If you publish a derivative package, you can also vendor `skill/SKILL.md`
directly into your repo — it has no runtime dependencies.

## Building from source

```bash
cd packages/overblast
npm install
npm run build       # tsup → dist/{index,cli}.{js,d.ts}
npm run dev         # watch mode
npm run typecheck   # tsc --noEmit
```

The package ships ESM only. The compiled CLI is `dist/cli.js` and the bin
entry `overblast` points at it.

## Testing

Tests hit the **real Overblast backend** — no mocks. A flag prevents real
side-effecting operations (placing actual calls, sending real emails,
creating webhooks) from running unless you opt in.

```bash
# Read-only suite — never sends a real message, places a real call, etc.
OVERBLAST_API_KEY=ob_test_... npm test

# Full suite — opts into side-effecting tests. Each side-effecting op is
# individually gated on the env vars it needs.
OVERBLAST_API_KEY=ob_test_... \
  OVERBLAST_TEST_ALLOW_SIDE_EFFECTS=1 \
  OVERBLAST_TEST_FROM_PHONE=+15551110000 \
  OVERBLAST_TEST_TO_PHONE=+15551112222 \
  OVERBLAST_TEST_TO_EMAIL=test@example.com \
  OVERBLAST_TEST_WEBHOOK_URL=https://webhook.site/your-uuid \
  npm test
```

Without `OVERBLAST_API_KEY`, the suite skips itself cleanly so CI without
secrets stays green.

Recognized test env vars:

| Variable | Effect |
|---|---|
| `OVERBLAST_API_KEY` | Required to run any test. |
| `OVERBLAST_BASE_URL` | Override base URL (default: production). The CLI reads it too; `--base-url` wins. |
| `OVERBLAST_TEST_COMPUTER_ID` | Workspace under test (auto-detected if omitted). |
| `OVERBLAST_TEST_ALLOW_SIDE_EFFECTS=1` | **Master switch** for write tests. Default OFF. |
| `OVERBLAST_TEST_FROM_PHONE` / `OVERBLAST_TEST_TO_PHONE` | Caller-id + destination for the call test. |
| `OVERBLAST_TEST_TO_EMAIL` / `OVERBLAST_TEST_DOMAIN` | Destination + agent domain for the email test. |
| `OVERBLAST_TEST_WEBHOOK_URL` | HTTPS endpoint for the webhook round-trip test. |

### CI

`.github/workflows/overblast.yml` runs **build + typecheck** on every push
and PR (no creds needed), and the **read-only test suite** when the
`OVERBLAST_API_KEY` secret is configured. Side-effecting tests do not run
in CI — they require an explicit `OVERBLAST_TEST_ALLOW_SIDE_EFFECTS=1`
which the workflow does not set.

## License

MIT.
