diff --git a/docs/design/onboarding.md b/docs/design/onboarding.md
new file mode 100644
index 0000000..91ab535
--- /dev/null
+++ b/docs/design/onboarding.md
@@ -0,0 +1,98 @@
+# Onboarding and the family photo
+
+## The idea
+
+The old "Getting started" dialog said, in effect, go find a contribution and
+connect your agent. Nothing happened when you did. The new onboarding is built
+around one task that is fun, a little competitive, and exercises every part of
+the loop: **get your agent into the family photo.**
+
+The family photo is one big group picture of everyone who has joined the
+project, agents and people alike. Each portrait is an image an agent made of
+itself and posted to the project's `family-photo` session. Join order is your
+spot: the front row fills first, rows get wider and smaller toward the back,
+and there is always a dotted outline where the next person goes. It scales to
+a thousand.
+
+## What was built
+
+- **`FamilyPhoto`** (`ui/src/family/FamilyPhoto.tsx`): the picture. A
+ deterministic seat plan (`seats(capacity)`) places portraits on a hillside
+ scene; portraits are circle-cropped, scale with depth and with the frame, and
+ the viewer's own are ringed in yellow. The next three open spots are drawn,
+ and the first is marked as yours until you are in it. The scene is a
+ placeholder SVG until the real source image is dropped in via the `scene`
+ prop; nothing else needs to change.
+- **Onboarding** (`ui/src/family/Onboarding.tsx`), three steps that complete
+ themselves:
+ 1. _There's a spot for you_: the photo, compact, with your spot marked.
+ 2. _Connect your agent_: the exact `axp park family-photo …` command with a
+ copy button. The step ticks when an executor owned by you appears in the
+ registry.
+ 3. _Your first task: a self-portrait_: what the agent will be asked, with the
+ prompt visible. A maintainer can send it from here; the intended path is
+ automation that sends it the moment an agent parks (an AAMP route or a
+ small maintainer-side watcher; see below).
+- **Gateway** (`src/workspace.ts`): `GET /api/family` lists portraits from
+ every session whose task is `family-photo` or `family-photo-N`, in join
+ order; `GET /api/portrait?session&digest` serves an image blob inline with
+ `nosniff`, a sandboxed CSP and immutable caching. These are the only blobs
+ the gateway serves as images, and only PNG, JPEG, WebP, GIF or SVG under
+ 1.5 MB. The page CSP now allows `blob:` images so authenticated fetches can
+ be shown through object URLs; `` alone cannot carry the token.
+- **Demo**: the fixture creates the `family-photo` session and posts seven
+ portraits the way an agent would (blob upload, then a comment with the image
+ reference and a caption). Procedural villager faces stand in for real
+ portraits.
+- **Test**: the browser suite checks the photo, join order, the image
+ endpoint's headers, that it is unreachable without the token, and the
+ onboarding dialog.
+
+## The protocol, unchanged
+
+A portrait is a comment whose body contains `axp-blob://`.
+Comments are authenticated, ordered, durable, capped at 256 per session and
+already part of export. That is why no new command was added. The 256 cap is
+the reason the gateway accepts `family-photo-2`, `-3`, …: a project shards
+when the front sessions fill. A dedicated `_axp/portrait` command with
+one-per-principal enforcement is the natural next protocol step; the UI
+would not change.
+
+## Malicious, competitive, collaborative
+
+- Front-row spots are first come, first served. That is the competition.
+- One portrait per person is a convention today, not a rule; the gateway shows
+ every portrait a principal posts. Enforcing it belongs in the host.
+- An agent's image tool runs under its contributor's permissions; tool
+ approval is unchanged, so a portrait that requires a tool the maintainer
+ won't allow doesn't get made. Blob size is capped by the host and again by
+ the portrait endpoint.
+- SVG portraits are served with a sandboxed CSP and only ever loaded through
+ ``, which does not execute scripts.
+
+## The portrait generator
+
+Portraits should look like one family, so there is a generator:
+[maceip/axp-avatar](https://github.com/maceip/axp-avatar), branch
+`axp/generative-inputs`. `tools/slice.py` cuts the uploaded sheets into layers
+(backgrounds, mannequin heads with hair or hats, villager and alternative
+bases, face accessories with the mannequin subtracted, isolated headwear) and
+`tools/compose.py` turns a seed into a stable 512px avatar: skin retoned by
+seed, the notion-avatar line parts (eyebrows, eyes, nose, mouth, sometimes a
+beard) placed by mapping the notion face box onto the head's skin box, then
+glasses and a hat. `--transparent` gives the family-photo cutout. The task
+prompt points agents at it as the "our tool" option; agents with their own
+image tool are free to use that instead.
+
+The sheets are game renders and are fine as prototype inputs; anything
+published should replace them with our own drawings in the same slots (the
+README in that repo says so too). The big empty group scene will arrive in the
+same repo and drops into `FamilyPhoto`'s `scene` prop.
+
+## Next
+
+- The real scene image, and a layout pass against it (the seat plan is a
+ function; rows, pitch and depth are four numbers).
+- Automation for step three: send `FAMILY_TASK_PROMPT` when a new executor
+ claims the family session.
+- Show the portrait beside its author everywhere avatars appear today.
diff --git a/src/workspace-contract.ts b/src/workspace-contract.ts
index 13c2745..03efccd 100644
--- a/src/workspace-contract.ts
+++ b/src/workspace-contract.ts
@@ -56,3 +56,20 @@ export interface WorkspaceCommand {
| { kind: "accept"; checkpoint: string; manifestDigest: string }
| { kind: "submit"; checkpoint: string; model: string };
}
+
+/** One person's portrait in the family photo. */
+export interface Portrait {
+ id: string;
+ author: string;
+ session: string;
+ digest: string;
+ createdAt: number;
+ caption: string;
+}
+export interface FamilyPhoto {
+ /** sessions that feed the photo (task `family-photo`, `family-photo-2`, …) */
+ sessions: string[];
+ /** in join order; index is the spot */
+ portraits: Portrait[];
+ capacity: number;
+}
diff --git a/src/workspace.ts b/src/workspace.ts
index 442e2a6..2079d5f 100644
--- a/src/workspace.ts
+++ b/src/workspace.ts
@@ -19,11 +19,24 @@ import { id, sha, digest } from "./protocol/schema.js";
import { Codes, requireThat, ProtocolError } from "./protocol/errors.js";
import { hashObject } from "./hash.js";
import { WorkspaceCommands } from "./workspace-commands.js";
+
import type {
Contribution,
ContributionDetail,
+ FamilyPhoto,
+ Portrait,
WorkspaceView,
} from "./workspace-contract.js";
+const PORTRAIT_TYPES = new Set([
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+ "image/gif",
+ "image/svg+xml",
+]);
+const PORTRAIT_MAX_BYTES = 1_500_000;
+/** Spots in the photo. The layout scales to this; sharding sessions raises it. */
+const FAMILY_CAPACITY = 1000;
export interface WorkspaceOptions {
url: string;
@@ -232,6 +245,58 @@ export class WorkspaceServer {
totalTurns: chat.turns.length,
};
}
+ /** The family photo: every portrait posted to the project's family sessions.
+ *
+ * A portrait is a discussion comment in a session whose task is
+ * `family-photo` (or `family-photo-N`, so a project can shard past the
+ * 256-comment discussion cap) whose body references an image blob in that
+ * session. Order is join order, which is what decides your spot. */
+ private async family(client: AxpClient): Promise {
+ const { items } = await client.ahp.request("listSessions", {
+ channel: ROOT,
+ });
+ const portraits: Portrait[] = [];
+ const sessions: string[] = [];
+ // Comments posted in the same millisecond keep the host's sequence.
+ const order = new Map();
+ for (const item of items) {
+ const session = id.parse(item.resource.slice("ahp-session:/".length));
+ const state = await this.snapshot(
+ client,
+ channels(session).exchange,
+ );
+ if (!/^family-photo(-\d+)?$/.test(state.task)) continue;
+ sessions.push(session);
+ const prefix = `axp-blob:/${encodeURIComponent(state.resource)}/`;
+ for (const [index, comment] of (state.discussion ?? []).entries()) {
+ const match = comment.body.match(
+ new RegExp(
+ `${prefix.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}([a-f0-9]{64})`,
+ ),
+ );
+ if (!match) continue;
+ order.set(comment.id, index);
+ portraits.push({
+ id: comment.id,
+ author: comment.author,
+ session,
+ digest: match[1]!,
+ createdAt: comment.createdAt,
+ caption: comment.body
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
+ .replace(/axp-blob:\/\S+/g, "")
+ .trim()
+ .slice(0, 140),
+ });
+ }
+ }
+ portraits.sort(
+ (a, b) =>
+ a.createdAt - b.createdAt ||
+ (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0),
+ );
+ return { sessions, portraits, capacity: FAMILY_CAPACITY };
+ }
private async workspace(
client: AxpClient,
offset: number,
@@ -333,7 +398,7 @@ export class WorkspaceServer {
response.setHeader("referrer-policy", "no-referrer");
response.setHeader(
"content-security-policy",
- "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
+ "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
);
try {
requireThat(
@@ -435,6 +500,43 @@ export class WorkspaceServer {
}
return;
}
+ if (url.pathname === "/api/family" && request.method === "GET") {
+ json(200, await this.family(client));
+ return;
+ }
+ if (url.pathname === "/api/portrait" && request.method === "GET") {
+ // Portraits are the only blobs the gateway serves as images. Content
+ // addressing makes them immutable, so they cache for a year; nosniff
+ // and an inline disposition keep an the only consumer.
+ const session = id.parse(url.searchParams.get("session"));
+ const sha256 = digest.parse(url.searchParams.get("digest"));
+ const blob = await client.call("_axp/blobGet", {
+ channel: channels(session).exchange,
+ digest: sha256,
+ });
+ requireThat(
+ PORTRAIT_TYPES.has(blob.mediaType),
+ Codes.invalid,
+ "Portraits must be PNG, JPEG, WebP, GIF or SVG",
+ );
+ const bytes = Buffer.from(blob.data, "base64");
+ requireThat(
+ bytes.length <= PORTRAIT_MAX_BYTES,
+ Codes.limit,
+ "Portraits are limited to 1.5 MB",
+ );
+ response.writeHead(200, {
+ "content-type": blob.mediaType,
+ "content-length": bytes.length,
+ "content-disposition": "inline",
+ "cache-control": "private, max-age=31536000, immutable",
+ "x-content-type-options": "nosniff",
+ "content-security-policy":
+ "sandbox; default-src 'none'; style-src 'unsafe-inline'",
+ });
+ response.end(bytes);
+ return;
+ }
if (url.pathname === "/api/patch" && request.method === "GET") {
const session = id.parse(url.searchParams.get("session"));
const checkpoint = sha.parse(url.searchParams.get("checkpoint"));
diff --git a/test/browser/workspace.spec.ts b/test/browser/workspace.spec.ts
index 272f7b4..395bdc0 100644
--- a/test/browser/workspace.spec.ts
+++ b/test/browser/workspace.spec.ts
@@ -510,3 +510,69 @@ test("stored agent content is inspectable and downloadable without executing its
await f.close();
}
});
+
+test("the family photo shows every posted portrait in join order and serves images only through the gateway", async ({
+ page,
+}) => {
+ const f = await workspaceFixture();
+ try {
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ const link = new URL(await f.open("contributor"));
+ await page.goto(link.href);
+ await page
+ .getByRole("navigation", { name: "Workspace" })
+ .getByRole("button", { name: "People" })
+ .click();
+ const photo = page.locator(".family-photo");
+ await expect(photo.locator(".fp-person")).toHaveCount(7);
+ // portraits load through the authenticated portrait endpoint as blob: URLs
+ await expect(photo.locator(".fp-person img").first()).toHaveAttribute(
+ "src",
+ /^blob:/,
+ );
+ await expect(photo.locator("figcaption")).toContainText(
+ "7 of 1000 spots taken",
+ );
+ // the contributor's own portraits are marked, and the next spot is offered
+ await expect(photo.locator(".fp-person.is-mine")).toHaveCount(3);
+ await expect(photo.locator(".fp-open")).toHaveCount(3);
+ // the endpoint refuses to serve a non-image blob and is not reachable without the token
+ const access = new URLSearchParams(link.hash.slice(1)).get("access");
+ const family = await page.request.get(`${link.origin}/api/family`, {
+ headers: { authorization: `Bearer ${access}` },
+ });
+ const listed = (await family.json()) as {
+ portraits: { session: string; digest: string; author: string }[];
+ };
+ expect(listed.portraits.map((p) => p.author).slice(0, 3)).toEqual([
+ "contributor",
+ "maintainer",
+ "verifier",
+ ]);
+ const first = listed.portraits[0]!;
+ const image = await page.request.get(
+ `${link.origin}/api/portrait?session=${first.session}&digest=${first.digest}`,
+ { headers: { authorization: `Bearer ${access}` } },
+ );
+ expect(image.status()).toBe(200);
+ expect(image.headers()["content-type"]).toBe("image/svg+xml");
+ expect(image.headers()["content-disposition"]).toBe("inline");
+ expect(image.headers()["x-content-type-options"]).toBe("nosniff");
+ const anonymous = await page.request.get(
+ `${link.origin}/api/portrait?session=${first.session}&digest=${first.digest}`,
+ );
+ expect(anonymous.status()).toBe(403);
+ // onboarding is built around the photo
+ await page.getByRole("button", { name: "Getting started" }).click();
+ const dialog = page.getByRole("dialog");
+ await expect(dialog).toContainText("Join the family photo");
+ // the fixture's contributor already has an agent connected, so step two
+ // completes itself instead of showing the park command
+ await expect(dialog).toContainText("Local ACP agent is here");
+ await expect(dialog.locator(".fp-person")).toHaveCount(7);
+ expect(errors).toEqual([]);
+ } finally {
+ await f.close();
+ }
+});
diff --git a/test/workspace-fixture.ts b/test/workspace-fixture.ts
index f95634b..b29f2be 100644
--- a/test/workspace-fixture.ts
+++ b/test/workspace-fixture.ts
@@ -185,6 +185,36 @@ export async function workspaceFixture() {
});
}
}
+ // The family photo: a session whose task is `family-photo`, with a few
+ // portraits posted the way an agent would (blob upload, then a comment
+ // carrying the image reference and a caption).
+ await maintainer.ahp.request("createSession", {
+ channel: channels("family-photo").session,
+ provider: "axp",
+ config: { title: "The family photo", task: "family-photo" },
+ });
+ const familyCaptions: [string, AxpClient, string][] = [
+ ["contributor", contributor, "Parser fixer. Likes small diffs."],
+ ["maintainer", maintainer, "Keeps the lights on."],
+ ["verifier", verifier, "Runs the tests twice."],
+ ["contributor", contributor, "Also here as a second agent."],
+ ["maintainer", maintainer, "Reviews before coffee."],
+ ["verifier", verifier, "Trusts, then verifies."],
+ ["contributor", contributor, "Writes the docs nobody asked for."],
+ ];
+ for (const [index, [, client, caption]] of familyCaptions.entries()) {
+ const ref = await client.call("_axp/blobPut", {
+ channel: channels("family-photo").exchange,
+ data: Buffer.from(portraitSvg(index)).toString("base64"),
+ mediaType: "image/svg+xml",
+ });
+ await client.call("_axp/comment", {
+ channel: channels("family-photo").exchange,
+ body: ` ${caption}`,
+ checkpoint: null,
+ path: null,
+ });
+ }
const open = async (
role: Principal["role"] = "maintainer",
signingKey?: string,
@@ -215,3 +245,48 @@ export async function workspaceFixture() {
verifier,
};
}
+
+/** A small round face, different every time: skin, hair, blush, expression. */
+function portraitSvg(seed: number): string {
+ const skins = [
+ "#f6d7b8",
+ "#e8b894",
+ "#c98e63",
+ "#8d5a3b",
+ "#f1c9a5",
+ "#b87552",
+ "#ffe0c2",
+ ];
+ const hairs = [
+ "#3a2a20",
+ "#7a4a2a",
+ "#d9a441",
+ "#1e1e24",
+ "#a04a2a",
+ "#6c6c7a",
+ "#e7c58a",
+ ];
+ const shirts = [
+ "#58aa72",
+ "#7dc395",
+ "#f3d43a",
+ "#f4a0c6",
+ "#8cc5f2",
+ "#3d7a52",
+ "#e0b800",
+ ];
+ const skin = skins[seed % skins.length];
+ const hair = hairs[(seed * 3) % hairs.length];
+ const shirt = shirts[(seed * 5) % shirts.length];
+ const smile =
+ seed % 3 === 0
+ ? "M40 66 Q50 74 60 66"
+ : seed % 3 === 1
+ ? "M42 68 Q50 72 58 68"
+ : "M43 67 L57 67";
+ const fringe =
+ seed % 2 === 0
+ ? ``
+ : ``;
+ return ``;
+}
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index d3254ac..e86166b 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
import {
ArrowRight,
ArrowUpRight,
- Check,
CircleHelp,
GitBranch,
LayoutGrid,
@@ -17,13 +16,14 @@ import { useWorkspace } from "./api.js";
import {
Avatar,
ContributionCard,
- Dialog,
Empty,
Loading,
Mark,
people,
} from "./components.js";
import { ContributionPage } from "./Contribution.js";
+import { Onboarding } from "./family/Onboarding.js";
+import { FamilyPhoto } from "./family/FamilyPhoto.js";
type Page = "overview" | "contributions" | "people" | "activity";
function currentPage(): Page {
@@ -520,6 +520,11 @@ export default function App() {
)}
{page === "people" && (
<>
+ navigate(id)}
+ />