From 92fa93afdd82f7ad5c4b8ea54115e7467b9d04d7 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Fri, 3 Jul 2026 21:48:24 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20send=5Ftest=5Fpost=20=E2=80=94=20a=20bu?= =?UTF-8?q?ilt-in=20welcome=20post=20for=20newly=20connected=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A newly connected agent's first post is the user's first impression, and leaving it to the agent to improvise is a quality lottery. send_test_post makes it deterministic: one no-arg call publishes a fixed card — shipped and versioned with sideshow, themed for light/dark — that confirms the connection works and hands the user example prompts that reliably produce real posts. All three tiers: the send_test_post MCP tool (streamable HTTP + stdio), POST /api/test-post, and `sideshow test-post`. One shared implementation (server/welcomePost.ts). Idempotent — the existing welcome card is returned (alreadySent: true), never duplicated; a user who deleted or retitled theirs gets a fresh one, matching intent. The card lives in its own "Getting started" session so task sessions stay about the task. The MCP initialize instructions nudge freshly connected agents toward it. Co-Authored-By: Claude Fable 5 --- .changeset/send-test-post.md | 5 ++ bin/sideshow.js | 14 +++++ guide/AGENT_HOWTO.md | 2 + mcp/server.ts | 17 +++++ server/app.ts | 34 ++++++++++ server/mcpHttp.ts | 33 ++++++++++ server/mcpSpec.ts | 16 ++++- server/welcomePost.ts | 82 ++++++++++++++++++++++++ test/welcomePost.test.ts | 119 +++++++++++++++++++++++++++++++++++ 9 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 .changeset/send-test-post.md create mode 100644 server/welcomePost.ts create mode 100644 test/welcomePost.test.ts diff --git a/.changeset/send-test-post.md b/.changeset/send-test-post.md new file mode 100644 index 0000000..76584e3 --- /dev/null +++ b/.changeset/send-test-post.md @@ -0,0 +1,5 @@ +--- +"sideshow": minor +--- + +Add `send_test_post` — a built-in welcome/test post for newly connected agents. One no-arg call publishes a fixed card (shipped with sideshow, themed for light/dark) that confirms the connection works and shows the user example prompts to try. Available on all three tiers: the `send_test_post` MCP tool (HTTP and stdio), `POST /api/test-post`, and `sideshow test-post`. Idempotent — if the welcome card is already on the board it is returned (`alreadySent: true`), never duplicated. The MCP initialize instructions now nudge freshly connected agents toward it. diff --git a/bin/sideshow.js b/bin/sideshow.js index c11ac44..b499ae3 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -132,6 +132,7 @@ usage: sideshow show show a single post (surfaces, indexes, ids, version, history) sideshow sessions list sessions sideshow demo seed two example sessions to explore the viewer + sideshow test-post [--agent ] publish the built-in welcome post (idempotent) sideshow guide print the design contract for posts sideshow setup print the AGENTS.md integration block sideshow agent-howto print current agent how-to @@ -1588,6 +1589,19 @@ const commands = { console.log(`Seeded ${DEMO_SESSIONS.length} demo sessions — open ${BASE} to look around.`); }, + // Publish the built-in welcome/test post (server/welcomePost.ts) — the same + // fixed card the MCP send_test_post tool sends. Idempotent server-side: if + // the card is already on the board, the server returns it instead of + // publishing a duplicate. + async "test-post"() { + const { values: flags } = parse({ options: { agent: { type: "string" } } }); + const created = await api("/api/test-post", { + method: "POST", + body: JSON.stringify({ agent: agentName(flags) }), + }); + console.log(JSON.stringify({ ...created, url: `${BASE}/p/${created.id}` }, null, 2)); + }, + async guide() { parse(); console.log(await fetchTextWithFallback("/guide", join(ROOT, "guide", "DESIGN_GUIDE.md"))); diff --git a/guide/AGENT_HOWTO.md b/guide/AGENT_HOWTO.md index 4b7b516..7e267ad 100644 --- a/guide/AGENT_HOWTO.md +++ b/guide/AGENT_HOWTO.md @@ -28,6 +28,8 @@ sideshow guide # or: curl -s ${SIDESHOW_URL:-http://localhost:8228}/guide If `SIDESHOW_URL` is unset, the surface is at `http://localhost:8228`. If it is not running, start it: `sideshow serve` (or `npx sideshow serve`). If the `sideshow` command is not on PATH but you are inside this repo, use `node bin/sideshow.js ...` as the CLI command. +Just connected, or the user asked for a test? Send the built-in welcome post once — it confirms the connection works and shows the user example prompts to try. MCP: `send_test_post`; CLI: `sideshow test-post`; raw HTTP: `POST /api/test-post`. It is idempotent (an existing welcome card is returned, never duplicated). + ## Publishing Prefer MCP tools if the sideshow MCP server is connected: `publish_post` `{title, surfaces, sessionTitle?}`, `update_post` `{id, title?, surfaces?}`, `wait_for_feedback`, `reply_to_user` `{postId, message}`, `list_posts`. (`publish_surface` / `update_surface` remain as deprecated aliases; `publish_snippet` / `update_snippet` remain as html-only sugar aliases.) Otherwise use the CLI — session grouping is automatic: diff --git a/mcp/server.ts b/mcp/server.ts index a492e1d..e4de041 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -257,6 +257,23 @@ server.registerTool( async () => text(await api("/guide")), ); +server.registerTool( + "send_test_post", + { + description: MCP_TOOL_DESCRIPTIONS.sendTestPost, + inputSchema: {}, + }, + async () => { + // The server owns the fixed content and the already-sent check; this tool + // is just the trigger. The welcome card lives in its own "Getting started" + // session, so the conversation's lazy session is deliberately not used. + const created = JSON.parse( + await api("/api/test-post", { method: "POST", body: JSON.stringify({ agent: AGENT }) }), + ); + return text({ ...created, url: `${API}/p/${created.id}` }); + }, +); + server.registerTool( "add_surface", { diff --git a/server/app.ts b/server/app.ts index 310ba55..e475ab2 100644 --- a/server/app.ts +++ b/server/app.ts @@ -44,6 +44,12 @@ import { type TraceStep, } from "./types.ts"; import { validateSurfaces } from "./postSurfaces.ts"; +import { + findWelcomePost, + WELCOME_POST_TITLE, + WELCOME_SESSION_TITLE, + welcomeSurfaces, +} from "./welcomePost.ts"; export type { FeedEvent } from "./events.ts"; export type { Feedback } from "./apiViews.ts"; @@ -1139,6 +1145,34 @@ export function createApp({ return publish(c, body, parsed.surfaces); }); + // The built-in welcome/test post (server/welcomePost.ts): the same fixed card + // the MCP send_test_post tool publishes, reachable from the CLI and raw-HTTP + // tiers (`sideshow test-post`, `curl -X POST .../api/test-post`). The body is + // optional (`{agent?}` labels a newly created session). Idempotent — if the + // card is already on the board it is returned (200 + alreadySent) rather than + // duplicated; a fresh publish is a 201 like any other post. + app.post("/api/test-post", async (c) => { + const existing = await findWelcomePost(store); + if (existing) { + return c.json({ ...postWriteView(existing), alreadySent: true }); + } + const body = await c.req.json().catch(() => null); + const result = await publishPostFlow({ + surfaces: welcomeSurfaces(), + title: WELCOME_POST_TITLE, + sessionTitle: WELCOME_SESSION_TITLE, + agent: typeof body?.agent === "string" ? body.agent : undefined, + }); + if ("error" in result) return c.json({ error: result.error }, result.status); + return c.json( + { + ...postWriteView(result.post), + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }, + 201, + ); + }); + async function publish(c: any, body: any, surfaces: Surface[]) { const result = await publishPostFlow({ surfaces, diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index 0ce70f5..7b22a9b 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -13,6 +13,12 @@ import { } from "./types.ts"; import { HTTP_MCP_TOOLS, MCP_INSTRUCTIONS, MCP_SERVER_INFO } from "./mcpSpec.ts"; import { coerceSurfaces } from "./postSurfaces.ts"; +import { + findWelcomePost, + WELCOME_POST_TITLE, + WELCOME_SESSION_TITLE, + welcomeSurfaces, +} from "./welcomePost.ts"; // Stateless MCP over streamable HTTP: every request is self-contained, which // is what a serverless deployment needs. Session continuity is explicit — @@ -210,6 +216,33 @@ export function registerMcp(app: Hono, deps: McpDeps) { } case "get_design_guide": return deps.guide; + case "send_test_post": { + // Idempotent: a board only ever needs one welcome card. If it's already + // there, hand back the existing post instead of stacking duplicates — + // agents are told to call this right after connecting, and an eager one + // may call it more than once. + const existing = await findWelcomePost(deps.store); + if (existing) { + return JSON.stringify( + { + ...postWriteView(existing), + url: `${origin}/p/${existing.id}`, + alreadySent: true, + note: "the welcome post is already on this board — returning it, not republishing", + }, + null, + 2, + ); + } + const result = await deps.publishPost({ + surfaces: welcomeSurfaces(), + title: WELCOME_POST_TITLE, + sessionTitle: WELCOME_SESSION_TITLE, + agent: typeof args.agent === "string" ? args.agent : undefined, + }); + if ("error" in result) throw new Error(result.error); + return postResult(result, origin, "p"); + } case "add_surface": { const surfaces = await coerceSurfaces([args.surface]); if (surfaces.length === 0) throw new Error("invalid surface"); diff --git a/server/mcpSpec.ts b/server/mcpSpec.ts index 963c5bd..744b626 100644 --- a/server/mcpSpec.ts +++ b/server/mcpSpec.ts @@ -22,7 +22,9 @@ export const MCP_INSTRUCTIONS = 'publish, also pass sessionTitle to name the session after the task (e.g. "Auth refactor"). The ' + "user can comment in their browser; call wait_for_feedback after publishing something you want a " + "reaction to. Any publish/update/reply result may carry a userFeedback array — comments the user " + - "left since your last call, delivered once."; + "left since your last call, delivered once. Just connected, or asked for a test? Call " + + "send_test_post once — it publishes a small fixed welcome card that confirms the connection " + + "works and shows the user example prompts to try."; const d = { title: "Short human-readable title shown above the card", @@ -186,6 +188,8 @@ export const MCP_TOOL_DESCRIPTIONS = { "Upload a binary asset (image, trace file, any file) and get back its id and URL. base64-encode the bytes in `data`. Then reference it: put {kind:'image', assetId} or {kind:'trace', assetId} in a post's surfaces, or embed the returned url in an html surface (). Attached to this conversation's session.", getDesignGuide: "Fetch the design contract: post surfaces, html fragment rules, theme CSS variables, CDN allowlist, and the interactivity bridge. Call once per session before publishing.", + sendTestPost: + "Publish sideshow's built-in welcome post — fixed content shipped with sideshow that confirms the connection works and shows the user example prompts to try. Use it when the user asks for a test post, or right after connecting to a fresh/empty board. Idempotent: if the welcome post is already on the board it is returned (alreadySent: true), never duplicated.", addSurface: "Append a surface to an existing post (same card, new version). Optionally pass before/after (surface id or 0-based index) to control insert position; default is append at the end. If the result includes userFeedback, read it.", editSurface: @@ -365,6 +369,16 @@ export const HTTP_MCP_TOOLS = [ description: MCP_TOOL_DESCRIPTIONS.getDesignGuide, inputSchema: { type: "object", properties: {} }, }, + { + name: "send_test_post", + description: MCP_TOOL_DESCRIPTIONS.sendTestPost, + inputSchema: { + type: "object", + properties: { + agent: { type: "string", description: d.agent }, + }, + }, + }, { name: "add_surface", description: MCP_TOOL_DESCRIPTIONS.addSurface, diff --git a/server/welcomePost.ts b/server/welcomePost.ts new file mode 100644 index 0000000..05f036e --- /dev/null +++ b/server/welcomePost.ts @@ -0,0 +1,82 @@ +// The built-in welcome/test post — the fixed card `send_test_post` (MCP), +// `POST /api/test-post` (REST), and `sideshow test-post` (CLI) publish. +// +// Why fixed content: a newly connected agent's first post is the user's first +// impression of the whole product, and leaving it to the agent to improvise is +// a quality lottery. Shipping the card with sideshow makes the first post +// deterministic — it confirms the connection is live, shows what a good card +// looks like, and hands the user concrete prompts that reliably produce real +// posts. The content is versioned with sideshow itself, not authored per call. +// +// Idempotency: publishing is guarded by findWelcomePost — a second call finds +// the existing card (by its fixed title) and returns it instead of stacking +// welcome posts. If the user deleted or retitled it, a fresh one is published; +// that matches intent (they asked for a test post and don't have one). +import { htmlSurface, type Post, type Store, type Surface } from "./types.ts"; + +export const WELCOME_POST_TITLE = "👋 Your agent is connected"; +export const WELCOME_SESSION_TITLE = "Getting started"; + +// One composed html surface. Styled entirely from the viewer's theme variables +// (never hardcoded colors) so it reads correctly in light and dark; fallback +// values inside var() keep it legible if a token is ever renamed. +const WELCOME_HTML = ` +
+
+ + Connected +
+

Your agent can draw here now.

+

+ sideshow is a live surface your agents draw on while they work — posts land here + instantly as cards. +

+
+
+
🗺️
+ Diagrams +
html & mermaid
+
+
+
🔍
+ Code reviews +
native diff cards
+
+
+
📝
+ Plans & prose +
rendered markdown
+
+
+
📟
+ Logs & data +
terminal, json, traces
+
+
+
Try asking your agent
+
+
“Draw a diagram of this codebase's architecture and post it to sideshow.”
+
“Post a code review of the change you just made.”
+
“Sketch two layout options for this page so I can compare.”
+
“Explain the auth flow with a sequence diagram.”
+
“Post the failing test output and what you think is wrong.”
+
+

+ Sent by send_test_post — fixed content, versioned with sideshow itself. +

+
+`.trim(); + +// Fresh array per call — publish paths may tag/mutate surface objects (ids), +// so callers must never share one instance. +export function welcomeSurfaces(): Surface[] { + return [htmlSurface(WELCOME_HTML)]; +} + +// The idempotency probe: the existing welcome post, or null. Matched by the +// fixed title — the card has no other stable marker, and a user who retitled +// it has made it their own (a fresh test post is then correct, not a dupe). +export async function findWelcomePost(store: Store): Promise { + const posts = await store.listPosts(); + return posts.find((p) => p.title === WELCOME_POST_TITLE) ?? null; +} diff --git a/test/welcomePost.test.ts b/test/welcomePost.test.ts new file mode 100644 index 0000000..def09dc --- /dev/null +++ b/test/welcomePost.test.ts @@ -0,0 +1,119 @@ +// The built-in welcome/test post (server/welcomePost.ts): the fixed card +// send_test_post (MCP) / POST /api/test-post (REST, which the CLI and the +// stdio MCP server call) publishes. Pins the tier parity, the fixed +// title/session, and — hardest to see from the outside — the idempotency +// guard: a board only ever accumulates ONE welcome card no matter how many +// times an eager agent calls the tool. +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { createApp } from "../server/app.ts"; +import { HTTP_MCP_TOOLS, MCP_INSTRUCTIONS } from "../server/mcpSpec.ts"; +import { JsonFileStore } from "../server/storage.ts"; +import { WELCOME_POST_TITLE, WELCOME_SESSION_TITLE } from "../server/welcomePost.ts"; + +function makeApp(authToken?: string) { + const dir = mkdtempSync(join(tmpdir(), "sideshow-test-")); + const store = new JsonFileStore(join(dir, "data.json")); + return createApp({ + store, + viewerHtml: "viewer", + guideMarkdown: "# guide", + setupText: "# setup", + agentHowtoText: "# agent how-to", + authToken, + }); +} + +const json = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +const mcpCall = (id: number, method: string, params?: unknown) => + json({ jsonrpc: "2.0", id, method, params }); + +test("POST /api/test-post publishes the welcome card once, then returns it (idempotent)", async () => { + const app = makeApp(); + + const first = await app.request("/api/test-post", json({ agent: "test-agent" })); + assert.equal(first.status, 201); + const created = (await first.json()) as any; + assert.ok(created.id); + assert.equal(created.alreadySent, undefined); + + // The card carries the fixed title and lands in its own "Getting started" + // session (not any task session). + const post = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(post.title, WELCOME_POST_TITLE); + assert.equal(post.surfaces.length, 1); + assert.equal(post.surfaces[0].kind, "html"); + const sessions = (await (await app.request("/api/sessions")).json()) as any[]; + const welcomeSession = sessions.find((s) => s.id === created.sessionId); + assert.equal(welcomeSession?.title, WELCOME_SESSION_TITLE); + + // Second call: the existing card comes back — same id, flagged, NOT a dupe. + const second = await app.request("/api/test-post", json({})); + assert.equal(second.status, 200); + const again = (await second.json()) as any; + assert.equal(again.id, created.id); + assert.equal(again.alreadySent, true); + + // A body-less curl works too (the body is optional on this route). + const bare = await app.request("/api/test-post", { method: "POST" }); + assert.equal(bare.status, 200); + assert.equal(((await bare.json()) as any).id, created.id); + + const posts = (await ( + await app.request(`/api/sessions/${created.sessionId}/posts`) + ).json()) as any[]; + assert.equal(posts.length, 1); +}); + +test("the send_test_post MCP tool is advertised and publishes the same card", async () => { + const app = makeApp(); + + // Advertised on tools/list, and nudged in the initialize instructions. + const list = (await (await app.request("/mcp", mcpCall(1, "tools/list"))).json()) as any; + assert.ok(list.result.tools.some((t: any) => t.name === "send_test_post")); + assert.match(MCP_INSTRUCTIONS, /send_test_post/); + assert.ok(HTTP_MCP_TOOLS.some((t) => t.name === "send_test_post")); + + const call = (await ( + await app.request( + "/mcp", + mcpCall(2, "tools/call", { name: "send_test_post", arguments: { agent: "claude-code" } }), + ) + ).json()) as any; + assert.equal(call.result.isError, undefined); + const created = JSON.parse(call.result.content[0].text); + assert.ok(created.id); + assert.match(created.url, /\/p\//); + + // Idempotent through the MCP tier too: the same post, flagged alreadySent. + const repeat = (await ( + await app.request("/mcp", mcpCall(3, "tools/call", { name: "send_test_post", arguments: {} })) + ).json()) as any; + const again = JSON.parse(repeat.result.content[0].text); + assert.equal(again.id, created.id); + assert.equal(again.alreadySent, true); + + // And the REST tier sees the MCP-published card (one shared implementation). + const rest = await app.request("/api/test-post", { method: "POST" }); + assert.equal(rest.status, 200); + assert.equal(((await rest.json()) as any).id, created.id); +}); + +test("the test-post route is a write: token-gated like any publish", async () => { + const app = makeApp("secret"); + const denied = await app.request("/api/test-post", { method: "POST" }); + assert.equal(denied.status, 401); + const allowed = await app.request("/api/test-post", { + method: "POST", + headers: { authorization: "Bearer secret" }, + }); + assert.equal(allowed.status, 201); +});