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); +});