# Synapse Applications

> Build a new Synapse App as an OCI image and publish it to the Datamaker registry so the workspace can install it via the App Store. Use when the user asks to create/scaffold a Synapse App, sub-app, plugin, iframe app, or embedded dashboard, or mentions synapse-app.yaml, /plugins registry, apiVersion synapse.datamaker.io/v1, or the iframe bridge. Supports React, Vue, Next.js, Nuxt 3, Gradio, Streamlit, Static HTML, Django, FastAPI (priority in that order).

- Skill: `datamaker-kr/synapse-applications` (Agent Skill, multi-file: 13 files)
- Install (CLI): `npx skillmds@latest add datamaker-kr/synapse-applications`
- Raw SKILL.md: https://api.skillmd.com/api/skills/datamaker-kr/synapse-applications/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: datamaker-kr (https://skillmd.com/u/datamaker-kr)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/datamaker-kr/synapse-applications

---


# Synapse Applications

A Synapse App is a containerized HTTP app the host loads in an iframe. The platform discovers it by **pulling an OCI image** whose config carries a `io.synapse.app.manifest` label - the full `synapse-app.yaml` encoded as base64 YAML. There is no `sub-apps/` directory anymore. There is no compose-file rewrite. Apps live in the registry; the workspace pulls and runs them.

Default to React (Vite+TS) for greenfield. Use Next.js when the source is already Next.js or SSR/next-intl/app-router handlers are required. Priority order: React (Vite+TS), Vue 3 (Vite+TS), Next.js, Nuxt 3, Gradio, Streamlit, Static HTML, Django, FastAPI. Ask once if ambiguous.

## Source layout

App source can live anywhere - a separate repo, `~/projects/<slug>/`, or a temp dir. The host's monorepo no longer holds individual app source. Minimum tree:

```
<slug>/
  synapse-app.yaml    # manifest, single source of truth
  Dockerfile
  <framework source>
```

Slug: `^[a-z][a-z0-9-]{1,62}$`. The slug is the app's identity inside the workspace; pick something stable.

## Workflow

1. Confirm framework (default React).
2. `mkdir <slug> && cd <slug>`.
3. Copy `templates/synapse-app.yaml.tmpl` -> `synapse-app.yaml`; fill slots. **Set `runtime.image: registry.local.datamaker.io/synapse_apps/<slug>:<version>`.**
4. Write Dockerfile + entry files from `references/<framework>.md`.
5. Inject `templates/bridge-snippet.html` into the app's HTML head.
6. Implement `/health` returning 200.
7. Build with the manifest baked into a label, then push:
   ```bash
   bash templates/publish.sh <slug> 0.1.0
   ```
   That helper runs:
   ```bash
   MANIFEST_B64=$(base64 -w0 synapse-app.yaml)
   docker build \
     --label "io.synapse.app.manifest=${MANIFEST_B64}" \
     -t registry.local.datamaker.io/synapse_apps/<slug>:<version> \
     -t registry.local.datamaker.io/synapse_apps/<slug>:latest .
   docker push registry.local.datamaker.io/synapse_apps/<slug>:<version>
   docker push registry.local.datamaker.io/synapse_apps/<slug>:latest
   ```
8. Open the App Store at `http://localhost:4000`. The new app appears as a card. Click **Install**.
9. Visit `http://localhost:3000/apps`, press **Start** on the installed app, verify it reaches `healthy`.
10. Optional: `pnpm dlx ajv-cli validate -s schemas/synapse-app.schema.json -d synapse-app.yaml` (against the host repo's schema).

## Iterating on a published app

Until the `@synapse/cli` lands with a `synapse dev` mode (host-loopback hot reload), every code change requires a rebuild + repush + reinstall. To repush a fresh build over an existing tag:

```bash
bash templates/publish.sh <slug> 0.1.1   # bump SemVer
# then in the App Store: uninstall old, install new
```

Floating `:latest` works too; the workspace re-pulls on install. Hot reload inside the workspace is not yet supported - run the dev server on your host (`pnpm dev`) for fast iteration, then publish when stable.

## Manifest rules

Full contract: `docs/apps/manifest.md`, `schemas/synapse-app.schema.json`.

- `apiVersion: synapse.datamaker.io/v1` (frozen).
- `kind: SynapseApp`.
- `metadata.name`: slug; appears as the install identity. `metadata.version`: SemVer.
- **`runtime.kind: image`** (the only supported value). `runtime.image: registry.local.datamaker.io/synapse_apps/<slug>:<version>`.
- `network.ports.public`: the container port. The runner does NOT publish a host port - apps are reached only via the platform's reverse proxy (`/api/plugins/apps/<slug>/proxy/`). platform-api connects to `synapse-app-<slug>:<port>` over the docker network.
- `network.public_url`: still required by the schema (`^https?://.+/$`), but the platform overrides it server-side and returns `/api/plugins/apps/<slug>/proxy/` to the workspace UI. Treat this field as informational - put `http://localhost:<port>/` so static validation passes.
- `network.health.path`: required, starts with `/`. The host probes `http://synapse-app-<slug>:<port><path>` from inside the platform-api container.
- `runtime.volumes`: optional bind mounts. Array of `{host, container, readonly?}`. Translated by the runner into `-v host:container[:ro]`. `readonly` defaults to `true`. Use for large host-side caches (datasets, model weights) that should not be baked into the image. Single-tenant LAN contract; no allowlist - do not install untrusted images.
- `iframe.sandbox`: minimum `allow-scripts allow-same-origin`.
- `capabilities.auth/theme/locale`: default `none`; opt in explicitly. `auth: required` is not fully wired yet (`docs/apps/auth.md`).
- `capabilities.scopes`: GraphQL capability gateway scopes (`<ns>:<verb>`, ns in `projects|datasets|jobs|models|files|users`, verb in `read|write|self`). Omit or `[]` for apps that do not use the gateway. See `docs/apps/capabilities.md` + `backend/GRAPHQL.md`. **Gateway is very early; expect scope catalog and manifest shape to shift. Re-check docs before declaring scopes.**

## Label encoding

`io.synapse.app.manifest` accepts:

- **Canonical: base64-encoded YAML.** Robust against multi-line YAML and shell quoting.
- **Plain YAML/JSON.** Accepted as a fallback for one-line manifests.

`templates/publish.sh` always uses base64. Multi-line LABELs in a Dockerfile are fragile; bake the b64 string at build time via `--label`.

## Reverse proxy contract

Apps run behind a reverse proxy at `/api/plugins/apps/<slug>/proxy/` on the workspace origin (no host port published). This is the only browser-facing path. Two consequences:

1. **Sub-path mounting.** Apps are served at the prefix above. The proxy strips that prefix on the way upstream, so your code sees clean paths (`/`, `/health`, `/api/foo`). The original prefix is forwarded as `X-Forwarded-Prefix` and exported as env vars (see below) for apps that need to construct prefixed URLs themselves.

2. **Body rewriting.** The proxy rewrites HTML and JS responses in-memory:
   - HTML attribute values starting with `/` (`src`, `href`, `action`, `formaction`, `srcset`, `poster`, `data`) get the prefix prepended.
   - JS `import "/x"` / `from "/x"` / `import("/x")` specifiers get the prefix prepended (in both `.js` responses and inline `<script>` blocks within HTML).
   - **Not rewritten:** `fetch("/x")`, `new URL("/x")`, runtime-constructed paths in JS, JSON response bodies, image manifests. Apps must read the env vars below for those.

### Env exports per container

The runner sets these on every app container at start time:

| Var | Value | Used by |
|-----|-------|---------|
| `SYNAPSE_APP_ROOT_PATH` | `/api/plugins/apps/<slug>/proxy` | canonical, framework-agnostic |
| `SYNAPSE_APP_PORT` | manifest's `network.ports.public` | server bind hint |
| `GRADIO_ROOT_PATH` | same as `SYNAPSE_APP_ROOT_PATH` | Gradio honors this natively |
| `BASE_PATH` | same | generic |
| `BASE_URL` | same with trailing slash | generic |
| `PUBLIC_URL` | same | generic (CRA, some build tools) |

### Framework-specific wiring

- **Gradio**: `GRADIO_ROOT_PATH` is honored automatically by `gr.Blocks().launch()` and by `mount_gradio_app(...)`. No code change needed in most cases.
- **FastAPI**: `app = FastAPI(root_path=os.environ.get("SYNAPSE_APP_ROOT_PATH", ""))`.
- **Vite**: `vite.config.ts` -> `defineConfig(() => ({ base: process.env.SYNAPSE_APP_ROOT_PATH ? process.env.SYNAPSE_APP_ROOT_PATH + '/' : '/', server: { hmr: { path: process.env.SYNAPSE_APP_ROOT_PATH + '/__hmr', clientPort: 3000 } } }))`. The `server.hmr` block is required for HMR to work behind the proxy; without it the app still renders, but Vite's WebSocket connection fails (visible as `[vite] failed to connect to websocket` in the browser console).
- **Next.js**: `basePath` in `next.config.js` is build-time only. Either bake it (`NEXT_PUBLIC_BASE_PATH=...` at build, then `basePath: process.env.NEXT_PUBLIC_BASE_PATH`) and tag the image per workspace, or rely on the proxy's HTML rewriter for static asset URLs.
- **Nuxt 3**: `app.baseURL` in `nuxt.config.ts`, set from env in the config.
- **Streamlit**: `--server.baseUrlPath="$SYNAPSE_APP_ROOT_PATH"` flag.
- **Django**: `FORCE_SCRIPT_NAME = os.environ.get("SYNAPSE_APP_ROOT_PATH", "")` in settings, plus `STATIC_URL` / `MEDIA_URL` adjusted accordingly.
- **Static HTML / nginx**: ensure all asset URLs are relative (no leading `/`) or set a `<base href="...">` reading the env at template render time.

### Known limitation

**Vite HMR WebSocket** doesn't auto-discover the proxy path. The Vite client computes its WS URL from its own `server.hmr.*` config and ignores the proxy entirely. App fix: set `server.hmr.path` + `clientPort` as above. Alternative: ignore HMR (the published image is dev-server-mode but you don't iterate inside the workspace - you republish the image).

## Health endpoint

Mandatory. At `network.health.path` (usually `/health`). 200 = ready. No DB, no external fetches, no auth. Response body ignored. The platform polls `http://synapse-app-<slug>:<port><path>` from inside its own container - your container DNS name is `synapse-app-<slug>`, not your slug.

## Bridge

Ship `templates/bridge-snippet.html` in every app. Envelope `{synapse:1,id,ts,type,from,replyTo?,payload?}` per `docs/apps/bridge.md`. Behavior: posts `synapse:ready` on load, handles `synapse:theme` (toggles `.dark`), stashes `synapse:locale`/`synapse:user` on `window.__synapse`, emits debounced `synapse:resize`. Host runtime is partial; unknown types drop silently per spec - the snippet is safe to ship now.

## GraphQL capability gateway (early / provisional)

For Synapse domain reads (projects, datasets, jobs, users) prefer the capability gateway over bespoke REST. SDK surface: `client.query({ documentId?, query?, variables? })` from `@synapse/app-sdk`. Pilot queries: `me`, `projects(page)`, `project(id)`, `datasets(page)`, `dataset(id)`, `jobs(page)`, `job(id)`. No mutations yet. Scopes resolved server-side from the manifest. Full plan: `backend/GRAPHQL.md`. Manifest declares scopes under `capabilities.scopes`. **Changes often** - re-read `docs/apps/capabilities.md`, `docs/apps/bridge.md`, and `backend/GRAPHQL.md` before wiring.

## Federation (data:* scopes)

Sub-apps never talk to Postgres/S3/Snowflake/ES/Kafka/HTTP-JSON directly. The data federation gateway at `/plugins/data` (REST) and the grafted GraphQL root fan out to all external sources. DuckDB is the in-process engine. Full plan: `backend/FEDERATION.md`.

| Scope | For |
|-------|-----|
| `data:connections:read` | Read Connection metadata. |
| `data:datasets:read` | Read Dataset metadata + lineage. |
| `data:preview` | Call `datasetPreview` (row_cap-bounded). |
| `data:query` | Call `runQuery` with a trusted `documentId` (prod) or raw `sql` (dev only). |
| `data:query:write` | **Recognised but locked.** Phase 4+. |
| `data:admin:registry` | Catalog editor (`data-catalog` only). |

SDK surface: `client.data.query` / `client.data.preview` / `client.data.listConnections` / `client.data.listDatasets`. Bridge messages in `docs/apps/bridge.md`.

## Design tokens

Per `docs/apps/design.md`: Pretendard Variable, primary `#2563eb` (hover `#1d4ed8`, active `#1e40af`), slate neutrals, dark mode mandatory, radii 8/6/12/4px (buttons/inputs/cards/badges), motion 100-200ms `cubic-bezier(0.4,0,0.2,1)`. Chart palette: `#3b82f6 #22c55e #ef4444 #f59e0b #8b5cf6 #06b6d4 #ec4899 #64748b`. Copy the variable block from `docs/apps/design.md` section 13.1 into the app's stylesheet.

## Layout ownership - do NOT build chrome

The host renders the iframe inside its own shell. Duplicating that chrome produces double headers, double logos, two copyright lines, two theme switchers stacked.

| Element | Owner |
|---------|-------|
| App logo / brand mark | Host. |
| Top global nav bar / header | Host. |
| Footer / copyright / legal | Host. |
| Theme switcher | Host via `synapse:theme`. |
| Locale switcher | Host via `synapse:locale`. |
| User avatar / account menu | Host. |
| In-app navigation between sections | Sub-app. Use a left sidebar. |
| In-content tabs / breadcrumbs | Sub-app. |
| Page title + page actions | Sub-app (inside content area). |

If a ported source has `Header.tsx` + `Footer.tsx` with logo / nav / footer, delete both. Move nav into a `Sidebar`. Drop the logo. Remove theme/locale dropdowns - subscribe via the bridge.

## Default toolchain

- **Base image** for Node apps: current Node LTS on Alpine (`node:22-alpine`). `corepack enable && corepack prepare pnpm@9 --activate` in the Dockerfile.
- **Package manager**: pnpm. For Nuxt / shadcn stacks ship an `.npmrc` with `shamefully-hoist=true` + `auto-install-peers=true`.
- **Production target**: linux/amd64. macOS Docker Desktop (VirtioFS) is dev-only; do not design around Mac-specific bind-mount behavior.

## Secrets and runtime config

The manifest schema does **not** allow `runtime.env`. The runner only injects the proxy/root-path vars listed above and never forwards arbitrary env keys from the manifest. So how do you get `OPENAI_API_KEY`, DB credentials, signing keys, etc. into a published image?

**Pattern: bind-mount a host-side secrets directory.** Declare it under `runtime.volumes` with `readonly: true`, then read it at startup. Convention:

```yaml
runtime:
  kind: image
  image: registry.local.datamaker.io/synapse_apps/<slug>:0.1.0
  volumes:
    - host: /home/juho/.config/<slug>
      container: /etc/<slug>
      readonly: true
    - host: /home/juho/.local/share/<slug>   # optional: writable persistent data dir
      container: /data
      readonly: false
```

Drop `*.env` files into `/home/juho/.config/<slug>/` on the docker host. The app loads them on startup, e.g. (Python):

```python
from pathlib import Path
from dotenv import dotenv_values

secrets_dir = Path("/etc/<slug>")
env = {}
for p in sorted(secrets_dir.glob("*.env")) if secrets_dir.is_dir() else []:
    env.update({k: v for k, v in dotenv_values(p).items() if v is not None})
OPENAI_API_KEY = env.get("OPENAI_API_KEY") or os.environ.get("OPENAI_API_KEY") or ""
```

`chmod 600` the file on the host so other users on the box can't read it. The bind mount is read-only inside the container.

**Non-secret config** (model names, base URLs, feature flags) goes in the Dockerfile via `ENV` or in code defaults. There is no manifest mechanism for it today.

**Single-tenant LAN contract.** Any installed image gains the declared host paths. Do not install untrusted images, and do not bind paths broader than the app needs.

## Calling host services (Ollama, LM Studio, local DBs)

App containers attach to the `synapse-experimental_default` docker network, which means:
- **Other compose services** (`synapse-nginx`, `platform-api`, etc.) reach by service name (`http://synapse-nginx:80`, `http://platform-api:8000`).
- **Other installed apps** reach by container name (`http://synapse-app-<slug>:<port>`).
- **The docker host itself** is reachable as `host.docker.internal` (the runner injects `--add-host=host.docker.internal:host-gateway` for every installed app, so this works on Linux too — not just macOS / Windows Docker Desktop).

Use cases that justify host-internal: a local LLM runtime like Ollama at `:11434`, LM Studio at `:1234`, a host-side Postgres / DuckDB on a non-published port. Drop the URL into the secrets `.env`:

```
OLLAMA_BASE_URL=http://host.docker.internal:11434
```

Never publish a host port for cross-app traffic — go through the compose network instead.

## Per-framework CMD

| Framework        | Command                                                        |
|------------------|----------------------------------------------------------------|
| React/Vue (Vite) | `pnpm exec vite --host 0.0.0.0 --port <port>`                  |
| Next.js          | `pnpm exec next start --hostname 0.0.0.0 --port <port>` (build first) |
| Nuxt 3           | `pnpm exec nuxi preview --host 0.0.0.0 --port <port>` (build first) |
| Gradio           | `uvicorn app:app --host 0.0.0.0 --port <port>`                 |
| Streamlit        | `streamlit run app.py --server.address 0.0.0.0 --server.port <port>` |
| Static HTML      | nginx serves files                                             |
| Django           | `gunicorn -b 0.0.0.0:<port> wsgi:application`                  |
| FastAPI          | `uvicorn app:app --host 0.0.0.0 --port <port>`                 |

Always bind `0.0.0.0`. `127.0.0.1` is unreachable from outside the container. Published images are production-shape (no `--reload`); hot reload happens on the dev's host before publish.

## Port registry (currently taken by the workspace)

| Port | Owner |
|------|-------|
| 3000 | host frontend |
| 4000 | App Store (`appstore`) |
| 8000 | platform-api (FastAPI) |
| 8200 | local Synapse Django (via `synapse-nginx`) |

The workspace claims 3000 / 4000 / 8000 / 8200 on the host. Apps don't get a host port at all - they only listen inside the `synapse-experimental_default` docker network and are reached via the platform reverse proxy. Pick anything for `network.ports.public`; collisions only matter inside the container's own port namespace, not host-side. Common defaults: `7860` (Gradio), `8501` (Streamlit), `5173` (Vite), `3000` (Next/Nuxt), `8080` (FastAPI/Django).

## Common pitfalls

- Missing trailing slash on `public_url` (schema: `^https?://.+/$`). Note: the value is informational; the platform overrides it server-side with the proxy URL.
- `runtime.kind: docker-compose` instead of `image`. Only `image` is supported.
- Server bound to `127.0.0.1` instead of `0.0.0.0` inside the container.
- Missing `/health` - app reports unhealthy forever.
- Hardcoded `localhost` in server-side calls - use the workspace's `synapse-experimental_default` Docker DNS for cross-app traffic.
- Iframe sandbox without `allow-same-origin` breaks `localStorage` and same-origin fetch.
- Forgetting the `io.synapse.app.manifest` label at build time. Without it the App Store's catalog scan skips the image entirely.
- Missing pull credentials on the platform-api side: `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` are loaded from `backend/.env` at host startup.
- **Not reading `SYNAPSE_APP_ROOT_PATH` at runtime** - the proxy's HTML/JS rewriter handles static absolute paths in markup, but anything constructed at runtime (`fetch`, `new URL`, JSON manifests, image src built in JS) silently 404s on the workspace origin. Wire the env into your framework as listed in "Reverse proxy contract" above.
- **Vite host check rejection** - dev servers with `server.allowedHosts` allow-lists may reject the proxy's upstream Host. The proxy already rewrites Host to `localhost` on forward, which Vite accepts by default. If you've narrowed `allowedHosts`, add `localhost` back.
- **Vite HMR WebSocket failing** - `[vite] failed to connect to websocket` in the browser console. Configure `server.hmr.path` + `clientPort: 3000` in `vite.config.ts`. The app still renders if you skip this; only HMR is broken.
- **`<base href>` injection assumption** - the proxy does NOT inject a `<base>` element. It rewrites attribute values directly, so apps that rely on a `<base>`-anchored relative URL strategy work; apps that build URLs from `document.baseURI` see the workspace origin (no prefix).
- **Tagging the image differently from `runtime.image`** in the manifest. The platform uses the image ref it pulled (the install path), but mismatch is a footgun for downstream tooling.
- **Redundant chrome** - shipping a top header, logo, or footer inside the iframe. Specifically: do NOT render a Synapse mark, brand seal, or app-name logo in your sidebar/header. The workspace shell already renders the Synapse logo in its global header and your `metadata.label` as the page title above the iframe; a copy inside the iframe stacks 1:1 next to the host's. If you ported a sidebar with a logo slot at the top (`<aside><img src="logo.png">...</aside>`), delete the logo slot, keep the nav buttons.
- **Half-theme trap when porting light-only apps** - if the ported app has zero `dark:` Tailwind variants, toggling `html.dark` via the bridge produces dark Synapse-chrome over light upstream content. Either add `dark:` variants or make `applyTheme` a no-op.
- **Hydration mismatch** - reading `window`/`document`/cookies inside `useState` initializers. Initialize to a deterministic default, sync in `useEffect`.
- **Sidebar sticky offset inherited from host shell** - a ported sidebar's `top-16 h-[calc(100vh-4rem)]` was calibrated for a host header. The iframe has none; use `top-0 h-screen`.
- **Clipboard API silently blocked** - `navigator.clipboard.writeText` throws `NotAllowedError` unless the iframe has `allow="clipboard-read; clipboard-write"`. The shared `SubAppFrame` sets sensible defaults; manifest `iframe.allow` is advisory until the host reads it.
- **Putting secrets in the manifest** - the schema rejects `runtime.env`; the runner does not forward arbitrary env keys. Use the bind-mounted secrets pattern in "Secrets and runtime config" above. Do not bake API keys into the image.
- **Korean (CJK) PDFs render as blank pages** - `python:*-slim` base images ship neither CJK fonts nor poppler's CJK character map, so `pdf2image` / `pdftoppm` produces empty rasters and `pdftotext` errors with `Missing language pack for 'Adobe-Korea1' mapping`. Add `apt-get install poppler-data fonts-noto-cjk fontconfig && fc-cache -f` to the Dockerfile. Same trap exists for Japanese (`Adobe-Japan1`) and Chinese (`Adobe-GB1`/`Adobe-CNS1`).
- **FastAPI `status_code=204` + `response_model`** - FastAPI asserts no body on 204 and crashes at import: `AssertionError: Status code 204 must not have a response body`. Don't set `status_code=204` on the decorator and don't return a Pydantic model; instead return `Response(status_code=204)` from the body.
- **`window.prompt` / `window.confirm` blocked silently** - the iframe sandbox does NOT include `allow-modals`, so native modal dialogs return immediately as if cancelled. Symptom: rename / delete buttons "do nothing" with no console error. Fix: use the native `<dialog>` element (no sandbox flag needed) or build inline edit affordances. The manifest's `iframe.sandbox` field is advisory; the host's hardcoded list is what counts.
- **LightRAG `ollama_embed` dim validator wraps the function** - direct `await ollama_embed(texts, embed_model=..., host=...)` raises `ValueError: Embedding dimension mismatch detected: total elements (768) cannot be evenly divided by expected dimension (1024)` because the wrapper checks against a hardcoded default. Pass `ollama_embed.func` to your `EmbeddingFunc(func=...)` to bypass; LightRAG's outer `EmbeddingFunc(embedding_dim=...)` is the real source of truth.
- **LightRAG `anthropic_complete_if_cache` breaks on the query path** - the bundled adapter hardcodes `stream=True` in the SDK call but its internal `stream_response()` is fragile when the SDK returns a `Message` object on certain calls (`'async for' requires an object with __aiter__ method, got Message`). Indexing works, query fails with empty answer. Workaround: write your own llm_model_func that calls `anthropic.AsyncAnthropic().messages.create(...)` directly in non-streaming mode and returns a string.
- **Anthropic SDK ≤ 0.40 doesn't know Claude 4.x model names** - pin to `anthropic>=0.100.0` (or current). Older SDKs accept the model string but mishandle the response shape and return `Message` instead of the expected stream chunk, which cascades into the LightRAG breakage above.
- **Synapse data_collection sequential file specs need `max_index` as int + per-file `path`** - if you mirror docs into a `data_collection` whose `image_1` spec has `is_sequential=true`, the unit-create payload must use `meta.max_index = N` (a plain integer, NOT a `{spec: N}` dict despite the model docstring) AND each file entry must include `"path": "<filename>"` alongside `"checksum"`, otherwise `_create_data_unit_files` silently skips the bridge row creation. Two-step flow: POST `/data_units/` (list-shaped, returns the new unit) then POST `/data_unit_files/` to attach indexed files.
- **Synapse tenant has no default Storage row out of the box** - `POST /data_files/` returns 500 `Storage matching query does not exist` until you create one (Django shell: `Storage.objects.create(tenant=..., name='default', category='internal', provider='file_system', configuration={'location':'/srv/media','base_url':'/media/'}, is_default=True)`). Then nginx serves `/media/` from `/srv/media` in the `synapse-resources` volume. There is no public REST endpoint for creating storages.

## Recipes

`references/<framework>.md` for React, Vue, Next.js, Nuxt 3, Gradio, Streamlit, Static HTML, Django, FastAPI. Each has Dockerfile, entry files, run command, `/health` pattern, bridge wiring, framework-specific gotchas. Recipes pre-date the image-publish flow; treat the Dockerfile + run command as canonical and ignore any `sub-apps/` / `docker-compose.yml` references.

