feat: a description and a preview image per artifact (T2.1.3) - #43
Merged
Conversation
Two optional fields, description (one line, 300 chars) and ogImage (an
absolute http(s) URL), stored in meta.json and rendered as og: tags into the
two heads the server builds per request: the viewer frame and the markdown
page. Nothing is spliced into an html or jsx artifact's stored bytes, which
is what docs/formats.md promises for those types.
Settable on POST/PUT/PATCH, on the zip endpoint's query string, from two new
row-menu items in the dashboard, and as arguments on the publish_artifact and
update_artifact MCP tools. Both ride GET /api/artifacts. PUT keeps them when
omitted; PATCH with an empty string clears one.
Also fixes what the review turned up: the shells were filled by chaining
.replace(), so a stored value carrying the literal text of a later
placeholder became the target of that substitution. A description of
{{CONTENT}} put the whole rendered markdown body, unescaped, inside a meta
attribute and left the real content slot in the page as literal text. One
pass with a lookup table (lib/shells.js) closes it, including the older case
where a title could do the same to a jsx source.
Preview fields are also parsed before a rename moves storage, so a patch
carrying both a new slug and a refused value no longer half-applies.
This was referenced Aug 12, 2026
PR #42 landed the list_artifacts field enumeration this branch also writes to. One conflict, in docs/mcp.md, where both sides added a paragraph after the type line. Both kept, and #42's field list gained description and ogImage; the served tool description in server.js gained them too, so the assertion #42 added (the description names what the response carries) still holds. smoke.sh and server.js merged on their own: #42's MCP block and this branch's link-preview block do not overlap, and neither do the two slug sets. After the merge: npm test 65, smoke 176 assertions, both green.
Ten commits landed on main, including 405b024, which is the write chaining this branch's review filed as T2.1.13: every meta write now runs through withMetaChain, and saveArtifact and saveZipArtifact each split into a slug-settling outer half and a storing inner half. One conflict, in server.js, on both of those signatures. Main's structure kept; description and ogImage moved into the inner functions, storeArtifact and storeZipArtifact, where the parsing and the meta writes already sat. The parse-before-rename ordering this branch added survived the refactor: it is still above the storage.move inside applyPatch. After the merge: npm test 80, smoke 186 assertions, both green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this was
T2.1.3, the last big item in backlog 2 wave 1. "Per-artifact SEO/OG metadata. Optional
title/description/og-image fields on meta.json, injected into HTML/md shells; og-image can be
another artifact URL. Done when: settable via API/dashboard/MCP, renders in head, smoke case."
titlealready existed, so what this adds isdescriptionandogImage.What changed
The fields.
descriptionis one line, max 300 characters, with runs of whitespace collapsed.ogImageis an absolutehttp(s)URL, no username or password, capped at 2048 characters measuredon the normalized href. Anything else is a
400rather than a silent drop. Both live inlib/social.jswith the parsing and the tag rendering, for the reasonlib/redirect.jssitsoutside
server.js: both halves are then testable without a running instance.Where they render. The two heads the server builds per request: the viewer frame, which is what
a top-level visit to
/a/<slug>gets while frames are on, and the markdown render. Seven tags,conditional on what is set, ending in
twitter:cardassummaryorsummary_large_image.og:urlis the permanent
/a/<slug>link, never a?k=capability link: an unfurl outlives the message itappeared in, and a token expires and can be revoked.
Nothing is spliced into stored bytes. An
htmlartifact is served as-is, which is whatdocs/formats.mdhas always promised, and ajsxpage is baked at publish time, so writing tagsinto either means editing an author's document and re-editing it on every later metadata change.
Two consequences are now written into the docs: an html, jsx or zip artifact carries the preview
only while it is framed, and a redirect stores the fields and renders nothing, the same way it
stores a
framevalue it never uses. Reasoning is in AGENT-DECISIONS.md, 2026-08-11.Where they are settable.
POST/PUT/PATCH, the zip endpoint's query string, thepublish_artifactandupdate_artifactMCP tools, and two row-menu items in the dashboard("Description…" and "Preview image…").
PUTkeeps both when they are omitted, the way it keepstagsandproject;PATCHwith""clears one. Both rideGET /api/artifacts.One fix outside the fields, because the review found it here. The shells were filled by chaining
.replace('{{A}}', a).replace('{{B}}', b), so each value became part of the text the next callsearched. A description of
{{CONTENT}}therefore became the target of the content substitution:the whole rendered markdown body landed unescaped inside a quoted
<meta>attribute, closed it, andthe real content slot shipped to the reader as the literal string
{{CONTENT}}.lib/shells.jsdoes one pass with a lookup table instead. That also closes the older shape of the same bug, where a
titleof{{SOURCE}}could eat a jsx artifact's source slot.Screenshot
None this run. Publishing it was blocked: the sandbox classifier refused both the script that would
have posted the page and the
sipscall that would have shrunk the image enough to inline. Thethree shots are on disk in the session scratchpad (
preview-row-menu.png,preview-image-refusal.png,preview-menu-390.png). What they show is written out under Tests.Tests
npm test49 to 65.bash .github/workflows/smoke.sh154 to 176 assertions. Both green on thelocal backend.
Every new positive assertion was checked against something that fails:
{{SOCIAL}}fromshells/md.htmlturns the md assertion red; deleting it fromshells/frame.htmlturns the frame assertions red. Each was run separately, so neither result isthe other's shadow.
body.
breaks out fails rather than merely looking different.
the whitespace collapses, which a build that measured the input would refuse.
Coverage the review asked for and this now has: a zip site's framed head (which also pins the
trailing slash in
og:url), the zip endpoint's two query params including a refused one,og:titlecarrying a real title rather than the slug fallback,
X-Robots-Tagstill on a page that has apreview, and a private artifact reached through its capability link rendering the bare canonical
og:urlwith no token in it.Browser pass at 1200px and 390px, console clean: both menu items marked with the existing rose check
when a value is stored and unmarked when not, the description input capped at 300 in the browser, the
whitespace collapse matching what the server stores when the dialog is reopened, a scheme-less image
URL reopening the box with the typed value and the reason above it, and the corrected URL saving and
reaching the served head.
Review
Four lenses on a 190-line diff, all four because the change renders into
/a/:slug, which answerswithout a key. Findings verified before acting: the placeholder bug was reproduced by hand before it
was fixed.
Fixed here:
independently). Reproduced, then fixed with a single-pass fill plus five unit tests, including the
$&case a function replacement was already guarding.patchArtifactmovedstorage before it validated the rest of the patch, so a
400came back with the move alreadydone: the row's link dead, the live URL in no row. The two new fields now parse before the move.
The same shape reachable through the older fields is filed, not fixed.
docs/formats.mdclaimed?raw=1returns what was uploaded. True for html, jsx and zip; anmd artifact renders through the same shell either way, so its
?raw=1carries the tags too.Corrected, and the sentence now points at
/a/:slug/sourceas the endpoint that always returnsthe uploaded bytes.
ogImage must be an absolute http:// or https:// URLin a toast after the dialog had closed. Itnow validates in the browser first and reopens the box with what was typed, the way "Target…"
does, with wording about a chat app fetching the image rather than a visitor following a hop.
way the tag editor caps a tag at 32.
Each item now carries the existing "on" check when a value is stored, and each dialog says the
preview shows while the frame is on, except on an md row where it does not depend on the frame.
redirect row, including one that already had a description set through the API. They now appear
when a value is stored, so it can be seen and cleared.
a bstored asa bbutredisplayed as typed. The row now collapses it the same way.
keepOrDropwas untestable and unreachable. It moved intolib/social.jsasdropIfRefusedwith a test, since both parsers are stable on their own output and the branch onlyfires for meta an older build wrote.
Filed rather than fixed, as T2.1.13 to T2.1.16 in backlog 2:
meta.jsonand leave an artifact that answers404 on its own DELETE, reproduced 4 runs in 10. The cause is a bare
fs.writeFileper object plusa full-record rewrite, both older than this change, but the dashboard now offers two more
one-field PATCHes per row, so it is easier to hit. The fix is atomic writes across five backends
plus merge-on-write, which is its own item (T2.1.13).
patchArtifactfor every field except the two this changetouched (T2.1.14).
it means either editing stored bytes or adding a string operation to the hot path for the most
common type, which is a product call.
docs/cli.mdclaims nothing, so this is a gap rather than a wrong doc.Recorded and not acted on:
ogImagemay point at a loopback or link-local host. The server never fetches it, so thereis no request forgery here; the fetch belongs to whichever chat app renders the card, which is the
same reasoning already published for redirect targets.
preview card. Stripping them is a decision about what an operator may write, not a defect.
the header of
dashboard-check.mjs. Verified in a browser instead.Merged main in, twice
First, PR #42 (
0f9377c). One conflict, indocs/mcp.md: both sides added a paragraph directlyafter the
typeline. #42's paragraph enumerates whatlist_artifactsreturns, and it now namesdescriptionandogImagealongsidetarget. The served tool description inserver.jsgained thesame two, so the assertion #42 added, that the description names what the response actually carries,
still holds rather than passing on a technicality.
smoke.shandserver.jsmerged on their own:#42's MCP block and this branch's link-preview block do not touch the same lines, and the slug sets
do not collide (
ci-redir-mcpagainstci-preview*).Then ten more commits (
00747e8), including405b024, which is the write chaining this branch'sown review filed as T2.1.13. That one landed while this PR sat open, so the concurrency hole the
review reported is now closed on
mainrather than still open behind this feature.One conflict, in
server.js, and it is the interesting one:saveArtifactandsaveZipArtifacteachsplit into a slug-settling outer half that takes the write chain and a storing inner half. Main's
structure kept, with
descriptionandogImagemoved intostoreArtifactandstoreZipArtifact,where the parsing and the meta writes already sat. Checked by hand afterwards that the
parse-before-rename ordering this branch added survived the refactor: it is still above the
storage.moveinsideapplyPatch, so a patch carrying a new slug and a refused preview value stillrefuses before anything moves.
After both merges:
npm test80,bash .github/workflows/smoke.sh186 assertions, both green, withevery link-preview case and every case from the ten new commits present in the same run.