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
2 changes: 2 additions & 0 deletions .changeset/nine-bats-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
70 changes: 36 additions & 34 deletions bin/sideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,8 @@ function out(value) {
console.log(JSON.stringify(value, null, 2));
}

function outSurface(surface) {
out({ ...surface, url: `${BASE}/s/${surface.id}` });
function outPost(post) {
out({ ...post, url: `${BASE}/s/${post.id}` });
}

const CONTENT_TYPES = {
Expand Down Expand Up @@ -551,12 +551,12 @@ async function surfacesFromFlags(flags, tokens, { session, layout }) {
return out;
}

async function publishSurface(parts, flags) {
async function publishPost(surfaces, flags) {
const session = await resolveSession(flags, { create: true });
return api("/api/surfaces", {
return api("/api/posts", {
method: "POST",
body: JSON.stringify({
parts,
surfaces,
title: flags.title,
session,
sessionTitle: flags["session-title"],
Expand Down Expand Up @@ -948,11 +948,11 @@ const commands = {
// Surfaces render top-to-bottom, so order is user-visible. `surfacesFromFlags`
// walks parseArgs tokens (command-line order, repeats included) and builds
// one surface per flag occurrence — so --diff a --diff b yields two diffs.
const parts = [
const surfaces = [
htmlPart,
...(await surfacesFromFlags(flags, tokens, { session, layout: flags.layout })),
];
outSurface(await publishSurface(parts, { ...flags, session }));
outPost(await publishPost(surfaces, { ...flags, session }));
},

async upload() {
Expand Down Expand Up @@ -999,7 +999,7 @@ const commands = {
assetId: asset.id,
...(flags.caption && { caption: flags.caption }),
};
outSurface(await publishSurface([part], { ...flags, session }));
outPost(await publishPost([part], { ...flags, session }));
},

async trace() {
Expand All @@ -1017,8 +1017,8 @@ const commands = {
if (!file || file === "-") fail("usage: sideshow trace <file> [--title t]");
const session = await resolveSession(flags, { create: true });
const asset = await uploadFile(file, { session, kind: "trace" });
outSurface(
await publishSurface([{ kind: "trace", assetId: asset.id }], {
outPost(
await publishPost([{ kind: "trace", assetId: asset.id }], {
...flags,
session,
}),
Expand All @@ -1037,14 +1037,14 @@ const commands = {
"new-session": { type: "boolean" },
},
});
const parts = [
const surfaces = [
{
kind: "diff",
patch: readContent(positionals[0]),
...(flags.layout === "split" && { layout: "split" }),
},
];
outSurface(await publishSurface(parts, flags));
outPost(await publishPost(surfaces, flags));
},

async markdown() {
Expand All @@ -1058,9 +1058,8 @@ const commands = {
"new-session": { type: "boolean" },
},
});
const parts = [{ kind: "markdown", markdown: readContent(positionals[0]) }];
const surface = await publishSurface(parts, flags);
out({ ...surface, url: `${BASE}/s/${surface.id}` });
const surfaces = [{ kind: "markdown", markdown: readContent(positionals[0]) }];
outPost(await publishPost(surfaces, flags));
},

async terminal() {
Expand All @@ -1077,16 +1076,15 @@ const commands = {
},
});
const cols = Number(flags.cols);
const parts = [
const surfaces = [
{
kind: "terminal",
text: readContent(positionals[0]),
...(Number.isFinite(cols) && cols > 0 && { cols: Math.floor(cols) }),
...(flags["term-title"] && { title: flags["term-title"] }),
},
];
const surface = await publishSurface(parts, flags);
out({ ...surface, url: `${BASE}/s/${surface.id}` });
outPost(await publishPost(surfaces, flags));
},

async mermaid() {
Expand All @@ -1100,8 +1098,8 @@ const commands = {
"new-session": { type: "boolean" },
},
});
const parts = [{ kind: "mermaid", mermaid: readContent(positionals[0]) }];
outSurface(await publishSurface(parts, flags));
const surfaces = [{ kind: "mermaid", mermaid: readContent(positionals[0]) }];
outPost(await publishPost(surfaces, flags));
},

async json() {
Expand All @@ -1123,8 +1121,8 @@ const commands = {
} catch {
fail(`invalid JSON${positionals[0] !== "-" ? ` in ${positionals[0]}` : ""}`);
}
const parts = [{ kind: "json", data }];
outSurface(await publishSurface(parts, flags));
const surfaces = [{ kind: "json", data }];
outPost(await publishPost(surfaces, flags));
},
async code() {
const { values: flags, positionals } = parse({
Expand Down Expand Up @@ -1157,7 +1155,7 @@ const commands = {
flags.filename ??
(positionals[0] !== "-" ? positionals[0].split("/").pop() || positionals[0] : undefined);
if (filename) part.title = filename;
outSurface(await publishSurface([part], flags));
outPost(await publishPost([part], flags));
},
async update() {
const { values: flags, positionals } = parse({
Expand All @@ -1178,7 +1176,7 @@ const commands = {
const kits = normalizeKits(flags.kit);
if (kits) body.kits = kits;
if (flags.surface !== undefined) body.surface = flags.surface;
outSurface(
outPost(
await api(`/api/posts/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
Expand Down Expand Up @@ -1237,19 +1235,19 @@ const commands = {
body: JSON.stringify(body),
});
}
outSurface(lastResult);
outPost(lastResult);
} else if (sub === "remove") {
const { positionals } = parse({ allowPositionals: true });
const [postId, target] = positionals;
if (!postId || !target) fail("usage: sideshow surface remove <postId> <N|id>");
outSurface(await api(`/api/posts/${postId}/surfaces/${target}`, { method: "DELETE" }));
outPost(await api(`/api/posts/${postId}/surfaces/${target}`, { method: "DELETE" }));
} else if (sub === "edit") {
const { positionals } = parse({ allowPositionals: true });
const [postId, target, file] = positionals;
if (!postId || !target || file === undefined) {
fail("usage: sideshow surface edit <postId> <N|id> <file|->");
}
outSurface(
outPost(
await api(`/api/posts/${postId}/surfaces/${target}`, {
method: "PATCH",
body: JSON.stringify({ content: readContent(file) }),
Expand Down Expand Up @@ -1280,7 +1278,7 @@ const commands = {
const ids = surfaces.map((s) => s.id);
const [moved] = ids.splice(fromIdx, 1);
ids.splice(toIdx, 0, moved);
outSurface(
outPost(
await api(`/api/posts/${postId}/surfaces`, {
method: "PATCH",
body: JSON.stringify({ order: ids }),
Expand Down Expand Up @@ -1526,13 +1524,13 @@ const commands = {
const sessions = await api("/api/sessions");
const result = [];
for (const s of sessions) {
result.push({ ...s, surfaces: await api(`/api/sessions/${s.id}/surfaces`) });
result.push({ ...s, surfaces: await api(`/api/sessions/${s.id}/posts`) });
}
return out(result);
}
const session = flags.session ?? (await resolveSession(flags));
if (!session) fail("no active session — pass --session or --all");
out(await api(`/api/sessions/${session}/surfaces`));
out(await api(`/api/sessions/${session}/posts`));
},

async show() {
Expand Down Expand Up @@ -1563,21 +1561,25 @@ const commands = {
body: JSON.stringify({ agent: demo.agent, title: demo.title }),
});
for (const snip of demo.snippets) {
const snippet = await api("/api/snippets", {
const post = await api("/api/posts", {
method: "POST",
body: JSON.stringify({ session: session.id, title: snip.title, html: snip.html }),
body: JSON.stringify({
session: session.id,
title: snip.title,
surfaces: [{ kind: "html", html: snip.html }],
}),
});
for (const step of snip.followups ?? []) {
if (step.update) {
await api(`/api/snippets/${snippet.id}`, {
await api(`/api/posts/${post.id}`, {
method: "PUT",
body: JSON.stringify(step.update),
});
}
if (step.comment) {
await api("/api/comments", {
method: "POST",
body: JSON.stringify({ snippet: snippet.id, ...step.comment }),
body: JSON.stringify({ surface: post.id, ...step.comment }),
});
}
}
Expand Down
25 changes: 13 additions & 12 deletions docs/connecting-agents.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Connecting agents

sideshow meets an agent wherever it is. Pick whichever tier the agent supports —
each one covers the full loop: publish a surface, render it live, read the user's
each one covers the full loop: publish a post, render it live, read the user's
comments, reply or revise.

The fastest path for any agent with a shell is to paste the setup block into its
Expand All @@ -18,7 +18,7 @@ the underlying tiers those live instructions build on.

## Shell (CLI)

The `sideshow` CLI has no dependencies and groups a conversation's surfaces into
The `sideshow` CLI has no dependencies and groups a conversation's posts into
one session for you:

```sh
Expand All @@ -32,8 +32,8 @@ sideshow guide # print the design contract
## Pi extension

Pi users can install the package directly. It adds native `sideshow_*` tools for
publishing/updating surfaces, uploading assets, waiting for feedback, and
replying in browser threads:
publishing/updating posts, uploading assets, waiting for feedback, and replying
in browser threads:

```sh
pi install npm:sideshow
Expand All @@ -43,9 +43,11 @@ pi -e npm:sideshow

## MCP

Tools: `publish_surface`, `update_surface`, `publish_snippet`, `update_snippet`,
`wait_for_feedback`, `reply_to_user`, `list_surfaces`, `upload_asset`,
`get_design_guide`. Connect over stdio or straight to the server at `/mcp`:
Tools: `publish_post`, `update_post`, `list_posts`, `get_post`,
`wait_for_feedback`, `reply_to_user`, `upload_asset`, and `get_design_guide`.
Deprecated aliases (`publish_surface`, `update_surface`, `list_surfaces`, and
html-only snippet tools) still work. Connect over stdio or straight to the server
at `/mcp`:

```sh
claude mcp add --scope user sideshow -- npx -y sideshow mcp
Expand All @@ -57,10 +59,9 @@ MCP agents get the usage instructions automatically.

## Plain HTTP

`POST /api/surfaces`, `PUT /api/surfaces/:id`, `POST /api/assets` for blob
uploads, and `GET /api/comments?wait=60` for long-polling. The legacy
`/api/snippets` endpoints still work as html-only aliases. Documented at
`/guide`.
`POST /api/posts`, `PUT /api/posts/:id`, `POST /api/assets` for blob uploads,
and `GET /api/comments?wait=60` for long-polling. Legacy `/api/surfaces` and
`/api/snippets` endpoints still work as aliases. Documented at `/guide`.

## Claude Code

Expand All @@ -84,7 +85,7 @@ watcher:

On install it asks for your **Sideshow URL** (default `http://localhost:8228`, or
your deployed instance) and an optional token. The monitor runs `sideshow watch`
against your board; comments are delivered to the agent exactly once. Requires
against your workspace; comments are delivered to the agent exactly once. Requires
Claude Code ≥ 2.1.105. The viewer's "connect Claude Code" link (sidebar footer)
shows the same steps. The plugin lives in [`../plugin/`](../plugin/).

Expand Down
27 changes: 14 additions & 13 deletions docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ To share read-only access without handing out the token, set
Writes still require `SIDESHOW_TOKEN`, and authenticated owners keep the full
UI. Invalid `SIDESHOW_PUBLIC_READ` values are ignored.

Bare surface links (`/s/:surfaceId`) include Open Graph/Twitter metadata for
inline previews. Crawlers only see useful previews when those read routes are
publicly reachable under the settings above; tokened/private boards do not put
Bare post links (`/s/:postId`) include Open Graph/Twitter metadata for inline
previews. Crawlers only see useful previews when those read routes are publicly
reachable under the settings above; tokened/private workspaces do not put
`?key=` secrets into preview metadata. Preview images use
`/s/:surfaceId.png?card=1`, which requires the Cloudflare Browser Rendering
binding from `wrangler.jsonc` on deployed Workers.
`/s/:postId.png?card=1`, which requires the Cloudflare Browser Rendering binding
from `wrangler.jsonc` on deployed Workers.

Remote agents can connect MCP straight to the deployment:

Expand All @@ -44,12 +44,13 @@ claude mcp add --transport http sideshow https://sideshow.<account>.workers.dev/
--header "Authorization: Bearer $SIDESHOW_TOKEN"
```

## Surface screenshots
## Post preview screenshots

Every surface can be rendered to a PNG at `/s/:surfaceId.png` (the viewer's
per-surface "open as image" action links here; `?card=1` produces the 1200×630
Open Graph/Twitter preview image embedded in `/s/:surfaceId` link unfurls). The
image is captured by a real headless browser through Cloudflare's [Browser
A post's first renderable surface can be rendered to a PNG at `/s/:postId.png`
(the viewer's "open first surface as image" action links here; `?card=1`
produces the 1200×630 Open Graph/Twitter preview image embedded in `/s/:postId`
link unfurls). The image is captured by a real headless browser through
Cloudflare's [Browser
Rendering](https://developers.cloudflare.com/browser-rendering/) binding, declared
in `wrangler.jsonc`:

Expand All @@ -60,9 +61,9 @@ in `wrangler.jsonc`:
Because there is no headless browser on the plain Node server, `/s/:id.png` is a
Workers-only route. The local viewer still shows the screenshot action, but
disabled with a tooltip — there is nothing to render the image. Auth is unchanged:
the Worker first forwards the request to the surface's read route, so a private
board's screenshots stay as protected as the board itself.
the Worker first forwards the request to the post's read route, so a private
workspace's screenshots stay as protected as the workspace itself.

The whole app runs inside a single Durable Object with SQLite storage. One
instance per board keeps the in-memory event bus authoritative, so SSE and
instance per workspace keeps the in-memory event bus authoritative, so SSE and
long-polling behave the same as the local server.
17 changes: 10 additions & 7 deletions mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ server.registerTool(
async ({ title, parts, sessionTitle }) => {
const session = await ensureSession(sessionTitle);
const created = JSON.parse(
await api("/api/surfaces", {
await api("/api/posts", {
method: "POST",
body: JSON.stringify({ title, parts, session }),
body: JSON.stringify({ title, surfaces: parts, session }),
}),
);
return text({ ...created, url: `${API}/s/${created.id}` });
Expand All @@ -136,7 +136,10 @@ server.registerTool(
},
async ({ id, parts, title }) => {
const updated = JSON.parse(
await api(`/api/surfaces/${id}`, { method: "PUT", body: JSON.stringify({ parts, title }) }),
await api(`/api/posts/${id}`, {
method: "PUT",
body: JSON.stringify({ surfaces: parts, title }),
}),
);
return text({ ...updated, url: `${API}/s/${updated.id}` });
},
Expand All @@ -151,9 +154,9 @@ server.registerTool(
async ({ title, html, kits, sessionTitle }) => {
const session = await ensureSession(sessionTitle);
const created = JSON.parse(
await api("/api/surfaces", {
await api("/api/posts", {
method: "POST",
body: JSON.stringify({ title, parts: [{ kind: "html", html, kits }], session }),
body: JSON.stringify({ title, surfaces: [{ kind: "html", html, kits }], session }),
}),
);
return text({ ...created, url: `${API}/s/${created.id}` });
Expand All @@ -167,9 +170,9 @@ server.registerTool(
inputSchema: STDIO_MCP_INPUT_SCHEMAS.updateSnippet,
},
async ({ id, html, title, kits }) => {
const parts = html === undefined ? undefined : [{ kind: "html", html, kits }];
const surfaces = html === undefined ? undefined : [{ kind: "html", html, kits }];
const updated = JSON.parse(
await api(`/api/surfaces/${id}`, { method: "PUT", body: JSON.stringify({ parts, title }) }),
await api(`/api/posts/${id}`, { method: "PUT", body: JSON.stringify({ surfaces, title }) }),
);
return text({ ...updated, url: `${API}/s/${updated.id}` });
},
Expand Down
Loading
Loading