# Decisions AI — Internal API

> Multi-agent decision-support backend, exposed as a Supabase-backed internal API for any MOEI app.
> Create a decision, attach documents, watch a panel of agents (Research → Advocate For → Advocate Against → Judge → Counter-arguments → Chief of Staff) work through it in real time, and read the final A4-ready briefing.

| | |
| --- | --- |
| Project URL | `https://lydtdudifolcdzunlvtu.supabase.co` |
| Project ref | `lydtdudifolcdzunlvtu` |
| Region | `eu-central-1` |
| Anon key | `sb_publishable_e2G3zcb5z_4hS3e3O_czxw_qxZnGHD5` (publishable, browser-safe) |
| SDK | `@moei/decisions-sdk` (TypeScript) |
| Realtime | Supabase Realtime over WebSocket on `decisions`, `runs`, `run_steps`, `chat_messages` |
| Stream transport | Per-token agent output is flushed to row updates every ~1.5s; Realtime ships each `UPDATE`. Chat refinement uses SSE. |
| Auth posture (v1) | Open. RLS is on, every table has a `demo_all` policy of `(true, true)` for `anon, authenticated`. |
| Machine-readable specs | [`/openapi.yaml`](/openapi.yaml), [`/schema.sql`](/schema.sql), [`/realtime.json`](/realtime.json), [`/llms.txt`](/llms.txt), [`/llms-full.txt`](/llms-full.txt) |

## TL;DR

A decision is a row in `public.decisions`. To create one and watch it run:

```ts
import {
  createDecisionsClient,
  createDecision,
  subscribeToRun,
  subscribeToRunSteps,
  getLatestRun,
} from "@moei/decisions-sdk";

const client = createDecisionsClient({
  url: "https://lydtdudifolcdzunlvtu.supabase.co",
  anonKey: "sb_publishable_e2G3zcb5z_4hS3e3O_czxw_qxZnGHD5",
});

const { decision } = await createDecision(client, {
  title: "Approve fibre rollout phase 2",
  question: "Should we approve the second phase of the fibre rollout in Q3?",
  // When the decision originates from the HEOS platform, pass the HEOS
  // user id. It's persisted to decisions.heos_user_id and is what you'll
  // filter on later when rendering "my decisions" inside HEOS.
  heosUserId: heosUser?.id,
});

subscribeToRun(client, decision.id, async (run) => {
  console.log("run.status =", run.status);
  if (run.status === "running") {
    subscribeToRunSteps(client, run.id, (step) => {
      console.log(`[${step.role}] ${step.status}: ${step.output_text?.length ?? 0} chars`);
    });
  }
  if (run.status === "complete") {
    const finalRun = await getLatestRun(client, decision.id);
    console.log("done in", finalRun?.completed_at);
  }
});
```

`createDecision` inserts the row and kicks off the multi-agent worker in one call. The worker survives the browser closing. Per-token progress arrives via Realtime — there is no separate streaming endpoint to consume.

## Quickstart

### 1. Install

```bash
npm i @moei/decisions-sdk @supabase/supabase-js
# Optional, only for the React hooks entrypoint:
npm i @tanstack/react-query react
```

### 2. Construct a client

```ts
import { createDecisionsClient } from "@moei/decisions-sdk";

export const client = createDecisionsClient({
  url: import.meta.env.VITE_SUPABASE_URL,
  anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
});
```

`.env.example` for any consumer app:

```
VITE_SUPABASE_URL=https://lydtdudifolcdzunlvtu.supabase.co
VITE_SUPABASE_ANON_KEY=sb_publishable_e2G3zcb5z_4hS3e3O_czxw_qxZnGHD5
```

### 3. Create a decision and stream live progress

```ts
import {
  createDecision,
  uploadDocument,
  getLatestRun,
  subscribeToRunSteps,
} from "@moei/decisions-sdk";

const { decision } = await createDecision(client, {
  title: "Approve fibre rollout phase 2",
  question: "Should we approve the second phase of the fibre rollout in Q3?",
  context: "Budget envelope is AED 240M. Cabinet wants a recommendation by 2026-05-15.",
  // Whenever the call comes from the HEOS platform, pass the HEOS user id.
  heosUserId: heosUser.id,
});

// Optional: attach supporting documents. Files up to 50 MB; PDF/DOCX/TXT/MD/PNG/JPEG/WEBP.
const file = /* File from <input type="file"> */;
await uploadDocument(client, decision.id, file);

// Wait for a run to exist (typically <1s after createDecision).
let run = await getLatestRun(client, decision.id);
while (!run) {
  await new Promise((r) => setTimeout(r, 250));
  run = await getLatestRun(client, decision.id);
}

const stop = subscribeToRunSteps(client, run.id, (step) => {
  if (step.status === "running") {
    console.log(`[${step.role}] ${step.output_text?.slice(-80) ?? ""}`);
  } else if (step.status === "complete") {
    console.log(`[${step.role}] DONE — ${step.tokens_output} output tokens`);
  } else if (step.status === "failed") {
    console.error(`[${step.role}] FAILED`);
  }
});
// Call stop() when you're done watching.
```

When every step is `complete`, `decisions.final_markdown` is filled and `decisions.status` flips to `complete`. The same fields re-translate into Arabic in the background; subscribe to `decisions` to be notified when `final_markdown_ar` populates.

## Authentication and security posture

### v1: open

- Every table has RLS enabled with a `demo_all` policy: `using (true) with check (true)` for both `anon` and `authenticated` roles. The anon key is therefore a master key for read and write across the entire schema.
- Storage bucket `documents` is publicly readable and the anon key can upload to it.
- The four edge functions are deployed with `--no-verify-jwt` so they accept the anon key (or no auth at all from the same Supabase project domain).
- This is intentional for v1 internal use across MOEI apps. Treat the anon key as a non-secret.

### CORS

Edge functions use `Access-Control-Allow-Origin: *` by default. To lock down a real deployment, set the `ALLOWED_ORIGINS` function secret to a comma-separated list and re-deploy all five edge functions. The repo ships a one-shot script:

```bash
npm run cors:lockdown -- "https://decisions.heos.ae,https://staging.heos.ae"
```

The script wraps `supabase secrets set ALLOWED_ORIGINS=…` and `supabase functions deploy …` for `run-pipeline`, `chat`, `ingest-document`, `suggest-followups`, and `translate`. After lockdown, requests from origins not in the list still get back the first allowed origin (a safe default that won't surprise misconfigured clients). See [`supabase/functions/_shared/cors.ts`](https://github.com/moei/decisions-ai/blob/main/supabase/functions/_shared/cors.ts).

### Future-flipping to per-user RLS

The schema is shaped for it: `decisions.created_by` and an unused `auth.uid()` join are reserved. When you flip:

1. Replace `demo_all` with policies of the form `using (created_by = auth.uid()::text)`.
2. Pass an authenticated `SupabaseClient` into `createDecisionsClient({ supabase: yourAuthedClient })`.
3. Set `decisions.created_by` on insert (the SDK already accepts it on `Decision["Insert"]`).

No SDK or app code changes are otherwise needed.

### Rate limits

Supabase enforces standard project quotas (Realtime: 200 concurrent connections / 500 messages/sec on the Pro plan). The Anthropic-backed edge functions cost real money; budget about **USD 1.50–4.00 per decision** at current Opus 4.7 rates. There is no per-app throttling — burn-rate alerts live in the Supabase dashboard.

## Base URLs and environment

| Resource | URL |
| --- | --- |
| REST (PostgREST) | `https://lydtdudifolcdzunlvtu.supabase.co/rest/v1/` |
| Realtime | `wss://lydtdudifolcdzunlvtu.supabase.co/realtime/v1/websocket` |
| Storage | `https://lydtdudifolcdzunlvtu.supabase.co/storage/v1/` |
| Edge functions | `https://lydtdudifolcdzunlvtu.supabase.co/functions/v1/` |

Every request must carry both an `apikey` header and an `Authorization: Bearer <anon-key>` header. The SDK handles this for you; for `curl` examples, set:

```bash
export DAI_URL=https://lydtdudifolcdzunlvtu.supabase.co
export DAI_KEY=sb_publishable_e2G3zcb5z_4hS3e3O_czxw_qxZnGHD5
```

## Endpoint catalog

A complete one-screen summary of every surface you can touch.

### REST tables (PostgREST, full CRUD via supabase-js)

- `decisions` — top-level decision record. Lifecycle: `draft → queued → running → complete | failed`.
- `documents` — uploaded supporting files. One-to-many with `decisions`.
- `pipelines` — agent pipelines. One default seeded (`Balanced Decision Brief`).
- `pipeline_nodes` — agents in a pipeline. Topologically sorted by edges, then by `sequence`.
- `pipeline_edges` — directed edges between nodes; defines parallelism.
- `runs` — one execution of a pipeline against a decision. Tracks heartbeat for resume.
- `run_steps` — one row per node per run. Carries live partial output.
- `chat_messages` — refinement chat after the briefing is done.
- `translation_jobs` — internal queue for the EN↔AR background translator.
- `decision_overview` (view) — read-only join of `decisions` + latest `runs` + step counts. Use for list pages.

### Edge functions (HTTP)

- `POST /functions/v1/run-pipeline` — kick off / resume the multi-agent worker for a decision (idempotent, fire-and-forget).
- `POST /functions/v1/chat` — stream a refinement chat reply (SSE).
- `POST /functions/v1/ingest-document` — extract text from an uploaded document.
- `POST /functions/v1/suggest-followups` — generate 4 follow-up chat chips for a finished decision.
- `POST /functions/v1/translate` — internal pg_cron worker; not for client use.

### Realtime channels

- `decisions:id=eq.<decision_id>` — single decision row updates.
- `runs:decision_id=eq.<decision_id>` — every run for a decision.
- `run_steps:run_id=eq.<run_id>` — per-step partial output (this is the per-token stream).
- `chat_messages:decision_id=eq.<decision_id>` — chat backlog updates.

### Storage buckets

- `documents` — uploaded supporting files. Path convention: `<decision_id>/<uuid>-<filename>`.
- `generated` — reserved for future server-generated artefacts (currently unused).

## Data model

```mermaid
flowchart LR
  pipelines --has--> pipeline_nodes
  pipelines --has--> pipeline_edges
  decisions --selects--> pipelines
  decisions --has--> documents
  decisions --has--> runs
  runs --has--> run_steps
  pipeline_nodes -.referenced by.-> run_steps
  decisions --has--> chat_messages
  decisions --enqueues--> translation_jobs
  runs --enqueues--> translation_jobs
```

All `id` columns are `uuid` with default `gen_random_uuid()`. All `*_at` columns are `timestamptz`.

### `decisions`

The user-facing record. One row per question.

```mermaid
stateDiagram-v2
  [*] --> draft
  draft --> queued: createDecision()
  queued --> running: run-pipeline picks it up
  running --> running: heartbeat refresh, partial flushes
  running --> complete: Chief of Staff finishes
  running --> failed: any node throws
  failed --> running: pg_cron resume_stale_runs (if heartbeat goes cold)
  complete --> [*]
  failed --> [*]
```

| Column | Type | Null | Notes |
| --- | --- | --- | --- |
| `id` | `uuid` | no | Primary key, `gen_random_uuid()`. |
| `title` | `text` | no | Short noun phrase (≤80 chars). The SDK exposes `extractAutoTitle()` to derive it from a question. |
| `title_ar` | `text` | yes | Arabic translation, populated asynchronously. |
| `question` | `text` | no | Full natural-language question, any length. |
| `question_ar` | `text` | yes | Arabic translation. |
| `context` | `text` | yes | Optional background paragraph the user attached. |
| `context_ar` | `text` | yes | Arabic translation. |
| `scope_frame` | `text` | yes | Auto-generated 150-220 word structured frame produced by the scope refiner agent before the panel runs. Saved on first execution. |
| `scope_frame_ar` | `text` | yes | Arabic translation. |
| `pipeline_id` | `uuid` | yes | FK to `pipelines.id`. Required to run; `createDecision` looks up the default if not given. |
| `status` | `text` | yes | One of `draft`, `queued`, `running`, `complete`, `failed`, `cancelled`. Default `draft`. |
| `current_node_id` | `uuid` | yes | The node currently executing. Reserved; not yet maintained by the server. |
| `final_markdown` | `text` | yes | The Chief of Staff's final A4-ready briefing in markdown. Populated once `status='complete'`. |
| `final_markdown_ar` | `text` | yes | Arabic translation. |
| `language` | `text` | yes | ISO 639-1 of the original input. Default `en`. |
| `created_by` | `text` | yes | Free-form identifier the inserter sets. Reserved for when RLS flips. |
| `heos_user_id` | `text` | yes | Identifier of the HEOS-platform user that created this decision. **Set this whenever the decision originates from the HEOS platform.** Free-form text; null for non-HEOS callers. Indexed (partial, where `heos_user_id is not null`). |
| `suggested_followups` | `jsonb` | yes | `string[]` of 3-5 follow-up chat prompts. Populated by `/suggest-followups`. Reset to `null` on each new run. |
| `created_at` | `timestamptz` | yes | `now()`. |
| `updated_at` | `timestamptz` | yes | `now()`. |
| `completed_at` | `timestamptz` | yes | Set when `status='complete'`. |

```ts
interface Decision {
  id: string;
  title: string;
  title_ar: string | null;
  question: string;
  question_ar: string | null;
  context: string | null;
  context_ar: string | null;
  scope_frame: string | null;
  scope_frame_ar: string | null;
  pipeline_id: string | null;
  status: "draft" | "queued" | "running" | "complete" | "failed" | "cancelled";
  current_node_id: string | null;
  final_markdown: string | null;
  final_markdown_ar: string | null;
  language: string;
  created_by: string | null;
  heos_user_id: string | null; // set when created from the HEOS platform
  suggested_followups: string[] | null;
  created_at: string;
  updated_at: string;
  completed_at: string | null;
}
```

### `runs`

One execution of a pipeline against a decision. Multiple rows can exist for the same decision if you re-ran it.

| Column | Type | Null | Notes |
| --- | --- | --- | --- |
| `id` | `uuid` | no | Primary key. |
| `decision_id` | `uuid` | no | FK to `decisions.id`. |
| `pipeline_id` | `uuid` | yes | Snapshot of which pipeline was used (may differ from `decisions.pipeline_id` if it was edited mid-run). |
| `status` | `text` | yes | `queued`, `running`, `complete`, `failed`, `cancelled`. Default `running`. |
| `started_at` | `timestamptz` | yes | `now()`. |
| `completed_at` | `timestamptz` | yes | Set when terminal. |
| `error` | `text` | yes | Stack trace / error message when terminal-failed. Truncated at 2000 chars. |
| `heartbeat_at` | `timestamptz` | yes | Refreshed every ~5s by the active worker. If older than 25s the run is considered abandoned and `pg_cron` re-kicks it. |
| `worker_id` | `text` | yes | Short opaque id for the worker that currently owns the run. Set/cleared atomically by `claim_pipeline_run()`. |

```ts
interface Run {
  id: string;
  decision_id: string;
  pipeline_id: string | null;
  status: "queued" | "running" | "complete" | "failed" | "cancelled";
  started_at: string;
  completed_at: string | null;
  error: string | null;
  heartbeat_at: string | null;
  worker_id: string | null;
}
```

### `run_steps`

One row per node per run. **This is where the per-token stream lives.** While the agent is generating, the server flushes partial values into `output_text`, `thinking_summary`, and `sources` every ~1.5s (or every ~1500 new chars, whichever comes first). Realtime ships each `UPDATE` to subscribers.

There is a unique index on `(run_id, node_id)`, so concurrent workers racing on the same node are resolved by Postgres.

| Column | Type | Null | Notes |
| --- | --- | --- | --- |
| `id` | `uuid` | no | Primary key. |
| `run_id` | `uuid` | no | FK to `runs.id`. |
| `node_id` | `uuid` | no | FK to `pipeline_nodes.id`. (TS types currently mark this nullable; in practice it is always set.) |
| `role` | `text` | yes | Snapshot of the node's role at run time (e.g. `research`, `chief_of_staff`). |
| `label` | `text` | yes | Snapshot of the node's display label. |
| `sequence` | `int` | yes | Snapshot of the node's `sequence`. |
| `status` | `text` | yes | `pending`, `running`, `complete`, `failed`. Default `pending`. |
| `input` | `jsonb` | yes | Reserved. Currently unused — the input is reconstructed from prior steps. |
| `output_text` | `text` | yes | Live and final text. **Grows monotonically** during streaming. |
| `output_text_ar` | `text` | yes | Arabic translation. |
| `thinking_summary` | `text` | yes | Live thinking output (Anthropic adaptive thinking deltas). Grows monotonically. |
| `tokens_input` | `int` | yes | Set when `status='complete'`. |
| `tokens_output` | `int` | yes | Set when `status='complete'`. |
| `tools_used` | `jsonb` | yes | `Record<string, number>` — name → call count. |
| `sources` | `jsonb` | yes | `WebSource[]` — every web result the agent referenced. Grows monotonically. |
| `started_at` | `timestamptz` | yes | When `status` flipped to `running`. |
| `completed_at` | `timestamptz` | yes | When `status` flipped to `complete` or `failed`. |
| `last_streamed_at` | `timestamptz` | yes | Refreshed on every partial flush; lets clients show "still working" pulses. |

```ts
interface RunStep {
  id: string;
  run_id: string;
  node_id: string;
  role: string | null;
  label: string | null;
  sequence: number | null;
  status: "pending" | "running" | "complete" | "failed";
  input: unknown | null;
  output_text: string | null;
  output_text_ar: string | null;
  thinking_summary: string | null;
  tokens_input: number | null;
  tokens_output: number | null;
  tools_used: Record<string, number> | null;
  sources: WebSource[] | null;
  started_at: string | null;
  completed_at: string | null;
  last_streamed_at: string | null;
}

interface WebSource {
  url: string;
  title: string;
  snippet?: string;
  page_age?: string | null;
  query?: string;
}
```

**Negative space worth knowing about.** `output_text` may be flushed back to `""` and `status` reset to `running` when `pg_cron` re-kicks an abandoned run; the worker upserts the row by `(run_id, node_id)`, restarting the agent from scratch for that node. Be tolerant of length decreasing.

### `chat_messages`

Refinement conversation about a finished decision. Both user and assistant messages are persisted. The assistant row is inserted **once at the end** of the SSE stream — during streaming, deltas come over SSE only, not Realtime.

| Column | Type | Null | Notes |
| --- | --- | --- | --- |
| `id` | `uuid` | no | Primary key. |
| `decision_id` | `uuid` | no | FK to `decisions.id`. |
| `role` | `text` | no | `user` or `assistant`. |
| `content` | `text` | no | Markdown body. |
| `content_ar` | `text` | yes | Arabic translation. |
| `created_at` | `timestamptz` | yes | `now()`. |

### `documents`

Uploaded supporting files. The `documents` Storage bucket is the source of truth for bytes; this table tracks metadata and extracted text.

| Column | Type | Null | Notes |
| --- | --- | --- | --- |
| `id` | `uuid` | no | Primary key. |
| `decision_id` | `uuid` | yes | FK to `decisions.id`. Nullable for forward-compatibility with global library docs. |
| `file_name` | `text` | no | Original filename. |
| `file_path` | `text` | no | Path inside the `documents` Storage bucket: `<decision_id>/<uuid>-<filename>`. |
| `mime_type` | `text` | yes | E.g. `application/pdf`, `image/png`, `text/markdown`. |
| `size_bytes` | `bigint` | yes | Hard upper bound 50 MB enforced by `/ingest-document`. |
| `extracted_text` | `text` | yes | Plain-text extraction. Empty for images (vision agents read the bytes directly). |
| `summary` | `text` | yes | First 400 chars of `extracted_text`. |
| `created_at` | `timestamptz` | yes | `now()`. |

### `pipelines`, `pipeline_nodes`, `pipeline_edges`

Pipeline definitions are mutable. The default seed pipeline is `Balanced Decision Brief` / `موجز قرار متوازن` with these 6 nodes in topological order: Research → Advocate For + Advocate Against (parallel) → Judge → Counter-arguments → Chief of Staff.

`pipeline_nodes`:

| Column | Type | Null | Notes |
| --- | --- | --- | --- |
| `id` | `uuid` | no | |
| `pipeline_id` | `uuid` | no | |
| `role` | `text` | no | One of `research`, `advocate_for`, `advocate_against`, `judge`, `counter`, `chief_of_staff`, `custom`. |
| `label`, `label_ar` | `text` | label NOT NULL | Display name. |
| `description`, `description_ar` | `text` | yes | Tooltip. |
| `system_prompt` | `text` | no | Anthropic system prompt for this agent. |
| `model` | `text` | yes | Default `claude-opus-4-7`. |
| `effort` | `text` | yes | `low`, `medium`, `high`, `xhigh`. Default `xhigh`. |
| `thinking` | `boolean` | yes | Adaptive thinking on/off. Default `true`. |
| `tools` | `jsonb` | yes | `string[]` of tool names: `web_search`, `read_documents`. Default `[]`. |
| `position` | `jsonb` | yes | `{x,y}` for the React Flow editor. |
| `config` | `jsonb` | yes | Free-form; reserved for per-role settings. |
| `sequence` | `int` | yes | Tie-break for topological levels. Default `0`. |

`pipeline_edges`:

| Column | Type | Null | Notes |
| --- | --- | --- | --- |
| `id` | `uuid` | no | |
| `pipeline_id` | `uuid` | no | |
| `source_node_id` | `uuid` | no | |
| `target_node_id` | `uuid` | no | |
| `label` | `text` | yes | Reserved. |
| `condition` | `jsonb` | yes | Reserved for conditional branching. Default `{}`. |

### `translation_jobs`

Internal queue. A `pg_cron` job ticks every minute and calls `/functions/v1/translate`, which fills the `*_ar` / `*_en` columns. Clients read this only to surface "translating in background" hints; do not insert into it directly.

### `decision_overview` (view)

Read-only convenience view (defined in [`schema.sql`](/schema.sql)) that joins `decisions` with its latest run and step counts. Each row is one decision. Use this for "decisions list" pages so you don't N+1 fan out into `runs` and `run_steps`.

```ts
interface DecisionOverview {
  // every column from `decisions`:
  id: string;
  title: string;
  title_ar: string | null;
  question: string;
  question_ar: string | null;
  context: string | null;
  context_ar: string | null;
  scope_frame: string | null;
  scope_frame_ar: string | null;
  pipeline_id: string | null;
  status: "draft" | "queued" | "running" | "complete" | "failed";
  language: string;
  created_by: string | null;
  heos_user_id: string | null;
  suggested_followups: unknown | null;
  created_at: string;
  updated_at: string;
  completed_at: string | null;
  final_markdown: string | null;
  final_markdown_ar: string | null;
  // joined from the latest run, null if no run exists yet:
  run_id: string | null;
  run_status: "pending" | "running" | "complete" | "failed" | null;
  run_started_at: string | null;
  run_completed_at: string | null;
  run_error: string | null;
  run_heartbeat_at: string | null;
  // counts from `run_steps` for that latest run, 0 when run_id is null:
  steps_total: number;
  steps_complete: number;
  steps_running: number;
  steps_failed: number;
}
```

```ts
import { listDecisionOverviews } from "@moei/decisions-sdk";
// every decision the current HEOS user owns, latest first
const rows = await listDecisionOverviews(client, {
  limit: 50,
  heosUserId: heosUser.id,
});
```

```bash
# all decisions for one HEOS user
curl "https://lydtdudifolcdzunlvtu.supabase.co/rest/v1/decision_overview?select=*&heos_user_id=eq.heos-user-42&order=created_at.desc&limit=50" \
  -H "apikey: $SUPABASE_ANON_KEY"
```

## Edge functions

All four are `POST` only, JSON request bodies, JSON or SSE response bodies. They accept the anon key in `apikey` and `Authorization: Bearer …`. CORS is `*` by default; see [Authentication and security posture](#authentication-and-security-posture).

### POST /functions/v1/run-pipeline

Kicks off the multi-agent worker for a decision. Returns immediately (HTTP 202). The actual work runs server-side inside `EdgeRuntime.waitUntil()`, persists incremental progress to `runs` and `run_steps`, and survives both the browser closing and the function's 400 s wall-clock cap (a `pg_cron` job named `resume_stale_runs` re-kicks any run whose heartbeat is older than 25 s).

**Request:**

```json
{ "decision_id": "f3b1e9c2-2a8d-4f3a-9e9f-3a1f1c8d2c0a" }
```

**Response (200/202):**

```json
{ "status": "launched", "decision_id": "f3b1e9c2-..." }
```

`status` is `"launched"` if a fresh worker was started, or `"already-running"` if another worker is currently driving the run with a fresh heartbeat. Both are non-error outcomes; either way, watch progress via Realtime.

**Idempotency.** Concurrent invocations are serialised by a Postgres advisory lock inside the `claim_pipeline_run()` RPC. Calling twice in a row is safe and cheap.

**curl:**

```bash
curl -sS -X POST "$DAI_URL/functions/v1/run-pipeline" \
  -H "apikey: $DAI_KEY" \
  -H "authorization: Bearer $DAI_KEY" \
  -H "content-type: application/json" \
  -d '{"decision_id":"f3b1e9c2-2a8d-4f3a-9e9f-3a1f1c8d2c0a"}'
```

**JS:**

```ts
import { runPipeline } from "@moei/decisions-sdk";
const result = await runPipeline(client, "f3b1e9c2-2a8d-4f3a-9e9f-3a1f1c8d2c0a");
// { status: "launched" | "already-running", decision_id?: string }
```

**Errors:**

| Status | Body | Meaning |
| --- | --- | --- |
| `400` | `{"error":"decision_id required"}` | Missing field. |
| `405` | `method not allowed` | Used GET/PUT etc. |

A "decision not found" or "pipeline missing" condition does **not** fail this endpoint — it accepts the launch and the worker fails async, writing the error to `runs.error`. Watch `runs.status === "failed"` to detect this.

### POST /functions/v1/chat

Streams a refinement chat reply for a finished decision. The endpoint reads `decisions` and the last 20 `chat_messages`, then streams Anthropic Opus 4.7 with thinking enabled and `effort=high`. The user's message is **not** persisted by this endpoint — insert it into `chat_messages` yourself before calling. The assistant's full reply is persisted automatically once the stream completes.

**Request:**

```json
{
  "decision_id": "f3b1e9c2-2a8d-4f3a-9e9f-3a1f1c8d2c0a",
  "message": "Summarise the top three risks the Judge flagged."
}
```

**Response:** `text/event-stream`. Each frame is `data: <json>\n\n`. The final frame is `data: [DONE]`.

Event shapes:

```ts
type ChatStreamEvent =
  | { type: "message_started"; messageId: string }
  | { type: "message_delta"; messageId: string; delta: string }
  | { type: "message_complete"; messageId: string; content: string }
  | { type: "message_failed"; messageId: string; error: string };
```

**Special frames:**

- `event: error\ndata: {...}\n\n` — fatal error mid-stream; the SDK throws.
- `data: [DONE]` — clean termination.

**curl:**

```bash
curl -sS -N -X POST "$DAI_URL/functions/v1/chat" \
  -H "apikey: $DAI_KEY" \
  -H "authorization: Bearer $DAI_KEY" \
  -H "content-type: application/json" \
  -H "accept: text/event-stream" \
  -d '{"decision_id":"f3b1e9c2-...","message":"Summarise the top three risks."}'
```

**JS:**

```ts
import { chat } from "@moei/decisions-sdk";

await chat(client, {
  decisionId: "f3b1e9c2-...",
  message: "Summarise the top three risks the Judge flagged.",
  onEvent: (e) => {
    if (e.type === "message_delta") process.stdout.write(e.delta);
    if (e.type === "message_complete") console.log("\n--- done ---");
    if (e.type === "message_failed") console.error("failed:", e.error);
  },
});
```

**Errors:**

| Status | Body | Meaning |
| --- | --- | --- |
| `400` | `{"error":"decision_id and message required"}` | Missing field. |
| `404` | `{"error":"decision not found"}` | `decision_id` doesn't match any row. |

### POST /functions/v1/ingest-document

Downloads a previously uploaded file from Storage, extracts text (PDF via pdf.js, DOCX via mammoth, plain text directly, images leave `extracted_text` empty so vision agents read bytes inline), and writes the result to `documents.extracted_text` + `documents.summary`. Hard 50 MB upper bound. `uploadDocument()` already calls this for you.

**Request:**

```json
{ "document_id": "1bf0a1c8-..." }
```

**Response (200):**

```json
{ "ok": true, "preview": "First 400 chars of extracted text..." }
```

**Errors:**

| Status | Body | Meaning |
| --- | --- | --- |
| `400` | `{"error":"document_id required"}` | Missing field. |
| `404` | `{"error":"document not found"}` | `document_id` doesn't match any row. |
| `413` | `{"error":"document exceeds 50MB limit","size_bytes":...}` | File too large. |
| `422` | `{"ok":false,"error":"...","preview":""}` | Extraction failed (e.g. corrupt PDF). For images this is suppressed. |
| `500` | `{"error":"download failed"}` | Storage download failed. |

**curl:**

```bash
curl -sS -X POST "$DAI_URL/functions/v1/ingest-document" \
  -H "apikey: $DAI_KEY" \
  -H "authorization: Bearer $DAI_KEY" \
  -H "content-type: application/json" \
  -d '{"document_id":"1bf0a1c8-..."}'
```

**JS:**

```ts
import { ingestDocument } from "@moei/decisions-sdk";
const { preview } = await ingestDocument(client, "1bf0a1c8-...");
```

### POST /functions/v1/suggest-followups

Generates 4 short follow-up chat prompts for a finished decision. Cached on `decisions.suggested_followups`; pass `force: true` to regenerate.

**Request:**

```json
{ "decision_id": "f3b1e9c2-...", "force": false }
```

**Response (200):**

```json
{
  "suggestions": [
    "Draft a press release on this decision",
    "Compare against the 2023 rollout precedent",
    "Summarise the budget implications for Cabinet",
    "What if SEC approval is delayed?"
  ],
  "cached": true
}
```

If the briefing isn't ready yet (`final_markdown` shorter than 120 chars) the endpoint returns `{"suggestions":[],"cached":false}` with HTTP 200 — not an error.

**JS:**

```ts
import { suggestFollowups } from "@moei/decisions-sdk";
const { suggestions } = await suggestFollowups(client, "f3b1e9c2-...");
```

## Realtime channels

All four channels are filtered server-side via the standard PostgREST filter syntax (`<column>=eq.<value>`). The Realtime layer broadcasts row-level `INSERT`, `UPDATE`, and `DELETE` events for tables in the `supabase_realtime` publication.

### `decisions:id=eq.<decision_id>`

Watches one decision row. Fires on every `UPDATE`: status transitions, `final_markdown` populating, Arabic translations arriving.

```ts
import { subscribeToDecision } from "@moei/decisions-sdk";

const off = subscribeToDecision(client, decisionId, (decision) => {
  if (decision.status === "complete") render(decision.final_markdown);
});
```

Raw payload (Supabase realtime channel format):

```json
{
  "schema": "public",
  "table": "decisions",
  "commit_timestamp": "2026-04-26T19:11:02.493Z",
  "eventType": "UPDATE",
  "new": { "id": "...", "status": "complete", "final_markdown": "..." },
  "old": { "id": "...", "status": "running", "final_markdown": null }
}
```

### `runs:decision_id=eq.<decision_id>`

Watches every run for a decision. Use this to detect `runs.status` flipping or to surface `runs.error`.

```ts
import { subscribeToRun } from "@moei/decisions-sdk";
const off = subscribeToRun(client, decisionId, (run, event) => {
  // event = "INSERT" | "UPDATE" | "DELETE"
  if (run.status === "failed") console.error(run.error);
});
```

### `run_steps:run_id=eq.<run_id>`

The per-token stream. `output_text`, `thinking_summary`, `sources`, and `last_streamed_at` are flushed every ~1.5s; you'll receive an `UPDATE` event each time. Each callback gets the **full** row — diff against the previous value if you want char-level deltas.

```ts
import { subscribeToRunSteps } from "@moei/decisions-sdk";

const seen = new Map<string, number>();
const off = subscribeToRunSteps(client, runId, (step) => {
  const prev = seen.get(step.id) ?? 0;
  const next = step.output_text?.length ?? 0;
  if (next > prev) {
    process.stdout.write(step.output_text!.slice(prev));
    seen.set(step.id, next);
  }
});
```

**Negative space.** Status can flip backward (`complete → running`) when `pg_cron` resumes an abandoned run; treat each `UPDATE` independently. `output_text` length can also decrease in that case (the worker restarts the failed node from scratch).

### `chat_messages:decision_id=eq.<decision_id>`

Watches the chat backlog. The user message is inserted by your client; the assistant message is inserted by `/functions/v1/chat` once its SSE stream completes. There is **no** per-token Realtime stream for chat — use SSE for that.

```ts
import { subscribeToChat } from "@moei/decisions-sdk";
const off = subscribeToChat(client, decisionId, (msg, event) => {
  if (event === "INSERT") render(msg);
});
```

## SDK reference

`@moei/decisions-sdk` has two entrypoints. Default (framework-free) and `/react` (TanStack hooks). All examples below assume `client = createDecisionsClient(...)`.

### Client

```ts
function createDecisionsClient(options: {
  url: string;
  anonKey: string;
  realtimeEventsPerSecond?: number; // default 10
  supabase?: SupabaseClient<Database>; // bring your own (e.g. authenticated)
}): DecisionsClient;
```

### Decisions

```ts
async function createDecision(client, input: {
  title: string;
  question: string;
  context?: string;
  pipelineId?: string;        // defaults to is_default=true
  language?: string;          // default "en"
  heosUserId?: string | null; // pass when called from the HEOS platform;
                              // persisted to decisions.heos_user_id
  autoStart?: boolean;        // default true; calls runPipeline immediately
}): Promise<{ decision: Decision; launched: boolean }>;

async function listDecisions(client, options?: {
  limit?: number;       // default 50
  cursor?: string;      // ISO timestamp; rows with created_at < cursor
  status?: string | string[];
  heosUserId?: string;  // filter by decisions.heos_user_id
}): Promise<Decision[]>;

async function getDecision(client, id: string): Promise<Decision>;
async function getDecisionDocuments(client, decisionId: string): Promise<DocumentRow[]>;
async function getDecisionSources(client, decisionId: string): Promise<DecisionSource[]>;
async function getLatestRun(client, decisionId: string): Promise<Run | null>;
async function getRunSteps(client, runId: string): Promise<RunStep[]>;
async function getChat(client, decisionId: string): Promise<ChatMessage[]>;

// Reads from the `decision_overview` view (decision + latest run + step counts):
async function listDecisionOverviews(client, options?: {
  limit?: number;
  cursor?: string;
  status?: string | string[];
  heosUserId?: string;  // filter by decision_overview.heos_user_id
}): Promise<DecisionOverview[]>;

// Single-round-trip variant of createDecision for non-JS clients. Inserts a
// row via the `create_decision_and_run` RPC and returns its id; the caller
// is responsible for invoking /functions/v1/run-pipeline next.
async function createDecisionAndRunRpc(client, input: {
  title: string;
  question: string;
  context?: string | null;
  pipelineId?: string | null;
  language?: string;
  heosUserId?: string | null;
}): Promise<string>;
```

### Pipelines

```ts
async function listPipelines(client): Promise<Pipeline[]>;
async function getPipeline(client, id): Promise<{ pipeline; nodes; edges }>;
async function getDefaultPipeline(client): Promise<Pipeline | null>;
```

### Documents

```ts
async function uploadDocument(client, decisionId: string, file: File): Promise<{
  document: DocumentRow;
  preview: string;
}>;
```

### Edge function wrappers

```ts
async function runPipeline(client, decisionId: string, signal?: AbortSignal): Promise<{
  status: "launched" | "already-running";
  runId?: string;
  decision_id?: string;
}>;

async function chat(client, options: {
  decisionId: string;
  message: string;
  signal?: AbortSignal;
  onEvent: (e: ChatStreamEvent) => void;
}): Promise<void>;

async function ingestDocument(client, documentId: string, signal?: AbortSignal): Promise<{
  ok: true;
  preview?: string;
}>;

async function suggestFollowups(client, decisionId: string, force?: boolean, signal?: AbortSignal): Promise<{
  suggestions: string[];
  cached: boolean;
}>;
```

### Realtime subscriptions

All return an `Unsubscribe` function. Channel names are random per call so the same id can be subscribed to from multiple components.

```ts
function subscribeToDecision(client, decisionId, onUpdate: (d: Decision) => void): Unsubscribe;
function subscribeToRun(client, decisionId, onChange: (r: Run, e: "INSERT"|"UPDATE"|"DELETE") => void): Unsubscribe;
function subscribeToRunSteps(client, runId, onChange: (s: RunStep, e) => void): Unsubscribe;
function subscribeToChat(client, decisionId, onChange: (m: ChatMessage, e) => void): Unsubscribe;
```

### React entrypoint

```ts
import { DecisionsProvider, useDecision, useRunSteps, useChatMessages } from "@moei/decisions-sdk/react";

<DecisionsProvider client={createDecisionsClient(...)}>
  <App />
</DecisionsProvider>
```

Hooks (each subscribes via Realtime under the hood):

```ts
useDecisions(): UseQueryResult<Decision[]>;
useDecision(id?: string): UseQueryResult<Decision>;
useDecisionDocuments(decisionId?: string): UseQueryResult<DocumentRow[]>;
useDecisionSources(decisionId?: string): UseQueryResult<DecisionSource[]>;
useLatestRun(decisionId?: string): UseQueryResult<Run | null>;
useRunSteps(runId?: string): UseQueryResult<RunStep[]>;
useChatMessages(decisionId?: string): UseQueryResult<ChatMessage[]>;
usePipelines(): UseQueryResult<Pipeline[]>;
usePipelineDetail(id?: string): UseQueryResult<{ pipeline; nodes; edges }>;
useDefaultPipeline(): UseQueryResult<Pipeline | null>;
useAllPipelineNodes(): UseQueryResult<Map<string, PipelineNode[]>>;
useLocalState<T>(key: string, initial: T): readonly [T, (v: T) => void];
useElapsed(startIso?: string | null): number;
```

## Recipes

### Create a decision with documents and stream progress

```ts
import {
  createDecision,
  uploadDocument,
  getLatestRun,
  subscribeToRunSteps,
  subscribeToDecision,
} from "@moei/decisions-sdk";

const { decision } = await createDecision(client, {
  title: "Approve fibre rollout phase 2",
  question: "Should we approve the second phase of the fibre rollout in Q3?",
  autoStart: false, // upload docs first
});

for (const file of fileList) {
  await uploadDocument(client, decision.id, file);
}

await runPipeline(client, decision.id);

let run = await getLatestRun(client, decision.id);
while (!run) {
  await new Promise((r) => setTimeout(r, 250));
  run = await getLatestRun(client, decision.id);
}

const lastSeen = new Map<string, number>();
const offSteps = subscribeToRunSteps(client, run.id, (step) => {
  const prev = lastSeen.get(step.id) ?? 0;
  const next = step.output_text?.length ?? 0;
  if (next > prev) {
    process.stdout.write(`[${step.role}] ${step.output_text!.slice(prev)}`);
    lastSeen.set(step.id, next);
  }
});

const offDecision = subscribeToDecision(client, decision.id, (d) => {
  if (d.status === "complete") {
    offSteps();
    offDecision();
    console.log("\n\n--- FINAL ---\n", d.final_markdown);
  }
});
```

### Resume an interrupted run

You don't have to do anything. The server-side `pg_cron` job `resume_stale_runs` re-kicks any run whose `heartbeat_at` is older than 25 s; the worker reuses completed `run_steps` and only restarts what's incomplete. From the client's perspective, Realtime continues to deliver `UPDATE`s on the same run row.

If you want to force a resume immediately, just call `runPipeline(client, decisionId)` again — it's idempotent.

### Build a "decisions list" page with live status badges

```tsx
import { useDecisions, useLatestRun } from "@moei/decisions-sdk/react";

function DecisionsList() {
  const { data: decisions } = useDecisions();
  return (
    <ul>
      {decisions?.map((d) => <Row key={d.id} decision={d} />)}
    </ul>
  );
}

function Row({ decision }) {
  const { data: run } = useLatestRun(decision.id);
  return <li>{decision.title} — {decision.status} ({run?.status})</li>;
}
```

The `useLatestRun` hook sets up a Realtime subscription per row. For lists over a few hundred rows, prefer subscribing to `decisions` only and rendering `decision.status` directly to keep the WebSocket connection count under the project quota.

### Render "my decisions" inside the HEOS platform

When you're embedding inside HEOS, always tag rows with the HEOS user id on creation and filter on it when listing. The `decision_overview` view already exposes `heos_user_id` so the per-user list takes a single round-trip:

```tsx
import {
  useDecisionsClient,
} from "@moei/decisions-sdk/react";
import {
  createDecision,
  listDecisionOverviews,
} from "@moei/decisions-sdk";
import { useQuery } from "@tanstack/react-query";

function HeosMyDecisions({ heosUserId }: { heosUserId: string }) {
  const client = useDecisionsClient();
  const { data: rows } = useQuery({
    queryKey: ["decisions", "heos", heosUserId],
    queryFn: () =>
      listDecisionOverviews(client, { heosUserId, limit: 100 }),
  });
  return (
    <ul>
      {rows?.map((r) => (
        <li key={r.id}>
          {r.title} — {r.status} ({r.steps_complete}/{r.steps_total})
        </li>
      ))}
    </ul>
  );
}

// And on creation, always pass the HEOS user id through:
async function newHeosDecision(
  client: ReturnType<typeof useDecisionsClient>,
  heosUserId: string,
  title: string,
  question: string,
) {
  const { decision } = await createDecision(client, {
    title,
    question,
    heosUserId, // ← critical: this is what links the row back to HEOS
  });
  return decision.id;
}
```

Equivalent raw REST query, useful from non-JS clients:

```bash
curl "https://lydtdudifolcdzunlvtu.supabase.co/rest/v1/decision_overview?select=*&heos_user_id=eq.$HEOS_USER_ID&order=created_at.desc&limit=100" \
  -H "apikey: $SUPABASE_ANON_KEY"
```

### Refine a finished decision via chat

```ts
import { chat } from "@moei/decisions-sdk";

// Persist the user's message so it appears in the backlog for everyone.
await client.supabase.from("chat_messages").insert({
  decision_id: decisionId,
  role: "user",
  content: userText,
});

// Stream the assistant reply. The reply is persisted automatically when
// the stream completes; subscribers via subscribeToChat will see it.
let buffer = "";
await chat(client, {
  decisionId,
  message: userText,
  onEvent: (e) => {
    if (e.type === "message_delta") buffer += e.delta;
    if (e.type === "message_complete") console.log("done:", buffer);
  },
});
```

## Errors and retries

### Canonical error envelope

Every edge function returns `{"error": "..."}` with the relevant HTTP status. The SDK wraps these in a thrown `Error` with a message of the shape `<endpoint> failed (<status>): <body slice>`.

### When to retry

| Endpoint | Retry on | Don't retry on |
| --- | --- | --- |
| `run-pipeline` | Network error, 5xx. Idempotent — safe to spam. | 400 (fix the body), 404 (decision really doesn't exist). |
| `chat` | Network error mid-stream. Idempotent — the next call sees the user's prior message in `chat_messages` and continues. | 400, 404. |
| `ingest-document` | Network error, 5xx, transient 422. | 413 (file too large; user must re-upload smaller), 404. |
| `suggest-followups` | Network error, 5xx. | 400, 404. |
| Supabase REST/Realtime | Network error, 5xx. supabase-js auto-reconnects Realtime. | 4xx. |

### What `pg_cron` covers automatically

- Any run whose `heartbeat_at` is older than 25 s is considered abandoned and re-kicked.
- Translation jobs are processed every minute by the `translate` edge function.

You do **not** need to retry `run-pipeline` from the client to deal with edge-function timeouts — the cron job does it for you.

## Versioning and changelog

- The SDK follows SemVer.
- Schema changes that drop or rename a column are major. Adding a column is a minor.
- Edge function request/response shapes are append-only within a major version.
- Changelog: see `CHANGELOG.md` in the SDK package.

The current version is `0.x` — pin minor versions until `1.0`.

## Glossary

- **Decision.** The user's question + context + the chosen pipeline. One row in `decisions`.
- **Run.** One execution of a pipeline against a decision. Multiple runs can exist if you re-ran. One row in `runs`.
- **Run step.** One execution of one node within one run. Carries the live partial output. One row in `run_steps`.
- **Pipeline.** A directed acyclic graph of agents. Topologically sorted at run time.
- **Node.** One agent in a pipeline. Has a `role`, a `system_prompt`, a `model`, and a `tools` list.
- **Scope frame.** A 150-220 word structured restatement of the decision (options, stakeholders, success criteria) auto-generated before the panel runs. Cached in `decisions.scope_frame`.
- **Chief of Staff.** The final synthesising node whose output becomes `decisions.final_markdown`.
- **Partial flush.** The 1.5-second cadence at which the `run-pipeline` worker writes accumulated `output_text`, `thinking_summary`, and `sources` to `run_steps`. Each flush triggers a Realtime `UPDATE`.
- **Heartbeat.** `runs.heartbeat_at`, refreshed every ~5s by the active worker. If it lapses past 25s the run is considered abandoned and re-kicked by `pg_cron`.
- **Resume.** A re-kick of an abandoned run. Reuses every `run_steps` row whose `status='complete'`; restarts the rest.

## Machine-readable specs

- [`/openapi.yaml`](/openapi.yaml) — OpenAPI 3.1 covering the four edge functions.
- [`/schema.sql`](/schema.sql) — `pg_dump --schema-only` of the `public` schema.
- [`/realtime.json`](/realtime.json) — every Realtime channel: name pattern, filter, table, payload shape.
- [`/llms.txt`](/llms.txt) — short index per [llmstxt.org](https://llmstxt.org).
- [`/llms-full.txt`](/llms-full.txt) — byte-identical copy of this document.
