artifakt-sdk
v0.3.1
Published
Embed the Artifakt review surface — anchored comments, versions, approvals — in any web app. Includes the write-only capture snippet for pinning feedback on your app's own UI.
Downloads
96
Maintainers
Readme
artifakt-sdk
Embed the Artifakt review surface — anchored comments, versions, approvals — in any web app. The SDK is a thin client of a Artifakt backend (hosted or self-hosted): your app mounts the review surface for one artifact; every comment, revision, and signature lands in the Artifakt record, and connected agents pick feedback up over MCP exactly as they do for the hosted viewer.
Setup
- In Artifakt Settings → Embeddable SDK: enable the SDK to get your
publishable key (
gvk_…) and register the origins that may embed (e.g.https://app.example.com). npm install artifakt-sdk
React
import { ArtifaktArtifact } from "artifakt-sdk/react";
<ArtifaktArtifact
baseUrl="https://app.artifakthq.com"
artifactId="<artifact id>"
publishableKey="gvk_…"
user={{ email: "[email protected]", name: "Dana" }}
onEvent={(e) => console.log("artifakt:", e.type)}
style={{ height: 720 }}
/>Vanilla
import { mountArtifaktArtifact } from "artifakt-sdk";
const embed = await mountArtifaktArtifact(document.getElementById("review"), {
baseUrl: "https://app.artifakthq.com",
publishableKey: "gvk_…",
artifactId: "<artifact id>",
user: { email: "[email protected]" },
});
// later: embed.destroy()Server-side token exchange (recommended)
The publishable key is client-safe, but exchanging it server-side keeps your origin allowlist tight and lets you assert the reviewer's identity from your own session:
import { createEmbedToken } from "artifakt-sdk";
// in your backend route
const { token } = await createEmbedToken({
baseUrl: "https://app.artifakthq.com",
publishableKey: process.env.ARTIFAKT_PUBLISHABLE_KEY,
artifactId,
user: { email: session.user.email, name: session.user.name },
});
// pass `token` to <ArtifaktArtifact token={token} …> instead of publishableKeyNotes
- Embed tokens are short-lived (1h) and scoped to one artifact; workspace surfaces (artifact list, settings, tokens) are never reachable from an embed.
- The reviewer identity is host-asserted: Artifakt records what your app vouches. Signatures made through an embed are named accordingly.
- Rotating the publishable key in Settings invalidates all outstanding embeds.
Capture mode (artifakt-sdk/capture)
A separate, write-only snippet for pinning feedback directly onto your own app's live UI — not an Artifakt-hosted artifact.
npm install artifakt-sdkimport { init } from "artifakt-sdk/capture";
const capture = init({
baseUrl: "https://app.artifakthq.com",
publishableKey: "gvk_…",
surfaceKey: "marketing-deck", // stable id for this surface
versionLabel: BUILD_ID, // recommended: your build/deploy id
user: { email: "[email protected]", name: "Dana" },
});
// later: capture.destroy();The workspace's capture policy is either anonymous (identity is
host-asserted, like user above) or invite (reviewers verify their
email via a magic link before they can leave feedback). Either way, the
token this snippet holds is write-only: it can observe versions of and
comment on exactly the one surfaceKey it was minted for, and nothing else
in the workspace. See Capture mode for
policies, snapshots, and token lifetimes.
Interaction model
The widget shows a floating spark (the fab). Clicking it is the lid: it
opens and closes an expanded panel. Inside the panel a two-way segment picks
the mode — Interactive (the page stays fully usable) or Feedback (a
capture overlay turns on and the cursor becomes a ghost pin carrying the next
note's number; click the page to plant a note, and a composer opens beside it).
Your sent notes list in the panel, each with a numbered pin echoed on the page.
Appearance (Auto / Light / Dark — Auto follows the host's
prefers-color-scheme) lives behind the panel's ⋯ overflow; the widget is
always its own graphite glass, only its edge adapts to the host.
Give interactive elements a stable data-afk id (e.g.
<button data-afk="submit-cta">) so pins keep pointing at the right element
across re-renders — agents generating UI can do this automatically.
App-anchored pins (canvas/WebGL)
For apps that render with canvas, WebGL, or a PDF viewer, there's no DOM
element under the cursor worth pointing at — data-afk has nothing to
attach to. Pass anchorProvider to init() and let the app own the anchor
in its own scene semantics instead:
const capture = init({
baseUrl: "https://app.artifakthq.com",
publishableKey: "gvk_…",
surfaceKey: "3d-viewer",
anchorProvider: {
// Called at pin time, before DOM anchoring. Return your app's own ref
// (a scene/node id, world coordinates — whatever makes sense) to own
// the anchor, or null to decline the click and let DOM anchoring
// handle it (e.g. a click that landed on your app's chrome, not the
// canvas itself).
resolve(x, y, target) {
const hit = myScene.pick(x, y);
return hit ? { ref: hit.nodeId, data: { worldPos: hit.point } } : null;
},
// Called every tracking frame for app-anchored pins. Return the
// anchor's current viewport coordinates and whether it's presentable
// right now (in frame, not occluded by your own UI, etc.) — return
// null when you can't say, which hides the marker either way.
project(ref, data) {
const node = myScene.get(ref);
if (!node) return null;
const p = myScene.projectToScreen(data.worldPos);
return { x: p.x, y: p.y, visible: node.visible };
},
},
});The stored anchor records your ref/data verbatim (Artifakt never
interprets them) and is round-tripped back to project() on every load — so
markers keep tracking a rotating model or a panning canvas the same way DOM
anchors track a scrolling page. If no anchorProvider is registered on a
later load (or project() returns null/throws), an app-anchored pin's
marker simply stays hidden — it's still listed in "my pins" and labeled
app-anchored there, it just has nowhere to draw a dot.
View-anchored navigation (single-URL multi-view apps)
For slide decks, wizards, and tab shells that switch what's on screen in
JavaScript without ever changing the URL, route matching can't tell one
view from another — a pin made on a hidden view would list under "This
page" but clicking it had nowhere to jump to. Pass viewProvider to
init() so the app can name and switch views itself:
const capture = init({
baseUrl: "https://app.artifakthq.com",
publishableKey: "gvk_…",
surfaceKey: "sales-deck",
viewProvider: {
// The id of the view showing right now (stamped on each pin's anchor
// at capture time), or null when none applies.
getView: () => `slide-${deck.currentSlide}`,
// Switch the app to `view`. Return a promise if the switch animates
// (e.g. a fade) — the widget waits for it before scrolling/flashing.
showView: (view) => deck.goToSlide(Number(view.replace("slide-", ""))),
// Optional: prettifies a view id for the echo list's group headings.
label: (view) => `Slide ${view.replace("slide-", "")}`,
},
});Once registered, a pin captured on a hidden view groups under that view's
label in the "my pins" list instead of a generic "This page" bucket, and
clicking it calls showView before scrolling/flashing the marker. Without
a viewProvider, the widget still fails honestly: jumping to a pin whose
element exists but isn't currently shown reports "This pin is on a view
that isn't showing right now" instead of silently doing nothing.
Already syncing your view to location.hash? (>= 0.2.4) Apps that do —
decks, wizards, docs sites — hand-write the same provider every time, so
pass the string "hash" instead of the object:
viewProvider: "hash" reads location.hash (minus the #) for getView
and assigns it for showView. One caveat: a view is only recorded while the
hash is actually set, so if your app can start with an empty hash, put the
initial view there too (e.g. a history.replaceState on load) or its
first-view pins won't carry a view id.
Want the "hash" preset AND a label? (>= 0.2.5) The bare string is sugar
for the whole provider, so it couldn't carry one — pass the preset as an
object instead:
viewProvider: { preset: "hash", label: (view) => `Slide ${view.replace("slide-", "")}` }Same hash-synced getView/showView as "hash", plus your label hook.
Orthogonality, not a special case: how views are read/switched is
mechanical and presettable, what they're called is semantic and hookable —
now you get both independently.
Automatic view titles (>= 0.2.5): you may not need label at all.
Every pin captured on a registered view records that view's own visible
heading (the first on-screen, unoccluded h1/h2/h3) alongside the view
id, frozen at capture time — a review record describes what the reviewer
actually saw, even if the heading is reworded later. Display precedence
everywhere a view is shown: an explicit label() hook wins if you supplied
one, then the captured heading, then the raw view id. Reach for label
only when the heading text itself isn't a good group name.
If a pin jump ever appears to do nothing, pass init({ debug: true }) — it
also narrates the pin-jump path to the console (prefix [artifakt-capture]),
so you can see exactly where the jump landed and why.
No bundler? The dist files are dependency-free native browser ESM: vendor
dist/capture.js + dist/capture-anchor.js side by side and
import { init } from "./capture.js" from a <script type="module">. Put
them in a version-named directory (assets/artifakt-0.1.4/) and change
the directory on upgrade — CDN/browser caches outlive republishes, so same
URL + new bytes silently keeps old code running; new content needs a new URL.
For pipelines that build artifacts (e.g. decks generated per publish), inject
the snippet at build time so every output carries it with the current build id.
Local pin echo (>= 0.1.6): the widget remembers, in this browser's
localStorage only, the pins THIS browser has successfully sent (capped at
100, oldest evicted first) — never read back from the server, so it stays a
write-only client. Idle, it shows a small count badge on the ✎ button and a
numbered marker at each pin's anchor position (skipped if the anchor no
longer resolves — the page changed — but the pin still shows in the list).
Markers position on the exact clicked element; if a page change breaks that
selector, they fall back to the data-afk anchor's own recorded geometry
(0.1.8+) rather than misapplying the clicked element's position to it. A
selector match is also verified against the pin's recorded text before
being trusted (0.2.0), so a virtualized/recycled node that now matches the
same selector but holds different content falls back to the data-afk
geometry too, instead of misplacing the marker.
Clicking a marker or the badge opens a panel: the pin's text, route, and
timestamp, capped honestly at "Sent to the workspace ✓" (there's no read
path, so it can never say "seen" or "resolved"), plus "Remove from this
view" — which deletes only the local echo, never the comment in Artifakt.
The badge's list (0.2.1) groups entries by page — this page's pins first,
then the rest under their own route — and every entry jumps to its pin,
navigating across pages when needed; clicking a route heading (0.2.2) jumps
to that page too. The list links to the full record in Artifakt (0.2.2).
Markers track their elements through transforms/slide navigation (a
frame-loop runs while markers are on screen) and hide when the element isn't
on the current page/viewport, isn't actually rendered (opacity/visibility/
display), or is covered by another layer — including decks that STACK
slides in place and switch via opacity/visibility/z-index rather than
moving them off-screen (0.1.9+, via checkVisibility and periodic
elementFromPoint occlusion sampling); on a multi-page surface only the
current page's markers show, but the list always shows every pin. Marker and
panel positions also compensate for a transformed, filtered, or zoomed
ancestor around the widget itself (0.2.0, via measurement — not style
inspection), so pins still land correctly under a scaled or transformed host
layout. This is a
personal echo, clearly distinct from the shared RECORD in Artifakt: another
browser or device won't show these markers, only your team's Artifakt view
does. Verified, synced-across-devices echo is a possible future tier, not
this one. Zero new network calls: everything above is localStorage and
rendering.
Interaction safety: idle, the widget is one floating button and a single
Escape listener that ignores everything outside pin mode — no global
click/drag/key interception, so it can't fight your app's own navigation.
(The local pin echo above runs a requestAnimationFrame loop, but only
while idle with at least one route-matching stored pin and the tab visible,
and only to reposition/hide markers — it never calls
preventDefault/stopPropagation, touches only the widget's own marker
nodes, and pauses the moment the tab is hidden.) Keystrokes typed into the
widget's panels stop at the widget boundary, so apps that bind Space/arrows
(slide decks) don't react while a reviewer types. Pin mode is deliberately
modal until a pin is placed or exited. While a panel or overlay is open
(pin, compose, the identity/verify prompts, or the pin echo panel), a
capture-phase listener keeps keys from
reaching the page even when focus has drifted outside the widget entirely (a
bare pin overlay, or a click that lands on a panel's padding) — Escape still
exits, and typing is redirected back into the open panel. On failure (bad
key, unlisted origin, server unreachable) it disables itself with one
console warning and never breaks the host page. Pass expectedVersion (the
version your build vendored) to init() to get a one-time console warning
if a stale cached copy of the widget ends up loaded on the page.
Testing
The capture widget's browser behaviors (pinning, echo markers, occlusion gating, layered anchors) are covered by a Playwright harness — an "element zoo" fixture with one of every element/rendering shape, exercised against a fully mocked API:
npm i && npx playwright install chromium && npm run test:zooSee test/zoo.spec.ts and test/fixtures/zoo.html.
Backoff and update awareness (>= 0.1.4): if the token exchange comes back
403 three times in a row (never counting the invite flow's
verification_required, and never a network error), the widget assumes the
key is dead, goes quiet for 24h — no render, no network calls at all, just
one console warning — and tries again normally afterward; a single success
at any point clears the counter. Separately, every observe response carries
the server's latest published SDK version; if it's newer than the one
you're running, the widget logs one console note and adds a small
dismissible line inside its own panels ("SDK x.y.z available — owners: ask
your agent to upgrade") — never a page-load banner, and dismissing it stays
quiet until an even newer version ships. Nothing auto-updates; upgrading is
always an owner's call.
