# 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 */;
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 ` 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.` — single decision row updates.
- `runs:decision_id=eq.` — every run for a decision.
- `run_steps:run_id=eq.` — per-step partial output (this is the per-token stream).
- `chat_messages:decision_id=eq.` — chat backlog updates.
### Storage buckets
- `documents` — uploaded supporting files. Path convention: `/-`.
- `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` — 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 | 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: `/-`. |
| `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: \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 (`=eq.`). The Realtime layer broadcasts row-level `INSERT`, `UPDATE`, and `DELETE` events for tables in the `supabase_realtime` publication.
### `decisions:id=eq.`
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.`
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.`
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();
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.`
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; // 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;
async function getDecision(client, id: string): Promise;
async function getDecisionDocuments(client, decisionId: string): Promise;
async function getDecisionSources(client, decisionId: string): Promise;
async function getLatestRun(client, decisionId: string): Promise;
async function getRunSteps(client, runId: string): Promise;
async function getChat(client, decisionId: string): Promise;
// 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;
// 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;
```
### Pipelines
```ts
async function listPipelines(client): Promise;
async function getPipeline(client, id): Promise<{ pipeline; nodes; edges }>;
async function getDefaultPipeline(client): Promise;
```
### 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;
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";
```
Hooks (each subscribes via Realtime under the hood):
```ts
useDecisions(): UseQueryResult;
useDecision(id?: string): UseQueryResult;
useDecisionDocuments(decisionId?: string): UseQueryResult;
useDecisionSources(decisionId?: string): UseQueryResult;
useLatestRun(decisionId?: string): UseQueryResult;
useRunSteps(runId?: string): UseQueryResult;
useChatMessages(decisionId?: string): UseQueryResult;
usePipelines(): UseQueryResult;
usePipelineDetail(id?: string): UseQueryResult<{ pipeline; nodes; edges }>;
useDefaultPipeline(): UseQueryResult;
useAllPipelineNodes(): UseQueryResult