@joai/sdk
v15.0.0
Published
JoAi public API SDK — build with your AI agents
Readme
@joai/sdk
The React entry point contains reusable building blocks for editable content on client websites. The components stay domain-neutral: sites provide identifiers, copy, styling hooks, and rendered fields; the SDK owns session handoff and the editing lifecycle.
Managed content editor
import { ManagedContentEditor } from '@joai/react'
<ManagedContentEditor
config={{
contentId: 'content-id',
publicSlug: 'website-content',
sessionStorageKey: 'website-content-session',
team: 'team-slug',
}}
initialContent={content}
title="Inhalte bearbeiten"
labels={{
edit: 'Bearbeiten',
save: 'Entwurf speichern',
saving: 'Wird gespeichert …',
publish: 'Veröffentlichen',
publishing: 'Wird veröffentlicht …',
cancel: 'Abbrechen',
toolbar: 'Inhaltseditor',
unsaved: 'Nicht gespeicherte Änderungen',
}}
messages={{
conflict: 'Der Inhalt wurde zwischenzeitlich geändert. Deine Eingaben bleiben erhalten. Bitte prüfe sie und versuche es erneut.',
loadError: 'Der Inhalt konnte nicht geladen werden.',
publishError: 'Die Änderungen konnten nicht veröffentlicht werden.',
published: 'Die Änderungen wurden veröffentlicht.',
saveError: 'Der Entwurf konnte nicht gespeichert werden.',
saved: 'Der Entwurf wurde gespeichert.',
sessionExpired: 'Deine Sitzung ist abgelaufen.',
unsavedNavigation: 'Du hast nicht gespeicherte Änderungen. Seite wirklich verlassen?',
}}
imagePickerLabels={{
applyCrop: 'Zuschnitt übernehmen',
back: 'Zurück',
choose: 'Bild auswählen',
close: 'Schließen',
cropDescription: 'Passe Bildausschnitt und Zoom vor dem Hochladen an.',
cropTitle: 'Bild zuschneiden',
dimensionsTooLarge: 'Die Bildabmessungen sind zu groß.',
dimensionsTooSmall: 'Die Bildabmessungen sind zu klein.',
drop: 'Bild hier ablegen',
empty: 'Noch keine Bilder vorhanden.',
field: 'Bearbeitbares Bild',
invalidType: 'Bitte wähle eine Bilddatei.',
libraryDescription: 'Wähle ein vorhandenes Bild oder lade ein neues hoch.',
libraryTitle: 'Bildmediathek',
loadError: 'Bilder konnten nicht geladen werden.',
loading: 'Bilder werden geladen …',
positionHorizontal: 'Horizontale Position',
positionVertical: 'Vertikale Position',
processing: 'Bild wird vorbereitet …',
retry: 'Erneut versuchen',
tooLarge: 'Das Bild ist zu groß.',
upload: 'Bild hochladen',
uploadError: 'Das Bild konnte nicht hochgeladen werden.',
uploading: 'Wird hochgeladen …',
zoom: 'Zoom',
}}
reauthenticateLabel="Anmeldung erneuern"
sessionExpiredMessage="Deine Sitzung ist abgelaufen. Deine lokalen Änderungen bleiben erhalten."
confirmCancel={() => window.confirm('Nicht gespeicherte Änderungen verwerfen?')}
confirmLeave={() => window.confirm('Du hast nicht gespeicherte Änderungen. Seite wirklich verlassen?')}
confirmPublish={() => window.confirm('Änderungen jetzt veröffentlichen?')}
>
{children}
</ManagedContentEditor>Use EditableContent, EditableImage, and useContentValue from @joai/react inside the editor. Pass server-fetched public content as initialContent to avoid a client-side loading state for visitors.
Configure each image field as a reusable, domain-neutral building block. Images are compressed to WebP at up to 2560 × 2560 pixels by default, while animated GIF and SVG files retain their original format. Cropping is opt-in per field:
<EditableImage
field="heroImage"
label="Titelbild"
maxSourceBytes={40 * 1024 * 1024}
maxUploadBytes={5 * 1024 * 1024}
processing={{
crop: { aspectRatio: 16 / 9, outputWidth: 1920 },
compression: { maxWidth: 1920, maxHeight: 1080, mimeType: 'image/webp', quality: 0.84 },
dimensions: { minWidth: 1280, minHeight: 720, maxPixels: 50_000_000 },
}}
/>Set compression: false or dimensions: false to disable only that stage, or processing={false} to upload the original image without client-side decoding, validation, cropping, or compression.
Session storage
useContentSession and createSessionTokenStore are exported for custom shells. A custom SessionTokenStore can be injected when a host needs a different persistence mechanism. The one-time authorization code is removed from the URL before the session token is persisted.
The managed editor starts the app authorization-code flow for reauthentication and stores the PKCE secret in local storage. Override reauthenticateUrl only when a host has a dedicated login callback.
createContentDraftStore uses sessionStorage by default. ManagedContentEditor wires it automatically so unsaved values survive refreshes and expired-session reauthentication without being shared across browser sessions.
Editing behavior
- Cancel restores the currently loaded version.
- Save is disabled when there are no local changes.
- A dirty publish saves and publishes with one request.
- Network failures keep local edits available for retry.
- Refresh, close, and link/navigation attempts warn while edits are unsaved.
- Editable text and image fields are visibly labelled in edit mode.
- The image library supports loading, empty, error, byte/dimension validation, cropping, compression, keyboard, focus, and drag-and-drop states.
- Expired authorization offers a sign-in action and restores locally persisted edits afterwards.
- Version conflicts load the latest version while preserving local edits.
- Public content cache entries are invalidated after authenticated mutations.
App authorization code
Apps that keep the bearer token on their server can use the SDK's PKCE helpers instead of implementing the flow themselves:
import { createAppAuthRequest, exchangeAppAuthCode } from '@joai/sdk'
const auth = await createAppAuthRequest({
callbackUrl: 'https://app.example/api/auth/callback',
team: 'team-slug',
})
// Store auth.state and auth.codeVerifier in a short-lived HttpOnly cookie,
// then redirect the browser to auth.url.
const response = await exchangeAppAuthCode({
code: callbackCode,
redirectUri: auth.redirectUri,
codeVerifier: storedVerifier,
})The callback origin must be registered for the team. The code is single-use, short-lived, bound to the canonical redirectUri, and verified with PKCE S256.
Hybrid guest carts
Storefronts can keep a guest cart without authenticating by generating an opaque cart token and passing it to the client:
const client = createClient({ env: 'mainnet', cartToken })
await client.cart.add('product', variationId, 2)The API accepts X-Cart-Token for cart.*. When the same token is sent with an authenticated request, the guest cart is merged into the user cart and deleted.
Same-origin BFF helpers
Browser apps can keep the bearer token in an HttpOnly cookie instead of exposing it to client JavaScript. These helpers live behind optional subpaths so the root SDK stays browser-safe:
import { createBffFetch } from '@joai/sdk/bff'
import { createBffSessionCodec, handleBffProxy } from '@joai/sdk/server'createBffFetchroutes SDK calls through a same-origin BFF path.createBffSessionCodecseals/unseals the bearer in an encrypted cookie.handleBffProxyforwards an allowlisted set of API routes with origin and CSRF checks.
