# Client Ops: CRM, Booking & Automations

The Pro, white-label suite that runs an agency's production-to-delivery loop — CRM, tasks, native booking, and an event/schedule-driven automation engine — on the agency's own brand and domain.

**On this page**

- [Overview](#overview)
- [Architecture: the shared module pattern](#architecture-the-shared-module-pattern)
- [CRM — `src/crm.js`](#crm--srccrmjs)
- [Tasks — `src/tasks.js`](#tasks--srctasksjs)
- [Booking — `src/booking.js`](#booking--srcbookingjs)
- [Automation — `src/automation.js`](#automation--srcautomationjs)
  - [Triggers](#triggers)
  - [Actions](#actions)
  - [The scheduler (`schedule` trigger)](#the-scheduler-schedule-trigger)
  - [AI-planned automations](#ai-planned-automations--post-apiautomationsplan)
- [Configuration](#configuration)
- [API surface](#api-surface)
- [Testing](#testing)
- [Gotchas](#gotchas)

## Overview

Client Ops is **off by default** and Pro-gated: `clientOpsEnabled(user, env)` in
[`src/crm.js`](../../src/crm.js) returns true when
`env.CLIENT_OPS_ENABLED === 'true'` **or** `planAtLeast(plan, 'pro')`. Every
management function calls `requireClientOps(user)` first (a `403` otherwise).
The dashboard reads the effective gate from `GET /api/billing` (the response
includes a `clientOps` boolean) so it can show/hide the suite.

Schema lives in migrations
[`007_crm.sql`](../../migrations/007_crm.sql) →
[`010_automation.sql`](../../migrations/010_automation.sql), plus
[`014_automation_schedule.sql`](../../migrations/014_automation_schedule.sql)
for scheduled automations. The canonical UI is the dashboard suite in
[`public/dashboard.html`](../../public/dashboard.html) (not the legacy `/admin`
studio).

## Architecture: the shared module pattern

Every module ([`crm.js`](../../src/crm.js), [`tasks.js`](../../src/tasks.js),
[`booking.js`](../../src/booking.js), [`automation.js`](../../src/automation.js))
follows the same shape — mirroring `scripts.js` / `personas.js`:

- **Workspace-scoped CRUD** — every query filters by `user.workspace.id` (tenant
  isolation; a row from another workspace 404s).
- **Injectable `query`** — each exported fn takes `options = { query = dbQuery }`,
  so tests pass a fake DB and never touch Postgres. See `test/*.test.js`.
- **`isUuid(v)`** guards before any id hits SQL.
- **`httpError(status, msg, expose)`** — typed errors; `expose` controls whether
  the message reaches the client.
- **Pro gating** — `requireClientOps(user)` at the top of every management
  function.
- **No import cycles** — anything that fires automations from another domain
  module (CRM, billing, render worker) uses a **dynamic
  `import('./automation.js')`**, and the call is always best-effort: a failing
  automation never breaks the domain event that fired it.

## CRM — `src/crm.js`

`crm_contacts` + append-only `crm_activity` + `crm_tags`/`crm_contact_tags` +
`meeting_briefs`. Contact statuses: `lead` / `prospect` / `active` / `inactive`.

Key functions:

- `createContact` — `409` on a duplicate email per workspace (enforced by a
  partial unique index on `(workspace_id, lower(email))`). On success it fires
  the **`contact_created`** automation trigger best-effort (dynamic import;
  payload `{ contactId, name, email }`) — a webhook/task failure never fails
  the create.
- `listContacts` — `status` / `q` filters, capped at 200, ordered by
  `last_activity_at`.
- `getContact` — contact + last 200 activity rows + tags.
- `updateContact` — dynamic patch (only the provided fields), re-checks
  ownership via `getContact` first.
- `deleteContact` — **GDPR hard-delete**: removes meeting briefs (which are
  `set null` on the FK) then the contact; activity / tag links cascade.
- `exportContact` — GDPR bundle: the contact + every meeting brief as one JSON.
- `addActivity` — append a timeline row + `touchContact` (bumps
  `last_activity_at`).
- `createTag` / `listTags` / `setContactTag` — workspace-scoped tags; a tag must
  belong to the workspace before it can be attached.
- `recordMeetingBrief` — persists a brief (from [`src/meeting.js`](../../src/meeting.js)),
  and when linked to a contact writes a `meeting_brief` activity row and touches
  the contact. Returns the brief id.
- `listBriefs` — workspace briefs, optionally filtered by contact.

## Tasks — `src/tasks.js`

`tasks` (status `open` / `doing` / `done` / `cancelled`; optional
assignee / contact / campaign refs and a `due_at`). `createTask`, `listTasks`
(filter by status/assignee/contact/campaign; ordered done-last then by due date),
`updateTask`, `deleteTask`, and `seedTasksFromActionItems` — bulk-create open
tasks from a meeting brief's action items (capped at 50, best-effort no-op when
client ops is off). Tasks carry a `source` column (`manual` by default,
`automation` when created by the automation engine).

## Booking — `src/booking.js`

A native, white-label scheduler — bookable meeting types on the agency's own
domain/brand.

- **`generateSlots({ calendar, dateStr, availability, busy, now })`** — a **pure,
  unit-tested** function: weekday availability windows × stepped by duration ×
  filtered by min-notice / max-days-out × buffer-aware conflict exclusion → an
  array of UTC ISO `{ startsAt, endsAt }` slots.
- **DST correctness** — `tzOffsetMinutes(date, tz)` and `zonedWallToUtc(...)`
  use `Intl.DateTimeFormat` with a two-pass guard, so wall time ↔ UTC is correct
  across DST boundaries (tested against EST/EDT).
- **Zero double-booking** — `book()` runs inside `withTransaction`, takes a
  per-assignee `pg_advisory_xact_lock(hashtext(assigneeId))`, re-checks for a
  conflicting event, then inserts the `calendar_events` row + the `bookings`
  row, upserts a CRM contact (the booker), and writes a `booking` activity row.
  The advisory lock only serializes inside a real transaction — under a plain
  autocommit connection it would guard nothing — which is why `book` uses the
  transactional client. Post-commit it fires the `booking_created` automation
  trigger.
- **Management (Pro-gated, workspace-scoped)** — `createCalendar` (slug must be
  unique, `409` otherwise), `listCalendars`, `setAvailability` / `getAvailability`
  (weekday windows in minutes), `listEvents`.
- **Public surface (unauth, by slug)** — `getPublicSlots` (never leaks the
  assignee's identity) and `book` back the unauthenticated
  `GET /api/calendars/:slug/slots` and `POST /api/calendars/:slug/book` routes,
  which are **rate-limited** in `api.js` (in-memory sliding window keyed by
  client IP: 60/min for slots, 10/min for book). `bookingToIcs` /
  `getBookingByToken` back the `.ics` confirmation download.

## Automation — `src/automation.js`

An event→action interpreter over a JSON node graph, with **two firing modes**:

1. **Domain events** — a system module calls
   `triggerAutomation(workspaceId, triggerType, data)` when something happens
   (a booking, a payment, a finished render). Direct-call, synchronous with the
   event, best-effort.
2. **The clock** — a `schedule` trigger fires from an in-process tick loop
   (see [The scheduler](#the-scheduler-schedule-trigger)). This is the durable
   runner for time-based automations; there are still no per-node time-delay /
   wait nodes *inside* a run (a run executes its actions straight through).

Core behavior (`runAutomation`, internal):

- Every run inserts an `automation_runs` row (`status='running'`,
  `trigger_data` = the payload), executes the graph's nodes **sequentially in
  array order**, records a per-node `steps` entry
  (`{ node, type, ok, ...result }`), and stops at the **first failing node**
  (run status `failed`, `error` = the first error). Edges are stored (for the
  visual builder) but not interpreted — there is no branching.
- `total_runs` on the automation is incremented for every run, success or not.
- `triggerAutomation` fires every **active** automation matching
  `(workspace, trigger_type)` and **never throws** back to the caller; each
  automation's failure is contained to its own run record.
- The graph is normalized on write: capped at **50 nodes / 100 edges**
  (`normalizeGraph`).
- `{{field}}` templating (`tmpl`) substitutes **top-level** keys of the trigger
  payload into action config strings (`{{assetUrl}}`, `{{name}}`, …); a missing
  key renders as an empty string. Nested keys are not supported.

Management is Pro-gated CRUD on `/api/automations` plus
`POST /api/automations/:id/run` to test an automation against a sample payload
(body `{ "data": { ... } }`).

### Triggers

`TRIGGER_TYPES` (exported): `booking_created`, `meeting_briefed`,
`payment_completed`, `render_succeeded`, `campaign_approved`,
`contact_created`, `schedule`.

All seven are wired:

| Trigger | Fired from | Notable payload fields |
|---------|------------|------------------------|
| `booking_created` | post-commit in `book` ([`booking.js`](../../src/booking.js)) | booking/contact fields |
| `meeting_briefed` | `persistBrief` ([`api.js`](../../src/api.js)) | brief/contact fields |
| `payment_completed` | `checkout.session.completed` webhook ([`billing.js`](../../src/billing.js)) | payment fields |
| `render_succeeded` | render worker on success ([`render-worker.js`](../../src/render-worker.js)) | `jobId`, `compositionId`, `assetId`, `objectKey`, **`assetUrl`**, `qaScore`, `qaMax`, `qaBand`, `qaPublishable` |
| `campaign_approved` | `approveCampaign` ([`campaigns.js`](../../src/campaigns.js)) | campaign fields |
| `contact_created` | `createContact` ([`crm.js`](../../src/crm.js)) | `contactId`, `name`, `email` |
| `schedule` | the scheduler tick loop | `scheduled: true`, `firedAt`, `automationId` |

**`assetUrl` is durable and externally fetchable.** The `render_succeeded`
payload carries `signedAssetUrl(objectKey)` from
[`src/storage.js`](../../src/storage.js): an HMAC-signed proxy URL
(`${ASSET_PROXY_BASE_URL || 'https://corereflex.com/api/assets'}/<key>?t=<token>`,
token = first 32 hex chars of `HMAC-SHA256("asset:<key>", ASSET_TOKEN_SECRET || JWT_SECRET)`).
It does not expire, which is what lets an external platform (e.g. the social
relay) fetch the render after the automation has completed. The
`publish_social` action templates it via `{{assetUrl}}`.

System triggers fired from CRM / billing / render dynamically
`import('./automation.js')` to avoid static import cycles, and a failure there
never breaks the domain event.

### Actions

`ACTION_TYPES` (exported): `create_task`, `contact_activity`,
`advance_campaign`, `webhook`, `run_recipe`, `publish_social`.
Each node is `{ id, type, config }`; unknown types are skipped, and a
misconfigured node **skips** (recorded in `steps`) rather than failing the run
— only a thrown error (e.g. a webhook 5xx) fails it.

- **`create_task`** — inserts a `tasks` row (`source='automation'`, status
  `open`). `config.title` is templated (default `Follow up`, capped 300 chars);
  `contactId` / `campaignId` link from the payload when they are UUIDs.
- **`contact_activity`** — appends a `crm_activity` row for `data.contactId`
  (skips when absent). `config.activityType` (default `note`) + templated
  `config.body` (capped 4000).
- **`advance_campaign`** — sets `campaigns.status = config.status` for
  `data.campaignId` (skips when either is missing).
- **`webhook`** — POSTs `{ trigger: data }` as JSON to `config.url`.
  **SSRF-guarded**: the URL must pass `safePublicUrl`
  ([`src/net-guard.js`](../../src/net-guard.js)) — https-only, blocks
  loopback / private / link-local / metadata hosts — and the fetch uses
  `redirect: 'manual'` so a 302 can't rebind into the private fleet after the
  check. 10s timeout. Any response ≥ 300 (including an unfollowed redirect)
  **throws a 502 and fails the run** — delivery is verified, not fire-and-forget.
- **`publish_social`** — relays a finished render/asset to the workspace's
  connected social platforms via the GoHighLevel relay in
  [`src/social.js`](../../src/social.js) (dynamic import; see
  [Billing & Packaging](billing.md) for metering). Config:
  `accountIds` (required — skips when empty; chosen at automation-build time),
  `mediaUrl` (templated, **defaults to `{{assetUrl}}`**), `summary` (templated,
  capped 5000), `type` (`post` | `story` | `reel`, default `post`),
  `scheduleDate`. `publish()` meters one `usage:publish` credit **before**
  egress and records a `social_posts` row either way, so a failed relay is
  auditable, not lost. The action runs as a synthetic workspace user
  (`{ id: null, workspace: { id } }`) since there is no human in the loop.
- **`run_recipe`** — scheduled autopilot: fires a programmable creative
  workflow (recipe) unattended. Config: `recipeId` (must be a UUID — skips
  otherwise) + optional `params`. Dynamically imports
  `runScheduledRecipe` from [`src/recipes.js`](../../src/recipes.js), which
  runs the recipe **in the background** (the scheduler tick is never blocked)
  as the recipe's author within its workspace, with `checkpointPolicy` forced
  to `'auto'` so a guided/manual recipe can't pause forever waiting for a
  human. Returns `{ recipeRun: runId, status }` into the step record. This
  pairing — a `schedule` trigger wrapping a `run_recipe` action — **is** the
  Autopilot feature; `GET /api/recipes/schedules` lists exactly those
  automations for the dashboard.

### The scheduler (`schedule` trigger)

Migration [`014_automation_schedule.sql`](../../migrations/014_automation_schedule.sql)
adds `automations.next_run_at timestamptz` plus a partial index
(`automations_schedule_due_idx` on `next_run_at` where
`trigger_type = 'schedule' and status = 'active'`) — the runner's hot path.

The schedule config lives in the existing `trigger_config` jsonb, in one of two
shapes:

```json
{ "everyMinutes": 30 }
{ "dailyAtUtcHour": 9, "dailyAtUtcMinute": 15 }
```

Three functions in [`src/automation.js`](../../src/automation.js):

- **`computeNextRun(schedule, from)`** — pure. `everyMinutes` → `from` + N
  minutes (floored, min 1). `dailyAtUtcHour`/`dailyAtUtcMinute` (clamped to
  0–23 / 0–59) → the next occurrence of that UTC wall time (tomorrow if it has
  already passed today). Anything else → `null` (not scheduled).
- **`runDueScheduled({ query, fetchImpl, now, limit, lock })`** — the tick.
  Selects due rows **across all workspaces**
  (`trigger_type='schedule' and status='active' and next_run_at <= now`,
  ordered by `next_run_at`, default limit 100, clamped 1–500), runs each with
  payload `{ scheduled: true, firedAt, automationId }`, then advances
  `next_run_at` via `computeNextRun` — **even when the run failed**, so a
  broken automation can never hot-loop. **Multi-instance safety**: in
  production (i.e. when using the default pool `query`) it wraps the whole
  tick in a non-blocking session advisory lock
  (`pg_try_advisory_lock(hashtext('corereflex-scheduler'))`); if another
  replica holds it, the tick returns `{ ran: 0, due: 0, skipped: 'locked' }`.
  Tests inject a `query` and run unlocked; `{ lock: false }` opts out
  explicitly.
- **`startScheduler({ intervalMs })`** — the in-process loop. Interval =
  `AUTOMATION_TICK_MS` (default 60 000 ms, floor 5 000 ms). Non-overlapping (a
  slow tick is never re-entered), error-safe, and `unref`'d so it never holds
  the process open. Returns a stop function. **The tick doubles as the job
  reaper**: after `runDueScheduled` it best-effort runs
  `reapStuckGenerationJobs` ([`src/generation-jobs.js`](../../src/generation-jobs.js))
  and `reapDeadRenderJobs` ([`src/render.js`](../../src/render.js)) — orphaned
  `running` generation jobs and crash-looped render jobs (DLQ) are cleaned up
  here, so disabling the scheduler also disables those reapers.

The loop is started at boot in [`src/server.js`](../../src/server.js) (right
after `server.listen`) **unless `AUTOMATION_SCHEDULER=false`** — set that when
a dedicated external cron drives `runDueScheduled` instead. If the DB is
unconfigured, the tick's own query just no-ops.

**Config validation is up-front.** `createAutomation` and `updateAutomation`
reject a `schedule` trigger whose config `computeNextRun` can't resolve
(`400: A schedule trigger needs \`everyMinutes\` or \`dailyAtUtcHour\` …`) —
otherwise the row would save as "active" with a null `next_run_at` and the tick
loop would skip it forever, a silent no-op the UI calls active. `updateAutomation`
also recomputes `next_run_at` whenever the trigger type or config changes, so
the clock stays consistent with the schedule.

### AI-planned automations — `POST /api/automations/plan`

[`src/workflow-planner.js`](../../src/workflow-planner.js) turns a
plain-English goal into a ready-to-create automation. The route (in
[`src/api.js`](../../src/api.js)) is auth-gated and takes `{ "goal": "..." }`
(`400` when empty).

```
POST /api/automations/plan
{ "goal": "when a render finishes, post it to our socials" }

200
{
  "name": "Publish finished renders",
  "triggerType": "render_succeeded",
  "triggerConfig": {},
  "graph": { "nodes": [
    { "id": "n1", "type": "publish_social",
      "config": { "mediaUrl": "{{assetUrl}}", "summary": "New drop" } }
  ] }
}
```

The response is a valid `createAutomation` input — the client POSTs it to
`/api/automations` as-is (the dashboard's builder pre-fills from it).

Implementation:

- `geminiPlanWorkflow` calls Gemini on Vertex (same mock/Vertex seam as the
  other planners): `workflow.plan` prompt from
  [`src/prompts/registry.js`](../../src/prompts/registry.js) with the **real**
  `TRIGGER_TYPES` / `ACTION_TYPES` lists interpolated, JSON response mode,
  temperature 0.3, timeout `WORKFLOW_TIMEOUT_MS` (default 20 s), traced via
  `runTraced` (task `workflow-planning`).
- `normalizeWorkflowPlan` coerces whatever the model returns into a runnable
  graph: unknown trigger types fall back to `contact_created`, unknown action
  types are dropped, actions cap at 10, node ids become `n1…n10`, and the name
  defaults to `Automation: <goal>`.
- `planWorkflow` (the public entry) **never hard-fails**: with Vertex
  unconfigured or the model erroring it degrades to `mockPlanWorkflow` (a
  `contact_created` → `create_task` default) and sets a `degraded` message on
  the response explaining why.

## Configuration

| Env var | Default | Effect |
|---------|---------|--------|
| `CLIENT_OPS_ENABLED` | unset | `'true'` force-enables the whole suite for any plan (otherwise Pro+ only) |
| `AUTOMATION_SCHEDULER` | on | `'false'` disables the in-process tick loop (use when external cron drives `runDueScheduled`; also disables the generation/render job reapers) |
| `AUTOMATION_TICK_MS` | `60000` | Scheduler tick interval (floored at 5 000 ms) |
| `WORKFLOW_TIMEOUT_MS` | `20000` | AI workflow-planner request timeout |
| `ASSET_PROXY_BASE_URL` | `https://corereflex.com/api/assets` | Base of the signed `assetUrl` in `render_succeeded` payloads |
| `ASSET_TOKEN_SECRET` (falls back to `JWT_SECRET`) | — | HMAC key for the signed asset URL — must match between the render worker and the API instance serving `/api/assets` |

See [Configuration](configuration.md) for the full platform env reference.

## API surface

| Route | Module |
|-------|--------|
| `POST/GET /api/crm/contacts`, `…/:id` (GET/PUT/DELETE), `…/:id/export`, `…/:id/activity`, `…/:id/tags` | crm.js |
| `POST/GET /api/crm/tags` | crm.js |
| `POST/GET /api/tasks`, `…/:id` (PUT/DELETE) | tasks.js |
| `POST/GET /api/calendars`, `GET/POST /api/availability`, `GET /api/calendar/events` | booking.js |
| `GET /api/calendars/:slug/slots`, `POST /api/calendars/:slug/book` (public, rate-limited) | booking.js |
| `POST/GET /api/automations`, `…/:id` (GET/PUT/DELETE), `…/:id/run` | automation.js |
| `POST /api/automations/plan` (goal → automation draft) | workflow-planner.js |
| `GET /api/recipes/schedules` (Autopilot: the `run_recipe` schedule automations) | recipes.js |
| `POST /api/campaigns/:id/approve` (fires `campaign_approved`) | campaigns.js |

Example — create a scheduled social publisher:

```
POST /api/automations
{
  "name": "Daily 9am UTC recipe run",
  "status": "active",
  "triggerType": "schedule",
  "triggerConfig": { "dailyAtUtcHour": 9 },
  "graph": { "nodes": [
    { "id": "n1", "type": "run_recipe",
      "config": { "recipeId": "<uuid>", "params": {} } }
  ] }
}

201 { "id": "…", "status": "active", "trigger_type": "schedule",
      "next_run_at": "2026-07-07T09:00:00.000Z", … }
```

Example — test-fire an automation with a sample payload:

```
POST /api/automations/:id/run
{ "data": { "contactId": "<uuid>", "name": "Ada" } }

200 { "ran": "<id>" }
```

`GET /api/automations/:id` returns the automation plus its **last 20 runs**
(`automation_runs`: status, per-node `steps`, first `error`) — the debugging
surface for a misbehaving automation.

## Testing

- [`test/automation.test.js`](../../test/automation.test.js) — the trigger
  interpreter, action nodes, gating + validation.
- [`test/automation-schedule.test.js`](../../test/automation-schedule.test.js) —
  `computeNextRun` (interval / daily-UTC / unrecognized), the up-front schedule
  config rejection, `runDueScheduled` execution + `next_run_at` advance
  (including on failure — the no-hot-loop guarantee).
- [`test/automation-publish-social.test.js`](../../test/automation-publish-social.test.js) —
  `publish_social` registration + the no-`accountIds` skip (no network).
- [`test/workflow-planner.test.js`](../../test/workflow-planner.test.js) —
  plan normalization, prompt wiring, the degrade-to-default path.
- [`test/crm.test.js`](../../test/crm.test.js),
  [`test/tasks.test.js`](../../test/tasks.test.js),
  [`test/booking.test.js`](../../test/booking.test.js),
  [`test/social.test.js`](../../test/social.test.js) — GDPR delete/export,
  DST/slot correctness, conflict exclusion + advisory lock, the GHL relay.

All are pure Node tests with an injected fake `query` (no Postgres, no
network). Run `npm test`.

## Gotchas

- **The graph is a straight line.** Edges are persisted (capped at 100) but the
  interpreter executes nodes sequentially in array order and stops at the first
  thrown error. No branching, no per-node waits — a run's "time" component
  lives entirely in the `schedule` trigger.
- **Skip vs. fail.** A misconfigured node (no `contactId`, unsafe webhook URL,
  empty `accountIds`, non-UUID `recipeId`) records a `skipped` step and the run
  continues; only thrown errors (webhook ≥ 300, DB errors, publish failures)
  fail the run.
- **Interval schedules drift.** `everyMinutes` computes the next run from the
  tick's `now`, not from the previous scheduled time — each cycle can slip by
  up to one tick interval. `dailyAtUtcHour` does not drift.
- **A failing scheduled run stays quiet.** `next_run_at` advances even on
  failure (by design — no hot loop), so a persistently broken automation just
  keeps failing on schedule. Watch `automation_runs` / the runs list in
  `GET /api/automations/:id`.
- **Credits are spent before egress.** `publish_social` meters `usage:publish`
  before the relay call with no refund on failure — a scheduled automation
  publishing to a broken connection burns a credit per run.
- **`AUTOMATION_SCHEDULER=false` turns off more than schedules.** The tick loop
  is also where stuck generation jobs and dead render jobs get reaped; an
  external cron replacement must cover those too (or call `runDueScheduled`
  which does not — the reapers live in `startScheduler`'s tick).
- **Signed asset URLs are cross-process.** The render worker mints `assetUrl`
  from *its* env; the API verifies with *its* env. `ASSET_TOKEN_SECRET` /
  `JWT_SECRET` and `ASSET_PROXY_BASE_URL` must agree across deployments or the
  URL 403s at fetch time.
- **`triggerType` is not validated against `TRIGGER_TYPES`** on create/update
  (any ≤60-char string is stored); only `schedule` gets config validation. A
  typo'd trigger saves fine and simply never fires. The AI planner can't
  produce one (it normalizes), but the raw API can.
- **Advisory locks, two flavors.** Booking uses a **transaction-scoped**
  `pg_advisory_xact_lock` (only meaningful inside `withTransaction`); the
  scheduler uses a **non-blocking session** `pg_try_advisory_lock` held only
  for the claim+advance. Don't swap one pattern for the other.
- **Dynamic imports are load-bearing.** CRM/billing/render → automation, and
  automation → social/recipes, are all dynamic to break module cycles. A static
  import "cleanup" will reintroduce the cycle.

Related: [Billing & Packaging](billing.md) · [API Reference](api-reference.md) ·
[Data Model](data-model.md) · [Configuration](configuration.md) ·
[Render Worker](render-worker.md) ·
[Client Operations (user)](../user/client-operations.md) ·
[Workflows & Automation (user)](../user/workflows-and-automation.md)
