@synap-core/sdk
v1.27.0
Published
Type-safe tRPC client SDK for Synap data pods, public-door client, and server-only pod-app sign-in
Readme
@synap-core/sdk
Type-safe tRPC client for Synap data pods. Connect any JavaScript/TypeScript app to your personal data pod with full autocomplete on all 47+ routers.
Install
npm install @synap-core/sdk
# or
pnpm add @synap-core/sdkQuick start
import { createSynapClient } from "@synap-core/sdk";
const synap = createSynapClient({
podUrl: "https://your-pod.synap.live", // your data pod URL
sessionToken: "...", // Kratos session token (see Authentication)
workspaceId: "ws_...", // your workspace ID
});
// Fully typed — autocomplete works on all procedures
const entities = await synap.entities.list.query({ limit: 20 });
const workspace = await synap.workspaces.get.query();
// Create an entity
await synap.entities.create.mutate({
profileSlug: "note",
title: "My first note",
properties: { content: "Hello from the SDK!" },
});Apps are views of pod state
Model first, UI second. An app never hard-codes a status, a status order, a stage or step list, or a status colour. Read the kind's declared stages with lifecycle.get (below), show where a thing stands with status.of, move it with status.move, and run playbooks through the pod. Load the pod skill rules/apps-are-views-of-pod-state before building an app.
Status: where a thing stands, and how to move it
Three doors over the same tRPC client (createSynapClient(...) or a React
trpc.useUtils().client), each taking the client first:
import { createSynapClient, status, lifecycle } from "@synap-core/sdk";
// Up to 10 ids (things, goals, sessions, runs); `include` adds moves, history,
// links, rollup, signals. An id you cannot see answers { id, type: "unknown" }.
const [deal] = await status.of(synap, [dealId], { include: ["moves", "history"] });
// The one mover: compare-and-set on `from`; a move into a pause says what it waits on.
const outcome = await status.move(synap, {
subject: { kind: "kind", entityId: dealId, property: "stage" },
from: "proposed",
to: "on_hold",
hold: { on: "person", ref: "person:<id>", until: { date: "2026-11-15" } },
});
// outcome.status: "moved" | "proposed" | "conflict" | "refused" | "denied" | "failed"
// The stages a kind declares, and where a new one starts (null when none, or
// on a pod that does not answer it yet). null when the kind has no lifecycle.
const stages = await lifecycle.get(synap, "deal");Authentication
Session token (non-browser clients: extensions, CLIs, mobile)
const synap = createSynapClient({
podUrl: "https://your-pod.synap.live",
sessionToken: "...", // Kratos session token from an API (native) login flow
workspaceId: "ws_...",
});apiKey is for the local-mode token only. It sends Authorization: Bearer
to /trpc, which on a real pod is behind Ory session auth and refuses hub / user
API keys (synap_hub_*, synap_user_*). createSynapClient throws early if you
pass one. For hub / user API keys use
@synap-core/hub-rest-client against /api/hub/*.
Session cookie (browser — after Kratos login)
const synap = createSynapClient({
podUrl: "https://your-pod.synap.live",
workspaceId: "ws_...",
// omit apiKey/sessionToken — credentials: "include" is set automatically
});Type helpers
import type { RouterInputs, RouterOutputs } from "@synap-core/sdk";
type EntityListInput = RouterInputs["entities"]["list"];
type EntityListOutput = RouterOutputs["entities"]["list"];Public doors (no credentials): @synap-core/sdk/public
A tiny, dependency-free fetch client for the pod's credentialless public doors
(published shares, guest forms, published sites). It never sends credentials
(credentials: 'omit', no Authorization/Cookie) and works from any origin, so it
is safe in a public site. The same entry ships the framework-free view-models a
site renders (no React): toFieldModels / validate / submitFailure (forms),
toSharePageModel, parseSafeMarkdown (data, never HTML), formatPublicValue,
toCollectionPageModel / pageQuery / parsePageParam, toRecordViewModel.
A published body is format: "markdown" (the models parse it into safe blocks) or
format: "html" (a whole HTML document such as an estimate: the models hand it
over as html; render it only in <iframe sandbox="" srcdoc={html}>).
import { createPublicClient } from "@synap-core/sdk/public";
const pub = createPublicClient({ podUrl: "https://your-pod.synap.live" });
const share = await pub.readShare(token); // { ok: true, data } | { ok: false, error }
const form = await pub.getForm(formToken); // fields, honeypotField, ticket, captcha
const sent = await pub.submitForm(formToken, {
fields: { email: "[email protected]" },
ticket: form.ok ? form.data.ticket : undefined,
captchaToken, // when form.data.captcha.required
});Errors are typed: not_found (the uniform 404: unknown, revoked and expired are
indistinguishable), rate_limited, network, server. A submit always answers
{ ok: true, received: true } on the pod's constant 202; the pod silently drops
too-fast, honeypot, captcha-failed and over-cap submissions, so wait out the form's
minimum time before submitting. Pass fetch to inject your own.
Published sites (1.2.0)
const site = await pub.getSite(siteToken); // manifest: title?, collections[{ key, count, sort, pageSize, addressing }]
const page = await pub.listCollection(siteToken, "paper", { offset: 20, limit: 10, sort: ["-year"] });
const rec = await pub.getRecord(siteToken, "paper", "on-graphs"); // slug or "p_…" public idPublished charts (1.12.0)
const charts = await pub.listCharts(siteToken); // items[{ publicId, slug, title?, cellKey, mode, publishedOn }]
const chart = await pub.getChart(siteToken, "interets-dette"); // + props, data, capturedAtA chart is drawn by its cellKey (a pod chart key like chart-line, or a
custom cell key) from data in that key's shape. mode says how the numbers
stay current: snapshot (frozen when the owner published it) or live (re-read
from the pod, cached up to a minute); the response has the same shape either
way, and capturedAt says when the numbers were taken. Needs a pod with
backend#750.
Site reads fail with not_found (one uniform miss for token, collection and
record), doors_disabled, bad_request (sent only when the door says 400: the
SDK refuses out-of-bounds offset/limit/sort locally, without a request),
rate_limited, pod_unreachable (no answer, or a 502/503/504) or server.
rate_limited carries retryAfterSec only when the response has a readable
Retry-After; the pod's public doors do not send one today, so expect
undefined. Successes carry the door's weak etag; a server may revalidate with
{ ifNoneMatch: etag } and get { ok: true, notModified: true } on a 304. Do not
pass ifNoneMatch from a browser: the doors allow no cross-origin request header
but Content-Type, so the preflight fails (browsers revalidate on their own).
What a pod app can see and do (spaces, roled records, writes): read the section "What a pod app can see and do" of AGENTS.md, shipped in this package (node_modules/@synap-core/sdk/AGENTS.md).
Pod-app sign-in (server-only): @synap-core/sdk/connect (1.3.0, 1.4.0, 1.5.0)
An app that reads and writes a person's OWN pod connects as an OAuth 2.1 public
client of that pod: its client_id is the https URL of its client metadata
document (CIMD), PKCE S256, every answer checked for the pod's iss. The pod
token stays on the app's server, sealed (AES-GCM) in an HttpOnly cookie; the
browser never sees it. Framework-free and Web-standard (Request → Response,
WebCrypto): Next route handlers and proxy.ts, Hono, Remix, edge runtimes.
Server-only: it throws when loaded in a browser, and a browser bundle
resolves it to a stub that throws.
// lib/synap/pod-app/auth.ts (server-only)
import { createPodAppAuth } from "@synap-core/sdk/connect";
export const synap = createPodAppAuth({
appUrl: process.env.SITE_URL!, // https://studio.example.com
clientName: "Studio",
requests: [
{ permission: "entity.post.read", reason: "Show your posts" },
{ permission: "entity.post.create", reason: "Save drafts to your pod" },
],
secret: process.env.SYNAP_APP_SESSION_SECRET!, // ≥ 32 chars, never NEXT_PUBLIC_
cloudUrl: process.env.SYNAP_CLOUD_URL, // optional (1.4): https://api.synap.live offers "Continue with Synap Cloud"
});
// app/.well-known/synap-client.json/route.ts export const GET = synap.handlers.clientMetadata;
// app/api/synap/connect/route.ts export const POST = synap.handlers.connect; // form { pod, next?, space?, prompt? }
// app/api/synap/callback/route.ts export const GET = synap.handlers.callback;
// app/api/synap/disconnect/route.ts export const POST = synap.handlers.disconnect; // form { next? }
// app/api/synap/cloud/route.ts (1.4) export const POST = synap.handlers.cloudConnect; // form { next?, space?, prompt? }
// export const GET = synap.handlers.cloudReturn;// proxy.ts: refresh before render (server components cannot set cookies)
const out = await synap.refreshIfNeeded(request);
// out.status: none | skipped | fresh | refreshed | kept | expired | revoked | unavailable
// out.lapsed (with none, 1.4): { podUrl } when this browser WAS connected and the connection lapsed
// send out.setCookies on the response; forward out.cookieHeader (when set) as the request's Cookie
// Next 16 strips its router headers before proxy code runs, so skip prefetches in the MATCHER
// (it still sees them; matcher values must be literals):
export const config = {
matcher: [{
source: "/((?!_next/static|_next/image|api/synap/|\\.well-known/|.*\\.\\w+$).*)",
missing: [{ type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }],
}],
};
// a server component / action (no Request there: pass the Cookie header)
const connection = await synap.readConnection((await headers()).get("cookie"));
if (!connection) redirect("/connect");
const pod = synap.client(connection);
const res = await pod.post("/entities", { profileSlug: "post", workspaceId: pod.spaces()[0].workspaceId, title: "Hello" }); // always a granted space
if (res.ok && res.status === "proposed") showPending(res.reviewUrl); // success: queued for review, never retry
if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupported | rate_limited (retryAfterSec) | pod_unreachable | pod_moved | …
pod.rateLimit(); // the budget the pod's last answer named: { limit, remaining, resetSec, at } | null
// Where a run opens (1.16): set once, e.g. createPodApp({ ..., runLink: "https://chat.synap.live/sessions/{sessionId}?space={workspaceId}" })
const run = await pod.playbooks.run(playbookId, { workspaceId: spaceId });
if (run.ok && run.data.link) show(run.data.link.url, run.data.link.title, run.data.link.stage); // ids only: the app that opens it signs in itself
pod.runLink(sessionId, spaceId); // the link of any session (a list's row), or null
// A streamed answer (the chat door of an app, needs `chat.stream`; 1.15): frames as they arrive, its own 120 s bound
const turn = await pod.stream("/chat/stream", { query: "What is waiting on me?", workspaceId: spaceId });
if (!turn.ok) turn.error.code; // door_unsupported when the grant holds no chat.stream, expired, rate_limited, …
else
try {
for await (const frame of turn.data) show(frame.event, frame.data); // data: the parsed JSON, else the text
} catch (e) {
if (e instanceof PodStreamBroke) e.code; // pod_unreachable | server: it broke off after it opened (never retried)
}Rate limits: a pod budgets each connection per 15-minute window, reads and writes in separate buckets (by default 3,000 reads and 500 writes; the pod's owner can change both: synap-backend
apps/api/src/middleware/rate-limit-classes.ts,crud_readandcrud). Over a budget every call answersrate_limitedwithretryAfterSec(fromRetry-After, else theRateLimitreset) until the window ends: show the wait, never "a minute". Read a list once per page and keep it (a request-scoped memo, or a short cross-request cache per connection, never shared between people).retryRateLimitedWithinSec(client option, default 0) waits out a short 429 on a GET and asks once more.Discovery:
discoverPod("you.pod.synap.live")fetches the pod's RFC 8414 metadata and requiresissuerequal to the address, CIMD,isson answers and S256 (not_a_pod,pod_unsupported,pod_unreachable). A Synap Cloud name typed the old way (you.synap.live) also triesyou.pod.synap.live, thenpod.you.synap.live; another address also triespod.<address>. A redirect is never followed (pod_movedwhen only an old, moved name answers). A browser launch (?iss=…&target_link_uri=…) is read withacceptLaunchand only pre-fills the connect page. The connect route fetches an address a visitor typed: rate-limit it per visitor.Local development: an app on
http://localhostcan sign in to the SDK's mock pod only (@synap-core/sdk/testing,allowInsecureLoopback). Every real pod, a local one included, refuses an httpclient_idandredirect_uri: run the app on https (a tunnel) to sign in to a real pod.space(the connect form,startConnect({ spaceHint })) is sent to the pod assynap_space_hint, a hint its consent screen may use. A pod may ignore it: never rely on a space being preselected.Failures redirect with
?error=<ConnectErrorCode>:denied,expired_transaction,mismatch,misconfigured,rate_limited,pod_unreachable, … (parseConnectErrorCodereads it back).connectanddisconnectare POST and same-origin only.Refresh (rotating, single-use, 30 days from issue) is feature-detected: a pod that issues no
refresh_tokengives a connection that ends with its 1-hour access token.refreshIfNeededrotates only inside the last 5 minutes, is single-flight per connection in the process, and persists the rotated connection BEFORE returning it. The pod revokes the whole connection when a spent refresh token is presented again, so for 2 minutes a request still carrying the spent token (a parallel request, a second tab) gets the rotation's result instead of reaching the pod. Across server instances only a shared server-sideConnectionStorerules that replay out. A concurrent loser (refused softly by the pod) keeps the still-valid access token. The pod's reuse grace answers a spent token presented again within 30 s with the same pair the first use got; itsexpires_inmay be under an hour, and the SDK reads it as sent.Your own store: implement
ConnectionStore(read,write,clear, returning Set-Cookie values) and pass it asstore.Lower level:
startConnect,handleCallback,refreshConnection,revokeConnection,buildClientMetadata,seal/unseal,startCloudConnect,acceptCloudReturn.
1.4.0 (additive)
Re-consent: the connect form's
prompt=consent(orstartConnect({ prompt: "consent" })) goes to the pod's consent screen even when this browser is already connected to that pod (to widen or narrow access). It is a normal sign-in: fresh PKCE verifier and state,isschecked; a denied or forged answer leaves the live connection as it is.Synap Cloud: with
cloudUrlset,synap.cloudConnectAvailableis true andcloudConnectAvailable({ cloudUrl })too. POSThandlers.cloudConnectsends the browser to<cp>/connect/pod?client_id=<client id>&state=<nonce>, the state sealed in an HttpOnly cookie of that browser (10 minutes). The CP returns to the CIMDinitiate_login_uri?iss=<pod>&state=<nonce>(your connect page):acceptLaunch(url).cloudStateis set, so forward the request's query to GEThandlers.cloudReturn, which checks the state against the cookie (constant time, single use) BEFORE the pod is used, then runs the same sign-in as a typed address.acceptLaunchitself never verifies anything; a return without a matching state starts no sign-in (mismatch,expired_transaction). A cancel on the chooser (?state=…&error=access_denied, noiss) also carriescloudState: forwarded the same way, it lands on the connect page with?error=deniedonce the state matched. Security notes: after the state matches, the named pod goes through the same discovery as a typed address (metadata,issuerequality, CIMD,iss, S256) before the browser is sent anywhere. The cloud cookie is single-use and cleared on every outcome (success, cancel, mismatch). Accepted trade-off: a third-party page that sends a visitor's browser to a forged return can cancel that visitor's Cloud sign-in in progress (they click again); nothing leaks and nothing is bound, the same nuisance any OAuthstatecheck allows.// app/connect/page.tsx (server component) const launch = acceptLaunch(url, origin); if (launch.cloudState) redirect(`${synap.paths.cloud}${new URL(url).search}`);connection.manageUrl/connection.appId: the pod's page for this app connection (pod-admin/apps/<appId>), read from the token response'ssynap_app: { id, manageUrl }(https only). Absent when the pod does not send it: hide the link, never build one.client.listPendingProposals({ workspaceId?, limit?, offset? }): "Waiting for review: N" (data.total, plus summary rows withreviewUrl) from the pod's seamedGET /proposalsdoor. Ask forproposal.readinrequests: without it the answer isforbiddenand nothing is sent (the pod would answer an empty list). It counts the person's pending proposals in those spaces, from any agent.spaces(connection)/client.spaces(): the grant by space ({ workspaceId, workspaceName, permissions }), each permission once.Expired vs never connected: each sign-in and refresh also sets a marker cookie (
__Host-synap_seen, 400 days) holding the pod's URL and nothing else.synap.readLapsed(cookieHeader)andRefreshOutcome.lapsedsay "this browser was connected to " once the sealed session is gone; a disconnect clears it.isPrefetchOrAsset(request)is exported, and its limit documented: inside Next 16's proxy a router prefetch is invisible (see the matcher above).Not added:
ensureTemplateon the pod-app client. A pod-app key's grant is pinned to the approved spaces, so creating a space is refused by the pod's grant check every time; ask for templates withtemplates(CIMDsynap_templates) instead: the pod's consent offers to create the space.
1.5.0 (additive): typed reads, who the app acts as, approvals
Which client for what. A pod app (a website or service a person connects to
their OWN pod, with a token from this sign-in) reads and writes through
synap.client(connection) / createPodClient(connection): it carries the
connection's token, refuses expired tokens locally, and answers the typed codes
below. @synap-core/hub-rest-client stays the client for scripts, CLIs and
agents holding a static pod key (apiKey): it retries, throws HubApiError and
knows far more doors. The two are separate on purpose; this SDK does not depend
on it (CLIENTS.md).
const pod = synap.client(connection);
// Typed reads: PodResult<T>, the same codes as get/post (expired | unauthorized | forbidden |
// door_unsupported | not_found | invalid | rate_limited | pod_unreachable | server | …)
await pod.listEntities({ workspaceId, profileSlug: "post", limit: 1000 }); // PodEntity[]
await pod.listEntities({ workspaceId, facetSlug: "lead", includeFacets: true }); // the wearers of a role, each with facets
await pod.listEntities({ workspaceId, profileSlug: "post", includeMedia: true }); // each row's linked files (media: { id, title, mimeType }[])
await pod.listEntities({ projectId }); // one project's things (1.24); { withoutProject: true } = filed into none
await pod.recall({ query: "pricing for creators", profileSlug: "post" }); // { entities, source }: best first, by words and meaning (1.24)
await pod.getEntity(id); // PodEntity, not_found on a miss
await pod.getEntityConnections(id); // { connections, counts }
await pod.listEntityChannels(personId); // { channels: PodChannel[] } (1.11, channel.read)
await pod.listChannelMessages(channelId, { limit: 50, before, beforeId }); // PodChannelMessage[], oldest first (no limit = every message); page back from the oldest one
await pod.listProposedMessages(channelId); // { proposed: PodProposedMessage[] } (+ proposal.read)
await pod.listSpaceConversations({ workspaceId, needsYou: true }); // { conversations, nextCursor } (1.13, channel.read in the space)
await pod.sendOnChannel(channelId, { text, draftId }); // { status: "sent" | "held" | "failed", … } (1.13, a person + proposal.approve)
await pod.measureLinkedinReach(personId); // { reach, invitedAt, providerId } (1.13, channel.read)
await pod.listViews({ workspaceId, type: "bento" }); // PodView[], a bento's tiles on `blocks` (1.17, view.read in the space)
await pod.getView(viewId, { workspaceId }); // PodView, not_found when the space holds none (1.17, view.read)
await pod.listCells({ workspaceId }); // PodCell[]: the space's cells, HTML ones `rendererType: "iframe"` (1.17, widget.read)
await pod.getCell("generated:reply-rate", { workspaceId }); // PodCell: the cell a bento tile names (1.17, widget.read)
await pod.playbooks.runChoice(playbookId); // who runs it: PodRunChoice (1.14); runButtonOf(choice) = run | ask | none
await pod.playbooks.saveRunChoice(playbookId, { kind: "synap_ai" }); // save the person's answer (a person: applied; an agent: proposed)
await pod.playbooks.run(playbookId, { workspaceId, subjectId, answer, remember }); // { status, sessionId, body } (playbook.run)
await pod.flow("deal", { workspaceId, properties: true }); // PodFlowOf: states in order, owners, cards, next, blocker (1.20)
await runNextStep(pod, flow, card, { workspaceId, answer }); // nextStepOf(flow, card) = run | worked | none; runs the owning playbook
await pod.getDocument(documentId); // { content, revision, sections, … }
await pod.listOwedSlots({ workspaceId, subjectProfileSlug: "post" }); // what waits on the person: { slots, truncated }, the pod's own needs-you
await pod.answerSlot(sessionId, { label, value: { type: "confirm", confirmed: true } }); // the person answers an ask (1.18, a person + session.update); conflict = it moved on
await pod.postRoomMessage(session.channelId, { content }); // the person writes in a run's room: { messageId } (1.18, a person + channel.read)
await pod.listFocusSessions({ workspaceId, status: "active" }); // PodFocusSession[] (a page of ≤ 50, newest first)
await pod.getFileUrl(fileEntityId); // { url, expiresAt, mimeType }
await pod.listPendingProposals({ proposalIds: [id1, id2] }); // only those (≤ 100)
// Who the token acts as: "person" for an official Synap app, else "agent".
pod.actsAs; // also connection.actsAs (absent = agent) and connectionActsAs(connection)
const space = pod.spaces().find((s) => s.templateSlug === "content-os"); // the space made from that template
// Approve, as the person (needs proposal.approve in requests; any app may ask for it since 1.6.1, but only
// a person connection, an official Synap app, can use it). An agent connection is refused before any
// request: { ok: false, error: { code: "forbidden", status: null, reason: "agent_cannot_approve" } }
const res = await pod.approveProposal(proposalId, { workspaceId });- Writes: an agent connection's writes are proposals (
proposedis success: showreviewUrl, never retry). A person connection's writes apply directly, as the person, with no review step (applied). The result reports what the pod did either way, so aproposedanswer to a person (a space that reviews its members' changes) is still success. actsAsis read from every token response (code exchange, refresh, the reuse grace), never carried over: a pod that stops saying"person", or never did, gives an agent connection. It drives the SDK's own refusals and your UI's words; the pod enforces the rule itself.- Local refusals answer
status: nullwith areason(agent_cannot_approve,not_granted,invalid_input) and send nothing: an agent approving, a missingproposal.read/proposal.approve, an id that could leave its path segment,listFocusSessionswithout a space or project. - Shapes are read from the backend routes (
src/connect/hub-types.tsnames each one). A 2xx answer of another shape isserver(status 200), never empty data. Fields the SDK does not type stay on the objects. templateSlug(PodGrant,PodSpace): the template the space was made from, when the pod names one; absent for a space made by hand and from pods that do not send it (keep a fallback, such asensure-template, for those). It survives the sealed cookie (readConnection).
1.5.0: hardening
A 401 renews once. A narrow, or a pod-side "Revoke key", ends the access key, not the connection. A client that can set cookies renews it ONCE (one single-flight rotation, persisted before use) and retries the request once:
// a route handler: send the renewed cookie on its own response, as with refreshIfNeeded const setCookies: string[] = []; const pod = synap.client(connection, { request, persist: (c) => void setCookies.push(...c) }); const entities = await pod.listEntities({ workspaceId }); const response = Response.json(entities); for (const c of setCookies) response.headers.append("Set-Cookie", c); return response;unauthorizedthen means the pod refused the connection itself. A server component cannot set cookies, so it passes nopersist: nothing is rotated there (the old refresh token would be spent and lost), and a 401 answersunauthorizedwithrefreshable: true. Send that visitor through the proxy once withrefreshIfNeeded(request, { force: true })(it rotates whatever the expiry says; refused, it isrevoked, neverkept).A revoked connection's cookie is cleared (
revoked/expiredwith the access token spent; the marker stays, so the visitor reads "expired"), so each page view no longer posts the dead token to/token. A refresh that fails while the access token still works (kept) is not asked again for a minute.A moved pod is
pod_moved(ConnectErrorCodeandPodErrorCode): a redirect from/token, the metadata or the Hub. Connect again with the pod's new address; the SDK never takes it from the redirect.Bounded answers: discovery (64 KB), the token endpoint (64 KB) and the Hub (
maxResponseBytes, 8 MiB) count bytes on the stream and stop there; Hub requests time out (timeoutMs, 10 s) aspod_unreachable.Paths: a Hub path that could leave
/api/hub(a dot segment, plain or percent-encoded,//,\, a query) answersinvalid/invalid_inputinstead of throwing. Encode each path segment (encodeURIComponent).buildClientMetadatarefuses what the pod refuses, at setup: a*, a permission with no action, a qualifier on a subject other thanentity(a kind),capability(an intent, whose one action isrun) andevent(a family), more than 48 requests counted with duplicates, a document past 8 KB, an IP addressappUrl. Since 1.6.1 (pod decision 452726d6) any action is accepted, gate verbs (playbook.run,proposal.approve, …) included, from any app: the person ticks what the app gets, and an agent connection never approves.Packaging:
engines.node >=20(WebCrypto global),typesperimport/require(.d.ts/.d.cts),sideEffects(only theconnectentry runs code on load), this package'sCHANGELOG.md.
Test without a pod: createMockAuthServer() from @synap-core/sdk/testing
(metadata, authorize() as the consent screen, /token with PKCE, rotating
refresh with the 30 s reuse grace, /revoke, Hub REST doors: GET /entities
answers an array as the pod does, GET /proposals lists what proposed writes
filed; synap_app is MOCK_APP unless app: null; writeMode, failures, refresh: false, entities,
refreshGraceSec), and createMockCloud() for the Synap Cloud pod chooser.
1.5: seed (one JSON shape, parseMockPodSeed) feeds GET /entities/:id,
GET /entities/:id/connections, GET /documents/:id, GET /focus-sessions and
proposalIds; actsAs: "person" | "agent" answers synap_acts_as (a person's
writes apply, it may approve where granted; an agent's approve is refused 403).
Role writes (PATCH /facets/:id, attach, detach) are checked against the grant
as the pod does (backend#411): where the role lives, a shared role's add or
remove in every space it is granted to (seed roles), and an applied write
shows in the next read (AGENTS.md):
import { readFileSync } from "node:fs";
const seed = parseMockPodSeed(readFileSync("mock/pod-seed.json", "utf8")); // { entities, sessions, documents, relations, proposals, spaces }
const pod = createMockAuthServer({ seed, actsAs: "person" });1.5.1: an entity's roles ride on its seed row as facets, the shape
includeFacets=true answers, plus the space whose members see it
(workspaceId, seed only). A role in a granted space puts a pod-wide row in the app's reach
(AGENTS.md, "What a pod app can see and do"). GET /entities then filters by
facetSlug, drops a lens's pod-wide rows on includePodWide: false, and puts
facetSlugs + facets on a row only when asked; GET /entities/:id/facets is
served:
{ "id": "ent_ada", "profileSlug": "person", "title": "Ada", "workspaceId": null,
"facets": [{ "facetId": "fct_1", "slug": "lead", "properties": { "leadStage": "prospect" }, "status": "new", "workspaceId": "ws_crm" }] }const pod = createMockAuthServer();
const cloud = createMockCloud();
const synap = createPodAppAuth({ ...config, fetch: pod.fetch, cloudUrl: cloud.url });
// POST connect → Location → pod.authorize(location) → GET callback with that URL
// POST cloud → Location → cloud.choose(location, { pod: pod.issuer, metadata: synap.metadata }) → forward to GET cloud1.7.0 (additive): act through the person's tools
pod.actions.list({ workspaceId, intent }) lists the person's tools for one
intent (PodAction[]: one per tool and account, labels from the pod), and
pod.actions.request({ workspaceId, intent, action, input }) asks their pod to
run one. Both need capability.<intent>.run in the space, asked for in
requests and granted by the person. An intent that reaches outside the pod
(publish_post) is always proposed: the person approves the exact post in
their pod. Every PodError now carries the pod's own code as podCode, and
POD_ERROR_CODES lists every PodErrorCode. AGENTS.md, "Act through the
person's tools", has the states and the refusals.
1.17.0 (additive): views and cells, to draw a pod dashboard
listViews({ workspaceId, type: "bento" }) reads the dashboards of a space; a
bento's tiles come on blocks, and a widget tile names a cell by
widgetType. listCells({ workspaceId }) reads that space's cells in one call
(getCell(typeKey, { workspaceId }) for one). Draw an iframe cell's
rendererSource in the sandboxed frame of @synap-core/types/cell-bridge, and
answer its read ops with this client: the frame never holds a token. Ask for
view.read and widget.read in requests. Cells need a pod with
synap-backend#926.
1.14.0 (additive): who runs a playbook, and one run button
playbooks.runChoice(id) reads who runs a playbook for the person (their saved
answer, the answers that can run it now, the workers hidden and why);
playbooks.saveRunChoice(id, answer) saves one; playbooks.run(id, opts) runs
it. runButtonOf(choice) is the one rule a run button draws: never a silent
default (nothing saved: ask, even with one answer), a saved answer runs at once.
1.13.0 (additive): every conversation of a space, the person's send, LinkedIn reach
listSpaceConversations({ workspaceId }) lists everyone the person reaches out
to in a space in one read, each with their draft (draft, held until they are
reachable, or failed) and their LinkedIn reach. sendOnChannel(channelId, {
text, draftId? }) sends what is in the message box as the person (a person
connection with proposal.approve): sent, held (kept, never lost: not a
LinkedIn connection yet) or failed. measureLinkedinReach(personId) reads one
person's LinkedIn reach now; its refusals name the step in podCode
(NO_IDENTIFIER: find their LinkedIn first). Needs a pod with
synap-backend#818.
1.11.0 (additive): read a person's conversations
listEntityChannels(entityId), listChannelMessages(channelId, { limit, before, beforeId })
and listProposedMessages(channelId) read the conversations the pod holds with a
person (LinkedIn, email, ...), each message with its direction, deliveryStatus
and externalSource, and the drafts agents proposed. Ask for channel.read (and
proposal.read for the drafts) in requests; a channel follows its person, so
channel.read in a space reaches the persons that space's grant reads (a pod-wide
person through a role there). limit omitted reads EVERY message; before is an
ISO time with Z or an offset. PodChannel.provider and accountId may be null.
A pod with the channels release is needed: an older pod answers door_unsupported
(and one that ignores limit is caught: door_unsupported, never the whole
history as a page). The mock pod seeds channels.
requests takes an event family, as the pod grants it: event.dev.read reads
the development events (pull requests, checks, main health) in the granted
spaces, narrower than event.read. Before, the SDK refused any qualifier outside
entity and capability.
1.10.0 (additive): send a message, for one person
send_message ({ to, text, subject? }) and request_connection ({ to, text? })
are typed in PodIntentInputs; to is the person's entity id, and the pod picks
the address each tool needs. pod.actions.list({ workspaceId, intent, subject })
marks each action's reach for that person and ranks the list (the channel
last used with them first).
1.9.0: pod-wide reads
An app may ask to read one kind everywhere in the pod: requests: [{ permission:
"entity.decision.read", reason, scope: "pod" }], a read of one kind only (a
write, a whole kind or another subject asked pod-wide is refused at setup). The
approved one comes back as a grant row with workspaceId: null: can(permission,
anySpace) is true, podWide() lists them, spaces() skips them, and
listEntities({ profileSlug }) with no space lists the kind across the pod.
Writes stay space-pinned. AGENTS.md, "What a pod app can see and do", has the rules.
1.11.0: one grammar, reach()
can() reads the grant with the pod's own permission grammar (a row covers
what its pattern covers: can("entity.note.read", space) is true for an
entity.read row there), and reach(permission) says where the connection holds
a permission: { kind: "pod" }, { kind: "spaces", ids } or null.
Reading efficiently
A pod answers every read over the network, so a page's time is roughly the number of reads that wait on each other (its
depth) times the pod's round trip, and a pod budgets each connection (a default pod: about 500 Hub calls per 15 minutes,
a fixed window; over it every call answers 429 until the window ends). These are rules of thumb from reading a slow app
(a CRM whose pages chained about 25 calls and fanned out one call per row), not measurements. Everything named below is in
src/connect/client.ts and hub-types.ts of this version.
- Start independent reads together. The client has no batching and no field selection, and Hub reads carry no ETag, so
the only levers are fewer calls and a shallower chain:
Promise.allover reads that do not need each other's answer, never anawaitbetween them. - No per-row fan-out on first paint.
getEntityConnections(id)(and anyget*by id) is one call per row. Prefer the read that answers for a whole list:flow(profileSlug, { workspaceId })returns a kind's states, the cards in each (who works them, next step, blocker) and the unclassified ones in ONE call (firstN0-50 cards per state,windowDays1-366,properties: truefor each card's values);listEntities({ includeFacets: true })puts a role's facets on each row;includeMediathe first files. Otherwise cap the rows that fan out, or draw the page first and stream the fan-out. - Page size.
listEntitiestakeslimit1-1000 (the SDK clamps silently outside it; the pod's default is 20) andoffset; a loop oflimit: 100is one round trip per 100 rows, one after the other. When the total is known, start the pages together; else raiselimit. Other doors are narrower:playbooks.listlimit1-100 withnextCursor;runs.list/listFocusSessionslimit1-50, andruns.list({ playbookId })filters AFTER the read ("a page holds at most 50"), so a space with more recent runs than 50 shows fewer of that playbook's runs than it has. The truncation trap (UNVERIFIED): whether a pod answers fewer rows thanlimitwhen it caps below what you asked, instead of an error, has not been measured here. Before raisinglimitabove 100 on a read that matters, ask a real pod for 1000 rows of a kind that has more and count them. A pod finding (not re-checked here) saysGET /entitiesanswers exactly 1000 rows with no cursor or "more" flag, so a list of exactly 1000 may be cut. Never end a paging loop on "a short page"; end it on an empty page, and say "first N" when you stop at a cap. - One fetch for everything waiting on the person (1.25.0).
pod.needsYou({ workspaceId?, projectId?, trackId?, sessionId?, limit? })(GET /needs-you) answers what waits on the person in ONE read, by the pod's own rule: the headlinerows.needs_you, the folds (working,stopped,review_later,from_system,changes_to_review), andcountover the same rows. Prefer it to chaininglistFocusSessions,listOwedSlotsand the proposals (three fetches, three opinions about what "needs you" means).limitis 1-200 per list (the SDK clamps; the pod's default is 100);truncatedsays a list was cut. It needssession.read(without it the pod answersforbidden); a key bounded by a grant reads the asks and sessions of the spaces, projects and objects its grant names and the proposals only withproposal.read.withheldis optional: present only when the key did not read every half (it names them:notifications, the person's owncaptures,invites,proposals...), absent for the whole read. A half named there is missing fromrowsandcount; draw it as "not shown to this app", never as "nothing waits". Needs a pod with backendGET /needs-youfor apps (an older pod:door_unsupportedornot_found). - Count without reading everything. There is no count door in this version:
listEntitiesanswers rows, not a total. A badge that needs the whole list belongs in its own streamed section, not in the page's first paint. - 429 and the budget.
pod.rateLimit()is the budget the pod's last answer named ({ limit, remaining, resetSec, at }), or null before any answer or when the pod sends none. A 429 israte_limitedwithretryAfterSec. By default the client never retries (retryRateLimitedWithinSecis 0); set it to a few seconds to wait out a short 429 for a GET once (writes are never retried). Keep it short: the caller waits with it, and a page that waits does not draw. An app with a read cache should answer a limited connection's GETs from its cooldown instead of asking again. - Time out on purpose.
timeoutMs(default 10 000 per Hub request; past it the read ispod_unreachable) andmaxResponseBytes(default 8 MiB) arePodClientOptions; a stream has its own timeout (PodStreamOptions.timeoutMs, default 120 s). LoweringtimeoutMsmakes a slow page fail visibly sooner; it never makes the pod faster. - Writes are never retried, and some read first.
runs.replyis two calls in a chain (it reads the run for its room); when the page already holds the run,postRoomMessage(run.channelId, ...)is one. Aproposedanswer is never asked again (see "Runs and playbooks: acting"). - Count your own calls.
createRequestMeter()(exported from@synap-core/sdk/connect) overonRequestgives calls, failures, retries, pod time, wall time and depth per page or action; each event carriesstartedAtanddurationMsso overlapping reads (started together) and back-to-back ones (a chain) can be told apart. Never log a query or a token:PodRequestEvent.pathis the path without its query on purpose.
Runs and playbooks: acting (1.25.0)
pod.runs.get(id) / pod.playbooks.get(id) (1.22) read a run and a process; these act on them. Each checks its
permission FIRST: without it the answer is forbidden / not_granted and nothing is sent. Ask for the permissions in
requests.
| Method | Pod door | Permission |
| --- | --- | --- |
| runs.cancel(runId, { reason? }) | POST /focus-sessions/:id/cancel | session.update |
| runs.pause(runId, { expectedStatus, hold? }) | PATCH /focus-sessions/:id { status: "paused", expectedStatus, hold? } | session.update |
| runs.resume(runId, { expectedStatus }) | PATCH /focus-sessions/:id { status: "active", expectedStatus } | session.update |
| runs.handToPerson(runId, { label, reason?, why?, unblocks?, ask? }) | POST /focus-sessions/:id/outputs/block | session.update |
| runs.reply(runId, { content }) | GET /focus-sessions/:id, then POST /threads/:roomId/messages | session.read, channel.read, session.update |
| playbooks.update(id, { stages?, name?, description?, goalTemplate?, criteria?, executor?, status?, reasoning? }) | PATCH /playbooks/:id | playbook.update |
proposed is success, and it means nothing happened yet. A PERSON connection (an official Synap app) acts as the
person: cancel, pause, resume and update come back applied. An AGENT connection (every other app) files a
proposal the person reviews in their pod (proposed, reviewUrl, data: null): until they approve it the run keeps
running and the playbook is unchanged. Say "Cancel asked: waiting for your approval", never "Cancelled". Never retry a
proposed answer. handToPerson is not governed (it applies for both); reply is the person's words, so an agent
connection is refused (person_only).
What cancel stops, and what it does not. Applied, the answer says it: stopped (the run's QUEUED jobs, and the
agent replies RUNNING in its room), notStoppable (a job a worker had already taken: it will finish), stopFailed (a
stop that errored; the cancel stands), finished (what had already applied: still there, undone only by reverting the
run) and notLinked (work that names no run). It does not reach outside the pod: an external or computer agent working
on its own machine is not stopped; tell that agent. A run that had already ended answers alreadyClosed: true with empty
lists, so a second cancel is harmless.
Pause and resume are compare-and-set. expectedStatus is the status you read: if the run moved meanwhile the answer
is conflict and nothing changed (read it again, decide again). An agent connection's pause must say what it waits on,
hold: { on: "person" | "agent" | "external" | "thing" | "decision" | "date" | "nobody", ref?, reason?, until?, reviewBy? }
(POD_HOLD_ON; never unspecified): without one the SDK refuses it (invalid_input), as the pod would. A person's pause
may omit it. A resume carries no hold. conflict also answers a run that has ended (a closed run stays closed: more work
is a new run) and, for an agent, a run held at a stage gate for the person's approval.
Hand a step to the person. handToPerson(runId, { label }) puts one of the run's declared outputs
(run.expectedOutputs[].label) on the person: it then appears in listOwedSlots (their "needs you"). reason is one of
POD_BLOCKED_REASONS (default decision); unblocks says what moves when they answer; ask how they can answer
({ mode: "confirm" | "choose" | "form" | "act" | "provide", ... }). An undeclared label is not_found, a delivered
output conflict.
Change a playbook. stages is the WHOLE list, in order. Read it with playbooks.get, change what you need, send
it back: a stage keeps every field the pod stores, including those this SDK does not shape (description, tools,
endAsk...), and playbookStageBody leaves out the parser's defaults (a null check, an empty goal), so a playbook
written back unchanged reads back the same. Every stage needs a category (POD_STAGE_CATEGORIES): a stage stored
before categories reads as null and is refused here until you give it one.
Not here yet, because the pod has no door for them: skipping or retrying one stage of a run, and a run's own
"needs you" summary. Read what waits on the person with listOwedSlots.
The mock pod (createMockAuthServer) answers these doors the pod's way (cancel's alreadyClosed, 202 proposed for an
agent token, the 409s, the hold rule, an undeclared stage), so an app's mock mode exercises the same states. A seed
session's mock-only inFlight: { queued, turns, taken } fills the cancel record; gateHeld: true holds it at a gate.
Mock pod: @synap-core/sdk/testing
A fake fetch that answers the public doors (forms, shares, sites) from fixtures,
with no network, for tests, a site's mock mode and a states gallery. Inject it
into the real client so everything above the wire is the production path:
import { createPublicClient } from "@synap-core/sdk/public";
import { createMockPod, MOCK_TOKENS, MOCK_FAILURE_TOKENS } from "@synap-core/sdk/testing";
const pod = createMockPod(); // or createMockPod({ sites: { mine: { … } } })
const pub = createPublicClient({ podUrl: pod.podUrl, fetch: pod.fetch });
await pub.listCollection(MOCK_TOKENS.site, "paper", { offset: 5 }); // demo site, 12 papers
await pub.getSite(MOCK_FAILURE_TOKENS.rateLimited); // { ok: false, error: "rate_limited" }
pod.requests; // every request (method, url, headers, credentials)It mirrors the doors: the uniform 404, the constant 202 (422 for missing
required or off-list answers), the collection query bounds, weak ETags and 304.
A credentials: "include" request rejects, as it would in a browser;
createMockPod({ browser: true }) also rejects headers a CORS preflight refuses.
React
For React apps use @synap-core/react which wraps this SDK with TanStack Query hooks.
API reference
Full procedure reference: doc.synap.live/docs/reference/hub-api
1.20.0 (additive): a kind's flow, to draw a board
pod.flow(profileSlug, { workspaceId }) reads a kind's or role's flow in one
call: its states in declared order with the stages that move things into each,
the cards in each (who works them, their next step, their blocker) and the
things in no declared state. nextStepOf(flow, card) says what "Run next step"
does for a card, and runNextStep(pod, flow, card, { workspaceId }) runs it
through playbooks.run. Ask for entity.<kind>.read (facet.read for a role)
and playbook.read / playbook.run in requests. The root SDK has
flow.of(client, input); @synap-core/hooks has useFlow. AGENTS.md, "Read a
kind's flow", draws a board from it.
