Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/send-test-post.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions bin/sideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ usage:
sideshow show <id> 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 <name>] 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
Expand Down Expand Up @@ -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")));
Expand Down
2 changes: 2 additions & 0 deletions guide/AGENT_HOWTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
34 changes: 34 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions server/mcpHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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");
Expand Down
16 changes: 15 additions & 1 deletion server/mcpSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 (<img src=\"...\">). 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:
Expand Down Expand Up @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions server/welcomePost.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div style="font-family:var(--font-sans);padding:8px 4px">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
<span style="width:10px;height:10px;border-radius:50%;background:var(--color-text-success,#3fb950);box-shadow:0 0 0 4px color-mix(in srgb, var(--color-text-success,#3fb950) 20%, transparent)"></span>
<span style="font-size:13px;color:var(--color-text-secondary);letter-spacing:.06em;text-transform:uppercase">Connected</span>
</div>
<h1 style="margin:0 0 8px;font-size:26px;line-height:1.2">Your agent can draw here now.</h1>
<p style="margin:0 0 20px;font-size:14px;color:var(--color-text-secondary);max-width:52ch">
sideshow is a live surface your agents draw on while they work &mdash; posts land here
instantly as cards.
</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin-bottom:24px">
<div style="background:var(--color-background-secondary);border-radius:var(--border-radius-lg);padding:14px">
<div style="font-size:20px;margin-bottom:6px">🗺️</div>
<strong style="font-size:13px">Diagrams</strong>
<div style="font-size:12px;color:var(--color-text-tertiary)">html &amp; mermaid</div>
</div>
<div style="background:var(--color-background-secondary);border-radius:var(--border-radius-lg);padding:14px">
<div style="font-size:20px;margin-bottom:6px">🔍</div>
<strong style="font-size:13px">Code reviews</strong>
<div style="font-size:12px;color:var(--color-text-tertiary)">native diff cards</div>
</div>
<div style="background:var(--color-background-secondary);border-radius:var(--border-radius-lg);padding:14px">
<div style="font-size:20px;margin-bottom:6px">📝</div>
<strong style="font-size:13px">Plans &amp; prose</strong>
<div style="font-size:12px;color:var(--color-text-tertiary)">rendered markdown</div>
</div>
<div style="background:var(--color-background-secondary);border-radius:var(--border-radius-lg);padding:14px">
<div style="font-size:20px;margin-bottom:6px">📟</div>
<strong style="font-size:13px">Logs &amp; data</strong>
<div style="font-size:12px;color:var(--color-text-tertiary)">terminal, json, traces</div>
</div>
</div>
<div style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--color-text-tertiary);margin-bottom:10px">Try asking your agent</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="border:1px solid var(--color-border-tertiary);border-radius:var(--border-radius-md);padding:10px 14px;font-size:13.5px">&ldquo;Draw a diagram of this codebase's architecture and post it to sideshow.&rdquo;</div>
<div style="border:1px solid var(--color-border-tertiary);border-radius:var(--border-radius-md);padding:10px 14px;font-size:13.5px">&ldquo;Post a code review of the change you just made.&rdquo;</div>
<div style="border:1px solid var(--color-border-tertiary);border-radius:var(--border-radius-md);padding:10px 14px;font-size:13.5px">&ldquo;Sketch two layout options for this page so I can compare.&rdquo;</div>
<div style="border:1px solid var(--color-border-tertiary);border-radius:var(--border-radius-md);padding:10px 14px;font-size:13.5px">&ldquo;Explain the auth flow with a sequence diagram.&rdquo;</div>
<div style="border:1px solid var(--color-border-tertiary);border-radius:var(--border-radius-md);padding:10px 14px;font-size:13.5px">&ldquo;Post the failing test output and what you think is wrong.&rdquo;</div>
</div>
<p style="margin:20px 0 0;font-size:12px;color:var(--color-text-tertiary)">
Sent by <code style="font-family:var(--font-mono)">send_test_post</code> &mdash; fixed content, versioned with sideshow itself.
</p>
</div>
`.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<Post | null> {
const posts = await store.listPosts();
return posts.find((p) => p.title === WELCOME_POST_TITLE) ?? null;
}
Loading
Loading