# Yroun Open API > Yroun — "Ways to see." Programmatic control of a Yroun hub — posts, pages (rich Tiptap content + live-data widgets), chat, schedules, series, webhooks, media. Everything in the hub UI is also a REST endpoint. Authenticate with an X-API-Key you mint in the developer console (console.yroun.com); a key can be scoped to selected hubs. Base URL: `https://oapi.yroun.com/hubs/{hubUid}` Machine-readable OpenAPI spec: `https://oapi.yroun.com/openapi.json` ## About Yroun YROUN Corp. (주식회사 이로운), also written 이로운. - Today Briefing — what is happening around the world - Finance — indices, stocks, FX, market analysis - Hub — a hub linking people and AI, from notes to control - Series — romance, fantasy, SF, thriller - Mate — study, exercise, a mate for your day - Play — action, strategy, puzzle, board ## What agents can do here Yroun treats AI agents as customers: identified, served, metered, revocable. - Discover: `GET https://oapi.yroun.com/hubs` lists the hubs your credential can act on; each hub's pages expose a title+description semantic tree so you can navigate without fetching bodies. - Act: everything in the Yroun UI is a REST endpoint — read and write posts, rich pages with live-data widgets, chat, schedules, and serialized fiction (series) through this one API. - Authenticate: a hub owner or admin mints an X-API-Key in the developer console (console.yroun.com); keys can be scoped per hub and revoked any time. OAuth 2.0 (PKCE) is available for user-delegated apps. - Cost: metered by the unified credit system (1 credit/request by default) — the same pool and headers a human developer gets. No separate agent tier, no blocking. - Ask: confused by anything in these docs, or hit unexpected behavior? File a support inquiry with your own key — POST /hubs/{hubUid}/support/inquiries against the hub you are integrating with, or against the Yroun Guide hub (hubUid 24e1f44e04bd43cda4877f834f10c1d6) for platform/API questions. The answer arrives as messages on the thread: poll GET /hubs/{hubUid}/support/inquiries/{threadUid} (agents receive no push). authorType AUTO = automated answer; POST .../escalate to insist on a human. - MCP: `@yroun/mcp` (npm) gives tool-native access — 31 tools over the same OAuth + Open API surface. Claude Code: `claude mcp add yroun -- npx -y @yroun/mcp@latest`; Claude Desktop/Cursor: mcpServers entry `{ "command": "npx", "args": ["-y", "@yroun/mcp@latest"] }`. Always register with @latest so npx re-resolves the newest release each launch (a bare spec can stay pinned to npx's cache); check the running version any time with the `yroun_auth_status` tool. Stuck on an old version despite @latest? npm min-release-age (pnpm minimumReleaseAge) can hold back young releases — register an explicit version (@yroun/mcp@x.y.z) — or clear a stale cache with `npx clear-npx-cache`. First run: call the `yroun_connect` tool (browser OAuth, grant persists). Full tool table: https://www.npmjs.com/package/@yroun/mcp ## Authentication `X-API-Key: yroun_key_xxxxxxxxxxxx` - Mint + manage keys in the developer console: console.yroun.com → API Keys. - A key may be restricted to selected hubs (GitHub fine-grained-PAT style); an unrestricted key acts on any hub you own. - Hubs you ADMIN but do not own are reachable too — but only through a key that names them explicitly in its hub list. An unrestricted key never reaches them, so an existing key can never gain access to someone else's hub without a deliberate change. Admin access covers content (pages, posts, series, schedules, media, place curations); hub snapshots, webhooks and chat stay owner-only. - PUBLIC hubs are READABLE by any scoped key — posts, pages, and schedules. Page reads honor per-page visibility: you receive exactly what a signed-out web visitor sees (PUBLIC pages), while the owner and scoped admins see their full tree. Writing always requires ownership or scoped admin access. - Missing/invalid key → 401. Send the key only as the X-API-Key header. - A hub PIN protects the web session only — it does NOT gate the Open API. An authorized key (ownership or scoped admin access, plus hub scope) reaches PIN-protected resources without any PIN step. - OAuth 2.0 (Authorization Code + PKCE, S256 required): authorize at https://oauth.yroun.com/oauth/authorize, exchange at POST /oauth/token (access 1h, refresh 30d single-use rotating), identity at GET /oauth/userinfo → {sub, email, name, locale}. `sub` is the account's immutable identifier; `email` is its sign-in address (null only when the account has none) — ownership is confirmed by verification code at sign-up, but the response carries NO email_verified claim yet (the OIDC claim is planned; until it ships the payload cannot distinguish the rare unverified account). Key by `sub` and match on `email` — email is verified but user-changeable. Scope `identity` = userinfo only (the minimal "Sign in with Yroun / confirm the viewer's email" scope — zero API access). Users disconnect at console.yroun.com/connections. Metadata: https://oauth.yroun.com/.well-known/oauth-authorization-server. - Registering your OWN app is self-serve: console.yroun.com → Your Apps. Two fields are required — an app name and one redirect URI — and a client_id is issued immediately; there is no review and no inquiry to file. A privacy-policy URL, terms link, logo and description are optional and editable later. Pick where the app runs: a browser/mobile/extension client gets NO secret and uses PKCE, while a server-side app is issued a client secret shown exactly once (we store only its hash — rotate to get a new one). Picking one of your hubs prefills the app name and image, which is what the consent screen shows. Everything the console does here is also an Open API capability your own key holds — see /oauth-apps in the server-level group — so registering and rotating can be automated and never requires a browser session. Full walkthrough: Guide hub → Open API → OAuth & Widget Viewer Identity. ## Credits - Every request consumes 1 credit by default, drawn in order: session → weekly → balance. - Each response carries X-Credits-* headers with running totals + reset times. - All three pools empty → 429 with the reset time in the body. ## Endpoints All paths are relative to the base URL above. ### Server-level (not hub-scoped) - `GET /hubs` — Discovery entry — list the hubs this credential can act on: { items: [{ uid, name, type, imageUrl, description, totalMembers }], nextCursor, hasMore }. Params: q? (name filter), cursor?, limit? (default 20, max 100). A hub-scoped key sees only its allowlisted hubs. Start here when you do not yet have a hubUid. - `POST /widget-tokens/verify` — Verify a YrounWidget viewer token that an openid-mode widget presented to YOUR server. Body { token, includeMembership? } -> { valid, viewerId, hubUid, membership? }. viewerId is a per-hub PAIRWISE pseudonym — stable for that viewer on that hub, never a Yroun account id, no cross-hub correlation. valid:false means bad/expired (tokens live 5 minutes — verify per request). includeMembership:true (2026-08-21) adds membership { isMember, authority } for the hub the token names — a JOINED member discloses its authority (e.g. ADMIN), everyone else is just isMember:false — so a multi-admin dashboard can make its policy "hub admin passes" with no hand-maintained allowlist. Non-members stay fully pseudonymous. Billed to the calling key (1 credit per verification). - `GET /oauth-apps` — List the OAuth apps this key owner registered: [{ clientId, name, description, clientType (CONFIDENTIAL|PUBLIC), redirectUris, scopes, hasSecret, createdAt }]. clientSecret is never present here — it is returned only by register and rotate-secret. - `POST /oauth-apps` — Register an OAuth app so other people can sign in with Yroun. Required: name, redirectUris (at least one https URI, matched exactly at /oauth/authorize). Optional: clientId (derived from the name when omitted), description, scopes (defaults to ["identity"]), logoUrl, privacyPolicyUrl, termsUrl, confidential (default false). confidential:true is a server-side app and receives clientSecret in THIS RESPONSE ONLY — we store a hash, so persist it now; false is a browser/mobile/extension client that authenticates with PKCE and gets no secret. The app is owned by the calling key owner; you cannot register on behalf of anyone else. Capped per plan (Free 1 / Air 3 / Pro 10). - `POST /oauth-apps/{clientId}/actions/rotate-secret` — Issue a new client secret for a confidential app you own; the response is the only place it appears, and the previous secret stops working immediately. 400 for a PUBLIC app, which holds no secret. - `DELETE /oauth-apps/{clientId}` — Revoke an app you own. Soft delete — existing tokens stop working at once. Someone else's app answers as not-found rather than forbidden, so this endpoint never confirms that a clientId exists. - Paths in this group are relative to the server root (https://oapi.yroun.com), NOT to /hubs/{hubUid}. ### Hub - `GET /` — Hub resource: { uid, name, handle, description, visibility (PUBLIC|PRIVATE), type, imageUrl, email, homepage, lat, lng, countryCode, totalMembers, minRole } - `PATCH /` — Update hub settings (partial — omit a field to keep it): name, description, email, homepage, lat, lng, countryCode, visibility. Returns the updated resource. A key can set visibility=PRIVATE but NOT PUBLIC (see Errors). `handle` is NOT settable here: every hub has a free permanent handle, and a custom one comes with a paid plan and is taken from the handle store — sending it returns 400 with the purchase path. Re-capitalizing the handle you already hold is free and does go through this endpoint. - `POST /subscribe` — Subscribe the caller to this hub - `GET /members` — List members. Query params: cursor (opaque, from a prior response nextCursor), limit (default 20, max 100), authority (optional filter, e.g. ADMIN), includeViewerId (boolean, default false). Response: { items: [{ userUid, email, authority, status, displayName, handle, userHubUid, joinedAt, viewerId }], nextCursor, hasMore }. authority is the enum name string, one of OWNER | ADMIN | MANAGER | MODERATOR | STAFF | VIP | SPONSOR | DIAMOND | PLATINUM | GOLD | SILVER | BRONZE | MEMBER. status is JOINED | PENDING (invited, not yet accepted) | DECLINED; PENDING members DO appear in the list and status is the AUTHORITATIVE pending signal — never infer it from userUid (legacy pending rows have userUid null; invites created since 2026-08-21 reference an account from creation, so userUid may be present). viewerId is always null for PENDING. viewerId is present only when includeViewerId=true (owner/admin surface, 2026-08-21): each JOINED member own-hub pairwise viewerId — the exact value widget-token verify returns for them — for pre-provisioning partner-side allowlists. Members only; non-member viewers have no lookup path. - `GET /members/me/pen-name` — The key owner's pen name in this hub — the byline on series they publish here, so one account can publish as a different creator per hub. Returns { penName, effectiveName, effectiveSource (HUB|ACCOUNT|HANDLE), seriesCountInHub, accountUsesImprints }. effectiveSource=ACCOUNT means this hub has set none and is inheriting the account default — worth flagging only when accountUsesImprints is true (the account publishes under a different name elsewhere); for a single-identity account it is the intended steady state. - `PUT /members/me/pen-name` — Set or clear this hub pen name { penName } (null/blank clears it). Resolved at read time, so a change re-bylines every series the caller already published in this hub — seriesCountInHub reports how many. Per-series overrides win over this; the account default is the fallback. 400 when the name reads as Yroun itself or as someone speaking for it — the response names the term that matched. Impersonating another PERSON is deliberately not screened here: that is a report, not a rejection. ### Posts - `GET /posts` — List posts (cursor) - `POST /posts` — Create a post { content (Tiptap JSON string), topicName?, tags?, mediaUids? } - `GET /posts/{postUid}` — Get one post - `PUT /posts/{postUid}` — Update a post - `DELETE /posts/{postUid}` — Soft-delete a post - `POST /posts/{postUid}/likes` — Like - `DELETE /posts/{postUid}/likes` — Unlike - `POST /posts/{postUid}/replies` — Reply { content } - `POST /posts/{postUid}/quotes` — Quote { content, tags? } - `POST /posts/{postUid}/reposts` — Repost ### Pages - `GET /pages` — List pages (metadata only — no content field; each item carries title, description, parentUid). On a PUBLIC hub any scoped key may call this and receives the PUBLIC pages; owners/admins receive their full tree. - `GET /pages/{pageUid}` — Get one page incl. content (+ description). Same reach as the list: PUBLIC pages of PUBLIC hubs are readable by any scoped key. - `POST /pages` — Create a blank page { parentPageUid? } - `PUT /pages/{pageUid}` — Update title/description/icon/content/visibility/parent/order (partial — omit a field to preserve it) - `DELETE /pages/{pageUid}` — Soft-delete a page - `GET /pages/{pageUid}/translations/{lang}` — Get one language's translation ({ title, content }) — 404 if none exists - `PUT /pages/{pageUid}/translations/{lang}` — Upsert a translation { title, content (Tiptap JSON string) }. The hub serves each visitor their preferred language automatically; the base content is the fallback when no translation exists for their language. lang = 2-letter code (ko, ja, en, …). Repeat per language. - `description` (max 500 chars, plain text, one line): together with `title` it is the page’s semantic index entry — the handle by which AI agents and humans pick the right page from a list without loading its body. On PUT, send `description` only when changing it; an omitted field preserves the current value (never send "" to mean "keep"). - `icon` is a dedicated one-emoji field, rendered before the title in the sidebar — put the page’s emoji HERE, never prepended into `title`. A title like "💰 Tokenomics" is the wrong shape; send `title` "Tokenomics" + `icon` "💰". `coverImageUrl` (full-width header image) and `isShowcase` (surface the page in the hub’s public Recommended Pages) are likewise dedicated PUT fields, not text baked into the title or body. - Navigating a hub’s pages as an agent: the list gives you the title + description + parentUid tree (no content) → pick the page → GET the single page for its body. Well-written descriptions are what make this navigation cheap — one list call instead of N body fetches. ### Chat - `GET /chat/rooms` — List rooms - `POST /chat/rooms` — Open a direct room { targetHubUid } - `GET /chat/rooms/{roomUid}/messages` — List messages (cursor) — each message carries messageUid, a stable id for that message; null on history predating 2026-08-05 - `POST /chat/rooms/{roomUid}/messages` — Send a message { roomUid, body } - `POST /chat/group-rooms` — Create a group room { name } ### Webhooks - `GET /webhooks` — List webhooks - `POST /webhooks` — Create { roomUid, name } → returns a one-time secret URL - `DELETE /webhooks/{webhookUid}` — Delete ### Connections (server-side widget auth — owner, or admin via a hub-scoped key) - `GET /connections` — List — responses carry a masked secretHint, NEVER the secret - `POST /connections` — Create { name, baseUrl, authHeaderName?, authScheme?, secret, accessLevel? (PUBLIC|MEMBERS|OWNER_ONLY, default OWNER_ONLY) }. Then reference it from widget attrs via connectionRef: name — the proxy injects the secret server-side. - `PATCH /connections/{connectionId}` — Update by numeric id (from list/create responses) — omit secret to keep the stored one - `DELETE /connections/{connectionId}` — Soft-delete (numeric id) ### Schedules - `GET /schedules` — List schedules (calendar events) - `POST /schedules` — Create - `PUT /schedules/upsert` — Idempotent upsert by externalId - `DELETE /schedules/{scheduleUid}` — Delete ### Places (curations) - `GET /places` — List this hub’s place curations (cursor+limit≤100). Each item: { place { uid, name, lat, lng, placeType, address, countryCode }, label, tier, edition, note, sourceUrl, externalId, attributes, updatedAt } - `GET /places/{placeUid}` — Get one curation - `PUT /places/upsert` — Curate a place onto this hub (idempotent — one live curation per place). body { place: { uid? | externalRef? | (name, lat, lng, placeType, countryCode, address?, phoneNumber?, homepage?) }, label?, tier?, edition?, note?, sourceUrl?, externalId?, attributes? } → { placeUid, placeCreated, curationCreated } - `DELETE /places/{placeUid}` — Remove the curation (soft delete — the canonical place always survives) - Places are a SHARED canonical pool and their own entity — a place is NOT a hub, so curating one never creates or claims a hub. A restaurant listed by two guide hubs is ONE place with two curations. Place resolution order on upsert: place.uid (an existing place) → place.externalRef (your stable external key, e.g. "osm-node-123"; an ambiguous ref is rejected rather than guessed) → create-or-match by name + coords (same placeType within ~150m + same normalized name reuses the existing place; otherwise a new place is created). - Curation fields are omit-to-preserve on an existing row: a field present in the body overwrites, an absent field keeps the stored value. `tier` is your guide’s internal rank (1 = highest); `label` is the display award ("3 Stars", "리본 2개"); `attributes` is a free JSON object for guide-specific extras. - Curations from PUBLIC hubs surface on yroun.com/maps: the Guides filter, award badges on pins, and the place popup’s guide list. This is the ecosystem surface behind Michelin / Blue Ribbon / Tabelog on Maps — your hub can curate places with the exact same endpoints. ### Media - `POST /media/upload-init` — Request a presigned S3 URL. Add `?library=true` to file the asset in the hub library instead of leaving it unattached. - `POST /media/upload-complete` — Finalize the upload → mediaUid. Pass the same `?library=true` you used on init. - `GET /gallery` — Every media asset this hub holds, newest first — `cursor` (numeric row id) + `limit` (default 20, max 100), optional `mediaType=IMAGE|VIDEO`. Items carry explicit derivative URLs — render these in grids, never the asset itself: `thumbnails {small,medium,large}` (fixed WebP ladder t320/t640/t1280; null until produced), `thumbnailUrl` (single poster fallback), `playbackUrl` (HLS playlist for VIDEO). Returns { items, nextCursor } where a null nextCursor IS the end (there is no hasMore). - The gallery accumulates on its own: media attached to a post lands in it when the post is created, with no extra call. The hub owner sees it as the Photos menu at hub.yroun.com/{hubUid}/gallery. - The library (`?library=true`) is the lane for assets that exist BEFORE they are published — a generated render awaiting review, a direct upload you have not posted yet. Library assets are exempt from the unattached-upload sweep that reclaims abandoned uploads, so an asset you file there survives until you use or delete it. Owner-authenticated gallery reads include them; a visitor sees only what has been posted. ### Snapshots - `POST /snapshots` — Capture { label } → { uid, label, capturedAt, sizeBytes }. Snapshots pages + hub core (no posts/endpoints/vault in V1). 5MB cap; older soft-deleted (retention: last 10 + 30 days). - `GET /snapshots` — List (page+limit, newest first) - `POST /snapshots/{snapshotUid}/restore` — Full-hub restore. Auto-captures the current state first as `before-restore-…` so undo-your-undo is always reachable. - `DELETE /snapshots/{snapshotUid}` — Soft-delete ### Series - `GET /series` — List series (serial content) - `POST /series` — Create a series. `genre` (optional) must be a code from the closed vocabulary — FICTION: ROMANCE, ROFAN, FANTASY, MODERN_FANTASY, MARTIAL_ARTS, SF, THRILLER, MYSTERY, HORROR, HISTORICAL, DRAMA, SLICE_OF_LIFE · NON_FICTION: SELF_DEVELOPMENT, ESSAY, BUSINESS, TECH, SCIENCE, HUMANITIES, HEALTH, ECONOMY. Unknown genres are rejected with 400. - `POST /series/{seriesUid}/episodes` — Add an episode. Optional `authorNote` = reader-visible author’s note under the body (omit to preserve on the PUT upsert twin, "" to clear). - `PATCH /series/{seriesUid}/tier` — Change tier ### Support (requester side — YOUR key files inquiries against any hub) - `GET /support/topics` — Hub's inquiry topics { uid, name, description } - `POST /support/inquiries` — Open an inquiry { topicUid?, title, body }. Your key OWNER is the requester. 1 credit (support.create); max 10 new inquiries per hub per rolling 24h — add follow-up messages to an existing thread instead of opening new ones. - `GET /support/inquiries` — List YOUR inquiries in this hub (page/size, newest activity first) - `GET /support/inquiries/{threadUid}` — Thread + newest-first messages (pass the last id as `cursor` for older). Messages carry authorType REQUESTER | STAFF | AUTO — AUTO is a document-bounded automated answer, always labeled. - `POST /support/inquiries/{threadUid}/messages` — Add a follow-up { body } (reopens a closed thread) - `POST /support/inquiries/{threadUid}/close` — Resolve your inquiry - `POST /support/inquiries/{threadUid}/reopen` — Reopen it - `POST /support/inquiries/{threadUid}/escalate` — Insist on a human — ONE-WAY: permanently disables automated answers on this thread. - A hub may auto-answer strictly within its registered ANSWERING GUIDE — free-form material the operator writes (facts, rules, tone, what to hand off), not a fixed Q&A format. Automated answers never handle account, payment or security topics (deterministic server pre-screen), and every send is server-gated (scope + evidence containment + caps) — refusals fall back to a human-sent reply. - Checking the answer as an agent: replies land as thread messages (agents receive no push) — poll GET /support/inquiries/{threadUid}; a new message with authorType STAFF or AUTO is the reply. Platform/API questions go to the Yroun Guide hub (hubUid 24e1f44e04bd43cda4877f834f10c1d6) — it is PUBLIC, so any scoped key can file there. - Last resort: an inquiry left unanswered (72h after escalate, 7 days otherwise) — or an account that cannot sign in to file one — can email support@yroun.com with the thread uid as reference. ### Support auto-reply config (staff side) - `GET /support/config` — Answering guide + toggle + review state { reviewStatus, reviewNote, hasApprovedGuide } + this month's usage { freeUsed, freeAllowance, paidUsed } - `PUT /support/config` — { answeringGuide?, autoReplyEnabled? } — omit a field to keep it; an empty guide clears AND disables. Guide cap 4000 chars (over-cap = 400, never truncated). A CHANGED guide is submitted for automated safety review and only grounds answers once APPROVED — the previously approved version keeps serving meanwhile, so automation never goes dark mid-edit. Poll reviewStatus on GET. Write links in the guide with the full https:// scheme: an automated reply may only contain links that appear in the approved guide and the match is on full URLs, so a guide saying example.com/x (no scheme) approves none and the first reply writing https://example.com/x is refused and falls back to a human draft. Free allowance 30 auto-replies/month, then 5 credits each from the owner balance. ## Content & widgets Post / page / episode `content` is a Tiptap document serialized with JSON.stringify (a string, not an object). That stringification applies to the TOP-LEVEL content field ONLY — inside the document, node attrs keep their native JSON types: columns, staticData, yAxisKeys, and bodyFields are real arrays, never nested JSON strings (a common agent mistake: `"columns": "[{...}]"` instead of `"columns": [{...}]`). Standard nodes: paragraph, heading (levels 1-3), bulletList / orderedList (listItem > paragraph), codeBlock (monospace; renders as a dark panel), blockquote, horizontalRule, image (attrs.src, attrs.alt), and table (table > tableRow > tableHeader | tableCell > paragraph) — full JSON shapes with examples live on the Guide hub → Content & Widgets page. Inline marks go on a text node's `marks` array: bold, italic, code, strike, underline, highlight, and link — link carries attrs.href, e.g. {"type":"text","text":"docs","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}. Markdown is NOT parsed: send "[docs](https://example.com)" and the brackets render as literal text. One exception is reconciled for you: a text node wrapped in backticks is stored as inline code with the backticks removed, so never send both the mark and the syntax. Use a node or mark type outside this vocabulary and that node is dropped when the page is read — the rest of the page still renders, and the hub owner is told what was hidden — so stay within the list above. Mind one inconsistency: the chart node is chartBlock, NOT apiChartBlock, despite its api-prefixed siblings (apiChartBlock is accepted on write and stored as chartBlock). Pages can also embed six live-data widget nodes that fetch from your API in the reader’s browser: - `apiTableBlock` — Paginated, filterable, optionally editable / CRUD-able table — from an API or from fixed inline rows. - attrs: title, apiUrl, rowKey, pageStartOffset, isWide?, dataSource? ('api' default | 'static'), staticData[]? — array of row objects rendered directly when dataSource='static'; no API call is made, endpointRef? + apiPath? — resolve the URL against the hub Endpoints registry instead of a raw apiUrl, connectionRef? + apiPath? — fetch through the hub Connection server-side proxy: the hub owner stores the external API credential once (encrypted), the Yroun server injects it per request, and the secret never reaches any browser, apiMapping {reqPageNum,reqPageSize,reqSort,reqSearch,resItems,resTotal}, updateUrl? (default = `{apiUrl}/{rowKey-value}` when omitted), updateMethod? (PATCH | PUT — default PATCH), updatePayloadMode? (default `auto` — PUT sends full row, PATCH sends changed-only; `full` / `changed` overrides per widget), sendFullRowOnUpdate? (legacy bool — superseded by updatePayloadMode; still honored when updatePayloadMode=auto), createUrl?, createMethod? (POST | PUT | PATCH — default POST; set PUT for register-as-upsert endpoints), deleteUrl?, readOnly? (bool, default false) — declares the table a read-only view: inline cell editing, row selection/delete and the save bar are withdrawn, and New is shown DISABLED with the reason (a viewer of the published page sees no write chrome either way). Leaving createUrl/updateUrl/deleteUrl blank does NOT do this: each falls back to the list apiUrl by REST convention, so an omitted URL cannot express "this endpoint is GET-only", columns[{ key, label, width, pinned?, hidden?, sortable?, type? — 'string' (default) | 'number' | 'boolean' | 'time' | 'img' | 'link' | 'select' | 'multiselect', 'img' renders the cell value as a 40px thumbnail; 'link' renders a clickable anchor — hrefKey? names the row field holding the URL (omit = the cell value IS the URL); 'select' + options[] → enum dropdown in the editable cell AND the create/update modal (single choice); 'multiselect' + options[] → checkbox-chip group in the modal, picks serialized as a CSV string (for CSV-typed API fields); (NOTE: 'datetime' does NOT exist — render dates as `time`), editable?: bool — true → cell becomes click-to-edit; the widget sends PATCH `{field: newValue}` to updateUrl or the default `{apiUrl}/{rowKey-value}`. The server MUST expose a matching PATCH endpoint that accepts the field. isselector?: bool — legacy alias of type:'select' (still honored). options?: string[] — used by 'select' / 'multiselect' / isselector. health? {warn?, danger?, direction?:`higher-worse`|`lower-worse`} → renders a numeric cell as a green / amber / red badge }] - apiMapping is the single lever for the RESPONSE shape and for the paging / sort / search parameter NAMES. It does not cover column filters — those go out under the column key itself (see the filter contract below). The widget does no auto-detection — at runtime it just reads `json[resItems]` for the row array and `json[resTotal]` for the page total. If the table renders empty while your endpoint returns rows, this mapping is wrong. - Defaults match the standard Spring `Page` shape — `{ content: [], totalElements, number, size, ... }` — so `resItems = "content"`, `resTotal = "totalElements"`, `reqPageNum = "page"`, `reqPageSize = "size"`. Most Java/Kotlin backends (Spring, jOOQ, MyBatis with PageHelper, JPA) emit exactly this shape; no override needed. - For deeply nested envelopes, use dot paths — e.g. Yroun-internal endpoints wrap the page under `data`, so set `resItems = "data.content"` and `resTotal = "data.totalElements"`. The widget walks dot-separated keys top-down. - For paginated APIs that page on 0 (Spring) the default is correct; for APIs that page on 1 (Laravel-style) put `reqPageNum = "page"` AND set `pageStartOffset = 1` on the widget root so the request number matches what the server expects. - For non-paginated endpoints that return the array directly, leave `resTotal` blank and the widget treats `numberOfElements` as the total. If your endpoint returns the array as the JSON root with no envelope, leave `resItems` blank too — the widget will use the root array. - Row deletion UX (2026-07-18): in edit mode a leading checkbox column selects rows (header checkbox = select all on the page) and a floating bulk bar offers Delete/Clear. Delete STAGES the rows (struck through, per-row undo) and commits on "Save changes" as batched DELETE calls to `deleteUrl` (or the `{apiUrl}/{rowKey-value}` fallback). The old per-row trash button is retired; the trailing column now only hosts the row-Edit shortcut and the delete-mark undo. The widget title renders on its own row above the controls. - When write controls appear (API mode, edit permission): New acts only when the create form resolves to at least one field — that set is `columns[] where editable=true` plus `formFields.create`. With no fields, and with `readOnly: true`, the button is still rendered but DISABLED and carries the reason on hover, so the capability and the way to switch it on stay discoverable; it is never removed from the toolbar. A page viewer sees no write chrome at all, which is unchanged. - Column filters (API mode) — the contract, spelled out because it is not in attrs and not in apiMapping. The filter bar renders whenever dataSource='api', for VIEWERS as well as editors, and its column dropdown is every non-hidden entry in columns[]. There is no filterable flag to set and no reqFilter mapping: a column is filterable because it is in columns[]. Each active filter goes out as ONE query param whose NAME IS THE COLUMN KEY VERBATIM and whose value is the raw typed string — GET {apiUrl}?page=0&size=10&status=APPLIED&kind=DAILY. So name your columns after the params your endpoint already accepts; there is no way to remap a key to a different param name. Equality is implied and no operator is ever sent (no LIKE, no ranges, no _gte). One filter per column — a second value on the same column replaces the first, so a from/to range is not expressible. Filtering is server-side, so it spans the whole dataset rather than the loaded page, and filter state is runtime-only — a page cannot ship pre-filtered. Input control by column type: boolean gets a yes/no select, time a date picker, number a number input, and any column carrying options[] (type:'select' / 'multiselect' / isselector) gets a dropdown of those options so an enum filter cannot be mistyped; everything else gets a plain text box. - Static mode (dataSource='static', 2026-07-27): sort, keyword search, and pagination run client-side over staticData — no server round-trip, no auth config needed. CRUD (create/update/delete), column filters, and the refresh button are API-only and hidden (static rows have no write target). apiMapping is ignored in static mode. Columns[] works identically — use it for labels, types, health badges, and pinning over your fixed rows. - `chartBlock` — Line / bar / pie chart from an API or from fixed inline rows (node type is chartBlock, not apiChartBlock). - attrs: title, type (line|bar|pie), dataKeyX, yAxisKeys[] dataSource ('api' default | 'static') staticData[] — array of row objects drawn directly when dataSource='static'; no API call is made API mode: apiUrl, dataRootKey, endpointRef?+apiPath? (hub Endpoints registry), connectionRef?+apiPath? (server-side connection proxy — secret never reaches the browser) - `apiRequestBlock` — Postman-style action button. - attrs: title, apiUrl, method (GET|POST|PUT|PATCH|DELETE), bodyMode (none|form|json), endpointRef?+apiPath? (hub Endpoints registry), connectionRef?+apiPath? (server-side connection proxy — secret never reaches the browser), bodyFields[{key,label,type:'text'|'textarea'|'number'|'select'|'checkbox', options?, required?}] — form mode, rawBodyTemplate (string, JSON text) — json mode's request body, e.g. '{\n "userIds": []\n}' - bodyMode json: the widget renders an editable JSON textarea seeded from rawBodyTemplate; the reader adjusts it and clicks Send. Sent VERBATIM with Content-Type: application/json — use it for array or nested payloads form fields cannot express (e.g. {"userIds": [1, 2, 3]}). - bodyMode form: on GET, fields are sent as a query string. On POST/PUT/PATCH they are serialized as a flat JSON object (Content-Type: application/json); a file/image field switches the whole body to multipart form-data. - Path-param interpolation is NOT supported — the apiUrl is sent verbatim. For endpoints that take an entity uid in the path, expose a query-param wrapper server-side (e.g. `/series-evaluations/by-uid?uid=…`) and bind `uid` as a bodyField. - `apiQueueBlock` — Status-grouped, auto-refreshing work queue (kanban-style operator surface). - attrs: title, apiUrl, pageSize (default 50), pollIntervalSec (default 30), endpointRef? + apiPath? (hub Endpoints registry) / connectionRef? + apiPath? (server-side connection proxy) — same URL-resolution trio as apiTableBlock, apiMapping {reqPageNum,reqPageSize,resItems,resTotal} — same contract as apiTableBlock, groupByField (default `status`) — row field to bucket rows by, groupOrder[] — display order of group values; unknown values render after, in arrival order, groupLabels {value: label} — pretty label per group value (falls back to the raw value), groupBadgeColors {value: tailwindClasses}, groupHeaderColors {value: tailwindClasses}, rowColumns[{key, label?, width?, type?}] — type 'datetime' renders relative age ('3m ago'), rowKey (default `uid`), perGroupMax (rows per group before the Load-more control; default 5), clickEventName (default 'yroun:queue-click'), clickEventTopic (default 'default'), clickNavigateTemplate? — URL template interpolated with row fields, e.g. '?item={uid}'; `?`-prefixed templates update the page URL without a reload (drives sibling widgets) - Operator surface: the widget fetches only for the hub owner and admins — other viewers see the frame without data. Place it on an admin/staff-visibility page. - Row click dispatches a window CustomEvent {detail: {topic, row, sourceWidgetId}} so a sibling widget on the same page (e.g. a detail panel) can react; clickNavigateTemplate is optional on top. - Polls every pollIntervalSec — state transitions (PENDING → READY) appear without a manual refresh. - Configurable in the page editor: the block header gear opens an inline settings panel with the shared Endpoint/Connection picker (endpoint, auth, grouping; JSON fields under Advanced) — no API call needed to set it up. - `faqBlock` — Accordion FAQ (frequently-asked-questions) — from fixed inline items or an API. - attrs: title (default 'FAQ'), dataSource ('static' default | 'api'), staticData[{question, answer}] — rendered directly when dataSource='static'; no API call is made, API mode: apiUrl, endpointRef?+apiPath? (hub Endpoints registry), connectionRef?+apiPath? (server-side connection proxy — secret never reaches the browser), apiMapping {resItems, resQuestion, resAnswer} — items-array key + per-item field names (defaults: content / question / answer), authSource? ('local' default | 'openid' | 'shared'), sharedKeyName? - Each question renders collapsed; clicking expands the answer (multi-open, aria-expanded). Answers render plain text with line breaks preserved — no markdown. - Entries missing a question or an answer are skipped, never crash the widget; the list is capped at 100 items. - Configurable in the page editor: the block header gear opens an inline settings panel — static mode edits items directly, API mode uses the shared Endpoint/Connection picker + field mapping. - `statGridBlock` — Grid of numeric tiles (KPI / monitoring) — from fixed values or an API, with an optional delta badge and threshold status per tile. - attrs: title (default 'Stats'), subtitle?, columns? (0 = auto, max 4), isWide?, dataSource ('static' default | 'api'), tilesFrom ('response' default | 'config') — api mode only, tiles[] — the tile list. In static mode each entry carries a literal {key?, label, value, unit?, format?, precision?, delta?, deltaUnit?, caption?, direction?, warn?, danger?, href?}. In config mode each entry may instead carry valuePath / deltaPath (dot paths into the response, array indexes allowed: series.0.value), API mode: apiUrl, endpointRef?+apiPath? (hub Endpoints registry), connectionRef?+apiPath? (server-side connection proxy — secret never reaches the browser), apiMapping {resItems (default 'items'), resRoot?} — where the metric list lives; an empty resItems means the response body IS the array, fieldMap {key,label,value,unit,delta,deltaUnit,caption,direction,format,precision,warn,danger,href} — YOUR response field names, so an endpoint you already shipped needs no change; dot paths allowed, pollIntervalSec (default 0 = off; anything below 30 is raised to 30), authSource? ('local' default | 'openid' | 'shared'), authMethod?, sharedKeyName?, useAuthHeader? - direction is the metric's own property and decides BOTH the delta color and the threshold badge: 'lower-worse' (default) means higher is better, 'higher-worse' means lower is better, 'neutral' means no judgement and no status. A falling bounce rate renders green with a down arrow; a rising error count renders red with an up one. - format picks the rendering: compact (default, 23K) | count (23,000) | decimal | percent | duration (seconds in, 5m 41s out) | bytes | ms | text. A value that cannot be formatted renders an em dash, never a zero — a missing measurement and a measurement of zero are different facts. - warn / danger set thresholds on the tile value and render an ok / warning / critical pill. No thresholds means no status affordance at all. - Polling is off by default and floored at 30 seconds: every viewer of the page calls your endpoint on that schedule, so 100 concurrent readers at 30s is 200 requests per minute. The interval is jittered per widget so viewers do not tick in phase, and it pauses while the tab is hidden. - A failed refresh keeps the last successful values on screen, labelled with when they were read, above an error banner that always renders. - Configurable in the page editor: the block header gear opens an inline settings panel (Setup / Tiles) with the shared Endpoint/Connection picker, the refresh selector, and per-tile fields. - statGrid is accepted as an alias on write and stored as statGridBlock. - `bookmarkBlock` — Notion-style preview card for an external URL (title, description, image, favicon). - attrs: url (required — http/https only; other schemes are rejected), title?, description?, image?, siteName?, favicon? — OG meta snapshot, metaFetched? (bool) — set true when you supply the meta yourself; leave false/omit and the viewer fetches + snapshots the OG meta on first editable render - In the page editor: paste a bare URL to get a Link / Bookmark / Embed choice menu, or type /bookmark. Programmatic writes only need { type: "bookmarkBlock", attrs: { url } }. - Outlink safety: cards open in a new tab with rel="noopener noreferrer nofollow ugc"; clicks to non-yroun.com domains show a leaving-confirmation with the real destination domain. ### Widget auth & viewer identity — identify viewers on your server Need to identify the viewer on YOUR server? Two paths return the SAME per-hub pairwise viewerId: widget-token verify (openid — cryptographic proof, 1 credit per verification) and connection-proxy identity headers (connectionRef — trusted-proxy assertion, free, zero integration work). Pick verify when identity gates anything security-sensitive; pick the proxy headers for personalization/analytics or when your API key must stay server-side. Same viewerId either way, so you can switch later. IDENTITY IS AUTHENTICATION, NOT AUTHORIZATION: a verified token proves only that a signed-in Yroun account made the request and that this is its stable per-hub pseudonym. It does NOT imply hub membership, hub visibility, plan or entitlement — any signed-in account can obtain a token for any hub uid, including hubs it is not a member of. Authorize from your own viewerId allowlist/role table, and compare the hubUid in the verify response against your hub before trusting the viewerId. Widgets default to no auth — point them at a public endpoint. For a protected API, one question picks the mode: (1) YOUR OWN server -> authSource openid — the widget calls your endpoint with the header Authorization: YrounWidget (this scheme is fixed; any authMethod value in the widget attrs is ignored in openid mode), and you verify the 5-min YrounWidget token server-side with your own key (POST oapi.yroun.com/widget-tokens/verify {token} -> {valid, viewerId, hubUid}, metered to the verifier); per-viewer pairwise id, and a one-time OAuth `identity` consent upgrades it to an email link (Guide hub -> OAuth & Widget Viewer Identity). (2) Third-party key-based API with ONE hub-held key -> connectionRef — the hub stores the key encrypted and the server injects it per request; it never reaches a browser. Create the connection FIRST (POST /oapi/hubs/{hubUid}/connections {name, baseUrl, authHeaderName?, authScheme?, secret, accessLevel?} — owner key required, secret is write-only), then set connectionRef to its name. Proxied requests from signed-in viewers also carry X-Yroun-Viewer (the SAME pairwise viewerId as widget-token verify) + X-Yroun-Hub — absent means anonymous, so a connection host gets a server-held credential AND per-viewer identity together. (3) Each viewer sends their OWN personal token -> authSource shared (saved: the viewer is prompted in-widget to store it under sharedKeyName in their per-hub vault) or local (this-browser-only). NEVER invent a sharedKeyName for a hub-held secret — that key must exist per-viewer, so only the page author would ever see data; for one shared secret POST a connection and set connectionRef instead. NEVER put a raw key in node attrs — page content is publicly readable. ## Conventions - List endpoints return a cursor envelope: { items: [...], nextCursor, hasMore }. Pass nextCursor as the `cursor` query param. Default limit 20, max 100. - UIDs are 32-char hex, no dashes. - Timestamps are ISO 8601 UTC with a Z suffix. - Idempotency: PUT /schedules/upsert keys on externalId; for POSTs, de-dup client-side with your own stable key (no Idempotency-Key header today). - A page PUT is NOT free even with identical content (it spends a credit + re-renders). The list endpoint omits content — GET the single page to compare, and skip the PUT when unchanged. ## Errors - 200 OK · 201 Created · 204 No Content - 400 — malformed body / missing field / session token sent to /oapi - 401 — missing or invalid X-API-Key - 403 — your plan or hub access does not authorize this call. Three distinct causes, named in errorMessage: the key is not scoped to this hub (add it to the key’s hub list); the hub is neither owned nor admin-accessible to you; or the operation is owner-only (snapshots, webhooks, chat) and you are an admin. - 403 HUB_EXPOSURE_VIA_API_KEY_FORBIDDEN — PATCH / with visibility=PUBLIC on a PRIVATE hub. Exposing private content is effectively irreversible and requires interactive PIN confirmation in the web app, which an API key cannot carry. A key CAN set visibility=PRIVATE (lock down); make a hub public in the web app instead. (errorMessage states the cause + remedy.) - 404 — resource not found - 413 HUB_SNAPSHOT_TOO_LARGE — snapshot capture exceeds the 5MB cap (reduce page count or page content size, or capture per-section as a future enhancement) - 429 — credits exhausted (body names the pool + reset time) - 5xx — retry with exponential backoff ## More Full guides, request/response shapes, and copy-paste recipes live in the in-app Guide hub (Open API section). This file is the machine-readable summary for agents and tools.