Use this skill when the user wants to BUILD THEIR OWN APP on top of Synap — a personal project or a product — using Synap as the backend (auth, typed data API, realtime). Two paths: add the SDK (@synap-core/sdk) to an existing app, or scaffold the opinionated Next.js + HeroUI base app (npx create-synap-app). Triggers: "build an app on Synap", "use Synap as my backend", "scaffold a Synap project", "add the Synap SDK", "connect my app to my pod", "npx create-synap-app". NOT the `synap` skill (capture/recall in a pod) nor `synap-ui` (views inside the pod's own UI) — this is about writing app CODE.
Resources
7Install
npx skillscat add synap-core/backend/synap-app Install via the SkillsCat registry.
Building an app on Synap
Synap is a sovereign data pod with a complete, typed API. You build an app on it
the same way you'd build on any backend — except the backend is the user's own
pod, and every write is governed (see governance.md).
Decide the path first
| The user has… | Do this | Read |
|---|---|---|
| an existing app (any framework) | add the SDK | using-the-sdk.md, install-commands.md |
| nothing yet, wants a full app fast | scaffold the base app (scaffolder not published yet) | creating-the-base-app.md, install-commands.md |
| a server / script / CLI (no browser) | SDK with a session token, or the REST client with an API key | using-the-sdk.md |
Both paths use the same underlying packages; only the amount of scaffolding differs.
The packages (all published under @synap-core/*)
@synap-core/sdk— typed tRPC client.createSynapClient({ podUrl, ... }). The one you almost always want.@synap-core/react— React/TanStack-Query bindings (SynapProvider, hooks).@synap-core/sdk-realtime— Socket.IO "something changed" events (createRealtimeClient).@synap-core/hub-rest-client— zero-dep REST client for API-key holders (HubRestClient,setupAgent).
Do not install @synap-core/auth: that package was retired, and its last npm
release signs in through a pod route that no longer exists.
Pin @^1.0.0 on sdk/react so you never resolve an old, abandoned 0.x.
The three rules that trip people up
- A write can come back
"proposed"— that is SUCCESS, not an error. It means
the change is queued for the pod owner's review (like a PR). Surface the review,
don't retry. Full contract:governance.md. - The pod URL has no
/trpcsuffix and no trailing slash. The SDK appends/trpc. - Non-browser clients (server/CLI/mobile) authenticate with a session token or
an API key, never a cookie. An API key only works on the REST client and
realtime, not on the tRPC SDK. Browser apps use the Kratos session cookie.
Get a pod to build against
- Self-host (free): run the backend's
install.sh(it installs Docker if
needed, fetches the release bundle, checks it against the release manifest's
sha256 and runssynap install); see the backend'sdeploy/README.md. It
also checks the manifest's signature: by default
(SYNAP_RELEASE_VERIFY=enforce) it refuses a release whose signature is
missing or does not verify;SYNAP_RELEASE_VERIFY=warnonly warns. - Managed (paid): sign up at synap.live and provision a pod.
You can develop the base app with no pod at all using its mock mode
(NEXT_PUBLIC_BASE_MOCK_MODE=true).
Honest scope
Building on the user's OWN pod is fully supported today. Building a multi-tenant
commercial app where other people connect their pods is not yet supported
— per-app data scoping is still being built. If the user asks for that, say so;
don't architect around a capability that isn't there.
Install & scaffold commands
Scaffold a full app (recommended for a fresh start)
Not published yet. The scaffolder (
@synap-core/create-synap-app) is not
on npm and the base template is not public, sonpxreturns 404 today. Use the
SDK path below until a release exists.
Once published:
npx @synap-core/create-synap-app my-appIt creates a Next.js + HeroUI project from the base template, with the SDK, auth,
app shell, command palette, realtime, and mock mode pre-wired. It also drops anAGENTS.md into the project so your AI coding tool knows how to work with it.
Add the SDK to an existing app
# npm
npm install @synap-core/sdk@^1.0.0
# with React bindings
npm install @synap-core/sdk@^1.0.0 @synap-core/react@^1.0.0
# realtime
npm install @synap-core/sdk-realtime
# REST client for API-key holders (agents, scripts); also has setupAgent()
npm install @synap-core/hub-rest-clientDo not install @synap-core/auth (retired; its last release calls a removed pod
route) or @synap-core/auth-bootstrap (not published).
Pin @^1.0.0 on sdk/react. Do not install unpinned @synap-core/sdk —
older 0.x versions on the registry are a different, abandoned package.
Get this knowledge into your AI coding tool
# print this skill from your pod's skill knowledge base
synap skill get synap-app
# or wire a tool end-to-end (skills + MCP)
synap connect --target=cursor # or claude-code, claude-desktop, codex, …Once the scaffolder is published, the scaffolded project ships an AGENTS.md at its root — most AI
coding tools read it automatically, so a freshly-scaffolded app teaches the AI
how to build on it with zero extra setup.
Verify an install actually works
Publishing bugs have shipped uninstallable Synap packages before. After adding a
package, confirm it resolves in a clean directory:
mkdir /tmp/synap-check && cd /tmp/synap-check && npm init -y
npm install @synap-core/sdk@^1.0.0
node -e "console.log(typeof require('@synap-core/sdk').createSynapClient)" # → functionUsing the SDK
Vanilla / TypeScript / server / CLI — @synap-core/sdk
import { createSynapClient } from "@synap-core/sdk";
const synap = createSynapClient({
podUrl: "https://pod.example.com", // NO trailing slash, NO /trpc suffix
sessionToken: process.env.SYNAP_SESSION_TOKEN, // see auth below
workspaceId: process.env.SYNAP_WORKSPACE_ID ?? "",
onAuthError: () => {/* 401: sign in again */},
});
// It's a typed tRPC client — full autocomplete over every router.
const me = await synap.users.me.query();
const entities = await synap.entities.list.query({ /* filter */ });
const result = await synap.entities.create.mutate({ /* ... */ });
// result.status may be "proposed" — that is success. See governance.md.Auth modes (pick ONE)
sessionToken— an Ory Kratos session token from a Kratos API (native)
login flow, sent asX-Session-Token. The normal choice for servers, scripts,
CLIs and mobile.- cookie fallback — omit both; the client sends the ambient Kratos cookie
(credentials: "include"). Browser only. apiKey— only the local-mode token of a desktop pod, sent asAuthorization: Bearer./trpcrefuses hub and user API keys
(keys starting with synap_hub_ or synap_user_), andcreateSynapClientthrows if you pass
one. For an API key, use@synap-core/hub-rest-client(below).
Other options: extraHeaders, and onAuthError({ status, message }), called on a
401 (it also turns the pod's plain-JSON 401 body into a clean tRPC error).
To mint an API key for a machine client, setupAgent(podUrl, provisioningToken, agentType) from @synap-core/hub-rest-client returns { hubApiKey, agentUserId, workspaceId }. That key works with the REST client and the realtime client, not
with /trpc.
React — @synap-core/react
import { SynapProvider, useSynap, useEntities } from "@synap-core/react";
function App() {
return (
<SynapProvider podUrl={podUrl} sessionToken={token} workspaceId={ws}>
<Dashboard />
</SynapProvider>
);
}
function Dashboard() {
const { data } = useEntities("note", { limit: 20 }); // thin data hook
const create = useSynap.entities.create.useMutation(); // raw tRPC hooks
// …
}useSynap is the tRPC React hook tree itself — writeuseSynap.entities.list.useQuery(…), not useSynap().entities…: calling it
returns undefined at runtime (some package docs still show the call form).
SynapProvider takes every createSynapClient option (incl. sessionToken andonAuthError) plus an optional queryClient to share an existing TanStack Query
client. Exports: SynapProvider, useSynapContext ({ podUrl, workspaceId }),useSynap, the data hooks useEntity, useEntities, useLinks, useView,
and the RouterInputs / RouterOutputs types. React 18 or 19 is a peer
dependency — the package does not bundle its own.
Realtime — @synap-core/sdk-realtime
import { createRealtimeClient } from "@synap-core/sdk-realtime";
const rt = createRealtimeClient({
url: podUrl, // pod base URL; the client adds the /presence namespace
workspaceId,
sessionToken, // a Kratos session token…
userId, // …and the identity id it belongs to (both required)
// or, as a service account: apiKey (scope realtime:observe), no userId
onInvalidate: (event) => {
// entity/relation changed — refetch, e.g.
queryClient.invalidateQueries({ queryKey: ["entities"] });
},
});
rt.connect(); // nothing connects until you call this
// … later
rt.disconnect();- Callbacks (all options, no subscribe methods):
onInvalidate(the
entity/relation-changing subset),onEvent(every known event,{ name, data }),onConnect,onDisconnect(reason),onError(error). - The returned client has
connect(),disconnect(), and read-onlyconnected
andstate("idle" | "connecting" | "connected" | "disconnected"). SYNAP_EVENT_NAMESlists the forwarded events;ENTITY_INVALIDATING_EVENTSis
the subset behindonInvalidate.- Websocket-only transport (credentials never ride a long-poll URL), and
connect()throws on a non-TLS URL unless you passallowInsecure: true
(localhost dev only).
Version note. @synap-core/sdk-realtime@0.1.0 (the current npm release) sends
every credential as auth.token. The pod reads { userId, token, workspaceId }
for a user and { apiKey, workspaceId } for a service account, so on 0.1.0 a
session token works only when you also pass userId, and an apiKey is always
refused. The fix — correct handshake keys, plus an up-front throw for no
credential, both credentials, or sessionToken without userId — lands in the
first release after 0.1.0. Until then, authenticate realtime withsessionToken + userId.
Two transports, on purpose
@synap-core/sdk(tRPC) — for apps signed in as a user. Typed, React hooks.@synap-core/hub-rest-client(REST against/api/hub/*, zero-dep) — for
agents/CLIs/scripts holding an API key.new HubRestClient({ podUrl, apiKey, workspaceId }). Carries agent verbs the tRPC surface has no first-class path
for (ask,orient,discover).
Keep types fresh
The SDK is typed by @synap-core/api-types, generated from the pod's live router.
If the pod adds a procedure and you don't see it, bump @synap-core/api-types.
Creating the base app
The base app is an opinionated, batteries-included starter: Next.js 16 (App
Router) + React 19 + HeroUI + Tailwind, with auth (incl. TOTP), an app shell
(tabs + slide-in right panel), a Cmd+K command palette, a realtime bridge, and a
mock mode — all pre-wired. You add your screens; the plumbing is done.
Scaffold it
Not published yet.
@synap-core/create-synap-appis not on npm and the
base template is not public, so this does not work today. Until a release
exists, build on the SDK directly (using-the-sdk.md). The rest of this page
describes the template as it will ship.
npx @synap-core/create-synap-app my-app
cd my-app # the scaffolder seeds .env.local from env.example;
# fill in your pod / control-plane URLs
pnpm install && pnpm dev # http://localhost:3010Develop with no pod by setting NEXT_PUBLIC_BASE_MOCK_MODE=true.
What's where
app/
layout.tsx Root layout → ClientProviders (client-only, see below)
ClientProviders Loads the provider tree with { ssr: false } (REQUIRED — see rule 1)
providers.tsx The real provider stack (auth, tRPC, realtime, palette)
(app)/home Your home screen — replace this
login/ onboarding First-run + auth screens
lib/
registerAll.ts Boot hook — registers cells/views (override in your app)
auth.ts mockMode Session + mock helpers
components/layout/
AppShell Root layout primitive — pass your sidebar as `leftNav`Four rules that keep the build green (all Next 16 / Turbopack specifics)
- The provider tree must load client-only.
app/ClientProviders.tsxdoesdynamic(() => import("./providers"), { ssr: false }). base's provider tree
pulls a dep that toucheswindowat module scope, which crashes a server
render. base is a client app shell — there is nothing to server-render — so
this is correct, not a hack. Keep it. config.matcherinmiddleware.tsmust be an inline literal, not a value
read from a helper — Next's static analyzer can't parse an expression there.- In
globals.css,@importmust come before the@tailwinddirectives.
Turbopack enforces the CSS spec strictly. - Build with Turbopack (the default) — do NOT pass
--webpack. In Next 16
the legacy--webpackflag silently produces nothing (exit 0, no output). If
you add a webpack alias, port it toturbopack.resolveAliasinnext.config.ts.
Deriving your own app
- Rename the namespaced storage keys (
base:session,base-workspace, …) so
two Synap apps don't share state in one browser. The README lists every key. - Replace
app/(app)/home/page.tsxwith your dashboard. - Pass your sidebar:
<AppShell leftNav={<MySidebar />}>. - Register app-specific Cmd+K commands with
useRegisterCommand([...]). - Register only the cells you need in
lib/registerAll.ts(a lean template
registers fewer than the full set).
Auth flow (already wired)
/login → Synap Cloud sign-in → optional TOTP → pod picker → session stored →/home. A pod can also be signed into directly through its own Kratos (the
login screen's manual-login step). You don't build this; you theme it.
Governance — "proposed" is success, not an error
This is the single most important thing to get right when writing code that
mutates a Synap pod.
The contract
Every governed mutation (entities.create, entities.update, relations.create, …)
has one of three outcomes, decided by the pod, not by your app:
| Outcome | How it arrives | What your code should do |
|---|---|---|
| applied | resolves with the procedure's own status ("created", "updated", …) |
proceed |
| proposed | resolves with status: "proposed", proposalId, reviewUrl, message — queued for the pod owner's review (like a PR) |
surface the review — do NOT treat as failure, do NOT retry |
| denied | rejects with a tRPC FORBIDDEN error whose message is the reason |
show why; do not retry blindly |
Why it matters for your code
- Never treat
proposedas an error. It is the normal, healthy path for any
write that governance decides a human should see first. Retrying aproposed
write creates duplicates. - A
proposedresult does not throw. It is a successful response with a
status; only a denial throws. - Surface the review link/id so the user can approve it. An unreviewed
proposal is not yet in the graph — it won't show up in reads until approved.
Pattern
import { TRPCClientError } from "@trpc/client";
try {
const res = await synap.entities.create.mutate(input);
if (res.status === "proposed") {
// tell the user "queued for review"; link res.reviewUrl / res.proposalId.
// res.entity is null — nothing exists until the proposal is approved.
} else {
// "created": res.id / res.entity are live
}
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === "FORBIDDEN") {
// denied: show err.message; don't retry
} else throw err;
}Idempotency
entities.create takes no idempotency key. The pod dedups some repeats on its
own — an identical agent-authored retry within a short window, and creates that
match an existing entity by identity (a stable externalId, or identifying
properties) — but neither covers every case. Never resend a proposed write,
and when unsure, read back before re-creating.