# 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.
This page requires JavaScript. The full API reference is available as plain markdown at /api.md (also at /llms-full.txt) and as OpenAPI 3.1 at /openapi.yaml.