npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/sdk

Quick 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 id

Published 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, capturedAt

A 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_read and crud). Over a budget every call answers rate_limited with retryAfterSec (from Retry-After, else the RateLimit reset) 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 requires issuer equal to the address, CIMD, iss on answers and S256 (not_a_pod, pod_unsupported, pod_unreachable). A Synap Cloud name typed the old way (you.synap.live) also tries you.pod.synap.live, then pod.you.synap.live; another address also tries pod.<address>. A redirect is never followed (pod_moved when only an old, moved name answers). A browser launch (?iss=…&target_link_uri=…) is read with acceptLaunch and 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://localhost can sign in to the SDK's mock pod only (@synap-core/sdk/testing, allowInsecureLoopback). Every real pod, a local one included, refuses an http client_id and redirect_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 as synap_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, … (parseConnectErrorCode reads it back). connect and disconnect are POST and same-origin only.

  • Refresh (rotating, single-use, 30 days from issue) is feature-detected: a pod that issues no refresh_token gives a connection that ends with its 1-hour access token. refreshIfNeeded rotates 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-side ConnectionStore rules 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; its expires_in may 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 as store.

  • Lower level: startConnect, handleCallback, refreshConnection, revokeConnection, buildClientMetadata, seal/unseal, startCloudConnect, acceptCloudReturn.

1.4.0 (additive)

  • Re-consent: the connect form's prompt=consent (or startConnect({ 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, iss checked; a denied or forged answer leaves the live connection as it is.

  • Synap Cloud: with cloudUrl set, synap.cloudConnectAvailable is true and cloudConnectAvailable({ cloudUrl }) too. POST handlers.cloudConnect sends 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 CIMD initiate_login_uri?iss=<pod>&state=<nonce> (your connect page): acceptLaunch(url).cloudState is set, so forward the request's query to GET handlers.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. acceptLaunch itself 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, no iss) also carries cloudState: forwarded the same way, it lands on the connect page with ?error=denied once the state matched. Security notes: after the state matches, the named pod goes through the same discovery as a typed address (metadata, issuer equality, 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 OAuth state check 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's synap_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 with reviewUrl) from the pod's seamed GET /proposals door. Ask for proposal.read in requests: without it the answer is forbidden and 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) and RefreshOutcome.lapsed say "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: ensureTemplate on 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 with templates (CIMD synap_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 (proposed is success: show reviewUrl, 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 a proposed answer to a person (a space that reviews its members' changes) is still success.
  • actsAs is 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: null with a reason (agent_cannot_approve, not_granted, invalid_input) and send nothing: an agent approving, a missing proposal.read / proposal.approve, an id that could leave its path segment, listFocusSessions without a space or project.
  • Shapes are read from the backend routes (src/connect/hub-types.ts names each one). A 2xx answer of another shape is server (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 as ensure-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;

    unauthorized then means the pod refused the connection itself. A server component cannot set cookies, so it passes no persist: nothing is rotated there (the old refresh token would be spent and lost), and a 401 answers unauthorized with refreshable: true. Send that visitor through the proxy once with refreshIfNeeded(request, { force: true }) (it rotates whatever the expiry says; refused, it is revoked, never kept).

  • A revoked connection's cookie is cleared (revoked / expired with 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 (ConnectErrorCode and PodErrorCode): 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) as pod_unreachable.

  • Paths: a Hub path that could leave /api/hub (a dot segment, plain or percent-encoded, //, \, a query) answers invalid / invalid_input instead of throwing. Encode each path segment (encodeURIComponent).

  • buildClientMetadata refuses what the pod refuses, at setup: a *, a permission with no action, a qualifier on a subject other than entity (a kind), capability (an intent, whose one action is run) and event (a family), more than 48 requests counted with duplicates, a document past 8 KB, an IP address appUrl. 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), types per import/require (.d.ts / .d.cts), sideEffects (only the connect entry runs code on load), this package's CHANGELOG.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 cloud

1.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.all over reads that do not need each other's answer, never an await between them.
  • No per-row fan-out on first paint. getEntityConnections(id) (and any get* 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 (firstN 0-50 cards per state, windowDays 1-366, properties: true for each card's values); listEntities({ includeFacets: true }) puts a role's facets on each row; includeMedia the first files. Otherwise cap the rows that fan out, or draw the page first and stream the fan-out.
  • Page size. listEntities takes limit 1-1000 (the SDK clamps silently outside it; the pod's default is 20) and offset; a loop of limit: 100 is one round trip per 100 rows, one after the other. When the total is known, start the pages together; else raise limit. Other doors are narrower: playbooks.list limit 1-100 with nextCursor; runs.list / listFocusSessions limit 1-50, and runs.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 than limit when it caps below what you asked, instead of an error, has not been measured here. Before raising limit above 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) says GET /entities answers 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 headline rows.needs_you, the folds (working, stopped, review_later, from_system, changes_to_review), and count over the same rows. Prefer it to chaining listFocusSessions, listOwedSlots and the proposals (three fetches, three opinions about what "needs you" means). limit is 1-200 per list (the SDK clamps; the pod's default is 100); truncated says a list was cut. It needs session.read (without it the pod answers forbidden); a key bounded by a grant reads the asks and sessions of the spaces, projects and objects its grant names and the proposals only with proposal.read. withheld is optional: present only when the key did not read every half (it names them: notifications, the person's own captures, invites, proposals...), absent for the whole read. A half named there is missing from rows and count; draw it as "not shown to this app", never as "nothing waits". Needs a pod with backend GET /needs-you for apps (an older pod: door_unsupported or not_found).
  • Count without reading everything. There is no count door in this version: listEntities answers 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 is rate_limited with retryAfterSec. By default the client never retries (retryRateLimitedWithinSec is 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 is pod_unreachable) and maxResponseBytes (default 8 MiB) are PodClientOptions; a stream has its own timeout (PodStreamOptions.timeoutMs, default 120 s). Lowering timeoutMs makes a slow page fail visibly sooner; it never makes the pod faster.
  • Writes are never retried, and some read first. runs.reply is two calls in a chain (it reads the run for its room); when the page already holds the run, postRoomMessage(run.channelId, ...) is one. A proposed answer is never asked again (see "Runs and playbooks: acting").
  • Count your own calls. createRequestMeter() (exported from @synap-core/sdk/connect) over onRequest gives calls, failures, retries, pod time, wall time and depth per page or action; each event carries startedAt and durationMs so overlapping reads (started together) and back-to-back ones (a chain) can be told apart. Never log a query or a token: PodRequestEvent.path is 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.