@unoverse-platform/studio 0.1.115 → 0.1.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/local/catalog.ts +3 -0
  2. package/local/plugin.ts +12 -9
  3. package/local/scaffold.d.mts +1 -0
  4. package/local/scaffold.mjs +12 -0
  5. package/package.json +1 -1
  6. package/screens/AgentChain.tsx +232 -0
  7. package/screens/AgentsView.tsx +85 -6
  8. package/screens/SkillsView.tsx +21 -3
  9. package/screens/TemplatesLibraryView.tsx +32 -3
  10. package/screens/TemplatesView.tsx +2 -2
  11. package/screens/index.ts +4 -1
  12. package/src/host.ts +5 -0
  13. package/src/index.css +11 -0
  14. package/vendor/base/src/agent-mcp/discovery.ts +5 -2
  15. package/vendor/base/src/agent-mcp/index.ts +3 -1
  16. package/vendor/base/src/agent-mcp/invoke.ts +199 -25
  17. package/vendor/base/src/agent-mcp/progressive.ts +124 -0
  18. package/vendor/base/src/agent-mcp/tools.ts +18 -11
  19. package/vendor/base/src/agent-mcp/types.ts +8 -0
  20. package/vendor/base/src/auth/agentSwitch.ts +52 -0
  21. package/vendor/base/src/auth/agentToken.ts +142 -0
  22. package/vendor/base/src/auth/auth.ts +7 -0
  23. package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
  24. package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
  25. package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
  26. package/vendor/base/src/definitions/load/load.ts +6 -1
  27. package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
  28. package/vendor/base/src/definitions/registry/catalog.ts +1 -1
  29. package/vendor/base/src/definitions/registry/objects.ts +109 -5
  30. package/vendor/base/src/definitions/registry/pipelines.ts +24 -5
  31. package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
  32. package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
  33. package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
  34. package/vendor/base/src/items/collect.ts +10 -0
  35. package/vendor/base/src/items/publish.ts +10 -0
  36. package/vendor/base/src/lint/design/index.mjs +14 -3
  37. package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
  38. package/vendor/base/src/lint/objects/index.d.mts +1 -0
  39. package/vendor/base/src/lint/objects/index.mjs +121 -3
  40. package/vendor/base/src/lint/objects/rules.d.mts +3 -0
  41. package/vendor/base/src/lint/objects/rules.mjs +106 -7
  42. package/vendor/base/src/manifests/compose.ts +3 -0
  43. package/vendor/base/src/manifests/executor/callback.ts +3 -2
  44. package/vendor/base/src/manifests/executor/memory.ts +27 -0
  45. package/vendor/base/src/manifests/executor/toolBridge.ts +79 -13
  46. package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
  47. package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
  48. package/vendor/base/src/manifests/runtime/events.ts +1 -1
  49. package/vendor/base/src/manifests/runtime/http/request.ts +47 -4
  50. package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
  51. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +59 -24
  52. package/vendor/base/src/manifests/runtime/usage.ts +13 -5
  53. package/vendor/base/src/platform/agentBudget.ts +65 -0
  54. package/vendor/base/src/platform/agentCalendar.ts +87 -0
  55. package/vendor/base/src/platform/inbound.ts +67 -0
  56. package/vendor/base/src/platform/taskChanged.ts +25 -0
  57. package/vendor/base/src/platform/tokenCost.ts +414 -0
  58. package/vendor/base/src/security/permissions.ts +10 -2
  59. package/vendor/sdk/appEngine.ts +42 -1
  60. package/vendor/sdk/lib/core/actions.ts +10 -0
  61. package/vendor/sdk/lib/core/apps.ts +2 -2
  62. package/vendor/sdk/lib/core/client.ts +11 -0
  63. package/vendor/sdk/lib/core/store.ts +1 -1
  64. package/vendor/sdk/lib/core/types.ts +2 -0
  65. package/vendor/sdk/lib/dom/app.ts +3 -2
  66. package/vendor/sdk/lib/dom/component.ts +38 -1
  67. package/vendor/sdk/lib/dom/element.ts +23 -0
  68. package/vendor/sdk/lib/dom/markdown.ts +6 -2
  69. package/vendor/sdk/lib/dom/primitives.ts +97 -6
  70. package/vendor/sdk/lib/voiceService.ts +3 -1
  71. package/vendor/sdk/main.ts +1 -1
  72. package/vendor/sdk/pageInterfaces.ts +109 -8
package/local/catalog.ts CHANGED
@@ -28,6 +28,7 @@ type Loaders = {
28
28
  listIdentity: (org?: string) => unknown[];
29
29
  /** The Agents of one org or every org: `<org>/<name>` items with their meta and door. */
30
30
  listAgents: (org?: string) => unknown[];
31
+ listAgentFolders: (org?: string) => unknown[];
31
32
  resolveTheme: (name: string) => unknown;
32
33
  DEFAULT_ORG: string;
33
34
  /** A project's on-disk home. The writable set is built from these (see source.ts). */
@@ -112,6 +113,7 @@ export async function loadCatalog(
112
113
  const homes = await load(resolve(baseSrc, "definitions/registry/homes.ts"));
113
114
  const identity = await load(resolve(baseSrc, "definitions/registry/orgDocuments.ts"));
114
115
  const agents = await load(resolve(baseSrc, "definitions/registry/agents.ts"));
116
+ const agentFolders = await load(resolve(baseSrc, "definitions/registry/agentFolders.ts"));
115
117
 
116
118
  return {
117
119
  listDefinitions: defs.listDefinitions as Loaders["listDefinitions"],
@@ -135,6 +137,7 @@ export async function loadCatalog(
135
137
  return out;
136
138
  }) as Loaders["listIdentity"],
137
139
  listAgents: agents.listAgents as Loaders["listAgents"],
140
+ listAgentFolders: agentFolders.listAgentFolders as Loaders["listAgentFolders"],
138
141
  resolveTheme: theme.resolveTheme as Loaders["resolveTheme"],
139
142
  DEFAULT_ORG: theme.DEFAULT_ORG as string,
140
143
  projectDir: defs.projectDir as Loaders["projectDir"],
package/local/plugin.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * See docs/architecture/authoring/LOCAL_STUDIO.md.
17
17
  */
18
- import { designHome, authoringRoot, orgRepoName, lintOptions, platformHome } from "./design-home.mjs";
18
+ import { authoringRoot, orgRepoName, lintOptions, platformHome } from "./design-home.mjs";
19
19
  import { createRequire } from "node:module";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { resolve, dirname } from "path";
@@ -177,12 +177,15 @@ function readMarkdownLibrary(dir: string): MarkdownEntry[] {
177
177
  * Scoping is the server's `?org=`: an org sees the universal tier plus its own and never
178
178
  * another org's, the rule RX_ORG_MODEL.md settled for a project's assets.
179
179
  */
180
- function readLibrary(projectRoot: string, marketplace: string, folder: "skills" | "blocks", orgs: string[], org?: string): MarkdownEntry[] {
180
+ function readLibrary(projectDir: (org: string) => string, marketplace: string, folder: "skills" | "blocks", orgs: string[], org?: string): MarkdownEntry[] {
181
181
  const out = readMarkdownLibrary(resolve(marketplace, folder));
182
182
  for (const o of orgs) {
183
183
  if (org && org !== "all" && o !== org) continue;
184
- // The org's own folder: the repo root in an org repo (UNO-88), else design/<org>/.
185
- const orgHome = orgRepoName(projectRoot) ? projectRoot : resolve(designHome(projectRoot), o);
184
+ // The org's own folder, found as every other screen here finds it (base projectDir): the
185
+ // repo root in an org repo (UNO-88), else its authored folder, else the copy the universe
186
+ // installed. Working it out here alone missed the installed copy, so Studio run from the
187
+ // platform listed no org's skills while the server listed them all.
188
+ const orgHome = projectDir(o);
186
189
  for (const e of readMarkdownLibrary(resolve(orgHome, folder))) {
187
190
  // A SKILL's lookup name carries its org, because that is the name every reader
188
191
  // addresses it by and two orgs may hold one bare name. A BLOCK's does not: the
@@ -445,9 +448,9 @@ export function localStudioHost(): Plugin {
445
448
  case path === "/dev/identity":
446
449
  return json(res, { items: c.listIdentity(org) });
447
450
 
448
- // Agents: the one file authored for the map, design/<org>/agents/<name>.yaml.
451
+ // Agents: folders in the one shape (agents/<name>/agent.yaml), and the older flat files.
449
452
  case path === "/dev/agents":
450
- return json(res, { items: c.listAgents(org) });
453
+ return json(res, { items: c.listAgents(org), folders: c.listAgentFolders(org) });
451
454
 
452
455
  case path === "/dev/atoms":
453
456
  return json(res, { atoms: c.listDefinitions("atom") });
@@ -611,17 +614,17 @@ export function localStudioHost(): Plugin {
611
614
  return json(res, selection ?? { empty: true });
612
615
 
613
616
  case path === "/skills":
614
- return json(res, { skills: readLibrary(projectRoot, c.marketplace, "skills", c.listOrgs(), org) });
617
+ return json(res, { skills: readLibrary(c.projectDir, c.marketplace, "skills", c.listOrgs(), org) });
615
618
 
616
619
  case path.startsWith("/skills/"): {
617
620
  const name = decodeURIComponent(path.slice("/skills/".length).replace(/\/file$/, ""));
618
- const found = readLibrary(projectRoot, c.marketplace, "skills", c.listOrgs()).find((s) => s.name === name || s.id === name);
621
+ const found = readLibrary(c.projectDir, c.marketplace, "skills", c.listOrgs()).find((s) => s.name === name || s.id === name);
619
622
  // SkillsView reads `d.skill`, matching the server's shape. Not the bare object.
620
623
  return found ? json(res, { skill: found }) : json(res, { error: "skill not found" }, 404);
621
624
  }
622
625
 
623
626
  case path === "/prompt-blocks":
624
- return json(res, { blocks: readLibrary(projectRoot, c.marketplace, "blocks", c.listOrgs(), org) });
627
+ return json(res, { blocks: readLibrary(c.projectDir, c.marketplace, "blocks", c.listOrgs(), org) });
625
628
 
626
629
  // Registry toggles are a DEPLOYED concept: they persist to installed_plugins
627
630
  // and decide what a running universe exposes. Local Studio has no database and
@@ -13,6 +13,7 @@ export function createProject(home: string, name: string): string;
13
13
  export function createFirstProject(home: string, name: string): string;
14
14
 
15
15
  /** Make `dir` one org's own repo (UNO-88): folders at the root, unoverse.yaml declaring the org, samples, editor globs, the check, Git. */
16
+ export const ORG_FOLDERS: string[];
16
17
  export function createOrgRepo(dir: string, org: string): { state: "committed" | "no-identity" | "inside-repo" | "no-git"; message?: string };
17
18
 
18
19
  /** Make `home` a Git repo on main with a first commit, unless it already is one; never an author it was not given. */
@@ -77,6 +77,9 @@ export function createProject(home, name) {
77
77
  return fillOrgFolder(root);
78
78
  }
79
79
 
80
+ /** The folder of every authored kind, at an org repo's root (packages/docs/onboarding/studio.md). */
81
+ export const ORG_FOLDERS = ["components", "apps", "templates", "atoms", "skills", "blocks", "nodes", "agents", "objects", "pipelines"];
82
+
80
83
  /**
81
84
  * One org's folder, filled: the welcome component, the sample styles, identity, app and
82
85
  * chat component. Shared by the monorepo shape (design/<org>/) and an org repo, whose root
@@ -93,6 +96,15 @@ function fillOrgFolder(root, { baselineOnly = false } = {}) {
93
96
  const dirs = baselineOnly ? ["styles", "identity"] : ["components/welcome", "apps", "styles", "skills", "blocks", "identity"];
94
97
  for (const d of dirs) mkdirSync(join(root, d), { recursive: true });
95
98
  if (!baselineOnly) writeFileSync(join(root, "components/welcome/welcome.yaml"), WELCOME);
99
+ // EVERY KIND'S FOLDER, EMPTY (owner, 2026-10-25). The baseline ruling dropped the SAMPLES; the
100
+ // folders went with them only because they were made beside the samples. A new org shows
101
+ // where each kind lives from the first minute. `.gitkeep` because Git keeps no empty folder.
102
+ if (baselineOnly) {
103
+ for (const d of ORG_FOLDERS) {
104
+ mkdirSync(join(root, d), { recursive: true });
105
+ if (readdirSync(join(root, d)).length === 0) writeFileSync(join(root, d, ".gitkeep"), "");
106
+ }
107
+ }
96
108
 
97
109
  // Worked example styles (sample-styles: brand ingredients + the override rules), so
98
110
  // styles/ is never an empty folder with no hint of the token cascade. Per PROJECT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.115",
3
+ "version": "0.1.117",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -0,0 +1,232 @@
1
+ /**
2
+ * AN AGENT, DRAWN (docs/architecture/signal/AGENTS.md § 3): a chain read straight off its
3
+ * agent.yaml, the way a pipeline's chain is read off its pipeline.yaml (Spatial's PipelineChain).
4
+ * Three kinds, drawn as three: its SKILLS feed in from above (what it knows: read into it before
5
+ * it starts, never called), its TRIGGERS wake it, and its TASKS are what it does (its org's own,
6
+ * then the marketplace MCPs). Below: its soul, access and settings. Nothing here is written for any
7
+ * one Agent: a new folder draws its own chain.
8
+ */
9
+ import { BookOpen, CalendarDays, ChevronDown, ChevronRight, Compass, Mail, MessageSquare, Mic, Plug, Radio, Workflow } from "lucide-react";
10
+
11
+ export interface AgentFolderItem {
12
+ ref: string;
13
+ org: string;
14
+ name: string;
15
+ title?: string;
16
+ role?: string;
17
+ description?: string;
18
+ whenToUse?: string;
19
+ category?: string;
20
+ version?: string;
21
+ /** Its picture, an image URL. */
22
+ avatar?: string;
23
+ skills: string[];
24
+ model?: { use?: string; reasoning?: string; fallback?: string; turns?: number };
25
+ budget?: { perDay?: string; perRun?: string };
26
+ tasks: string[];
27
+ access: string[];
28
+ triggers: { name: string; node?: string; wakes?: string; config?: Record<string, unknown> }[];
29
+ checkIn?: string;
30
+ memory?: { agent?: string };
31
+ askWhenUnsure?: string;
32
+ soul: string;
33
+ path: string;
34
+ /** Each skill in words, from its SKILL.md (server: devRoutes `withAbout`). */
35
+ skillsAbout?: { name: string; title: string; description: string }[];
36
+ /** Each marketplace MCP among its tasks, by URI, as its node names itself. */
37
+ mcpsAbout?: Record<string, { title: string; description: string }>;
38
+ }
39
+
40
+ const WAY_ICONS: Record<string, typeof Radio> = { spatial: Compass, slack: MessageSquare, email: Mail, voice: Mic, calendar: CalendarDays };
41
+ const WAKES: Record<string, string> = { mentions: "wakes on a mention", replies: "wakes on a reply", events: "wakes when an event starts" };
42
+ /** What a permission lets it reach, in words; the strict ones said plainly (AGENTS.md § 7a). */
43
+ const ACCESS_WORDS: Record<string, string> = {
44
+ "signal:read-all": "Can see everyone in Signal",
45
+ "signal:write": "Can add people to Signal",
46
+ };
47
+
48
+ /** Triggers | › | the Agent | › | what it does: the skills row above shares these columns. */
49
+ const COLUMNS = "minmax(0,1fr) auto minmax(0,1.1fr) auto minmax(0,1fr)";
50
+ const LABEL = "mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500";
51
+ const TILE = "flex items-center gap-2.5 rounded-lg border border-gray-200 bg-white px-3 py-2";
52
+ const GLYPH = "flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white text-gray-800";
53
+
54
+ /** `unoverse://tasks/<org>/<name>` → its name, and its org when it is not the Agent's own. */
55
+ function taskLabel(uri: string, org: string): { name: string; from?: string } {
56
+ const m = /^unoverse:\/\/tasks\/([^/]+)\/(.+)$/.exec(uri);
57
+ if (!m) return { name: uri };
58
+ return m[1] === org ? { name: m[2] } : { name: m[2], from: m[1] };
59
+ }
60
+
61
+ /**
62
+ * `mailbox`: its connected address in this environment (never in the file), drawn on the triggers
63
+ * its mailbox serves; `says`, by trigger, whether each works here today; `onConnect`, when the
64
+ * viewer may connect one, puts Connect there.
65
+ */
66
+ export function AgentChain({ agent: given, mailbox, says, onConnect, connecting }: { agent: AgentFolderItem; mailbox?: string | null; says?: Record<string, string>; onConnect?: () => void; connecting?: boolean }) {
67
+ // Every list may be absent (a hand-written file, an older server): drawn as empty, never a crash.
68
+ const agent: AgentFolderItem = {
69
+ ...given,
70
+ skills: given.skills ?? [],
71
+ tasks: given.tasks ?? [],
72
+ access: given.access ?? [],
73
+ triggers: given.triggers ?? [],
74
+ soul: given.soul ?? "",
75
+ };
76
+ const ways = agent.triggers;
77
+ // Its skills in words; a server that sends none still draws each by name.
78
+ const skills = agent.skillsAbout ?? agent.skills.map((name) => ({ name, title: name, description: "" }));
79
+ // What it does, in two kinds: its org's own tasks, then the marketplace MCPs it was given.
80
+ const mcps = agent.tasks.filter((uri) => agent.mcpsAbout?.[uri]);
81
+ const own = agent.tasks.filter((uri) => !agent.mcpsAbout?.[uri]);
82
+ return (
83
+ <>
84
+ {/* WHAT IT KNOWS: its skills, feeding into it from above */}
85
+ <div className="mb-1 grid gap-3" style={{ gridTemplateColumns: COLUMNS }}>
86
+ <div />
87
+ <ChevronRight size={18} className="invisible" />
88
+ <div>
89
+ <div className={LABEL}>Skills · how it works</div>
90
+ {skills.length === 0 && <div className="text-[12px] text-gray-500">None</div>}
91
+ <div className="flex flex-col gap-2">
92
+ {skills.map((k) => (
93
+ <div key={k.name} className="flex items-start gap-2.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-2" title={k.name}>
94
+ <span className={GLYPH}><BookOpen size={15} strokeWidth={1.8} /></span>
95
+ <div className="min-w-0">
96
+ <div className="truncate text-[13px] font-medium text-gray-900">{k.title}</div>
97
+ {k.description && <div className="line-clamp-2 text-[11px] text-gray-500">{k.description}</div>}
98
+ </div>
99
+ </div>
100
+ ))}
101
+ </div>
102
+ <div className="flex justify-center py-1 text-gray-300"><ChevronDown size={18} /></div>
103
+ </div>
104
+ <ChevronRight size={18} className="invisible" />
105
+ <div />
106
+ </div>
107
+
108
+ {/* THE CHAIN: triggers → the Agent → what it does */}
109
+ {/* Top-aligned: the Agent sits right under its skills, however many tasks it has. */}
110
+ <div className="mb-6 grid items-start gap-3" style={{ gridTemplateColumns: COLUMNS }}>
111
+ <div className="flex flex-col gap-2">
112
+ <div className={LABEL}>Triggers · what wakes it</div>
113
+ {ways.length === 0 && <div className="text-[12px] text-gray-500">None yet</div>}
114
+ {ways.map((w) => {
115
+ const Icon = WAY_ICONS[w.name] ?? Radio;
116
+ const mail = w.node === "MailboxMCP";
117
+ return (
118
+ <div key={w.name} className={TILE}>
119
+ <span className={GLYPH}><Icon size={15} strokeWidth={1.8} /></span>
120
+ <div className="min-w-0 flex-1">
121
+ <div className="truncate text-[13px] font-medium capitalize text-gray-900">{w.name}</div>
122
+ <div className="truncate text-[11px] text-gray-500">{mail && mailbox !== undefined ? mailbox ?? "No mailbox connected" : says?.[w.name] ?? (w.name === "spatial" ? "found in Spatial" : [w.node, w.wakes ? WAKES[w.wakes] ?? w.wakes : null].filter(Boolean).join(", "))}</div>
123
+ </div>
124
+ {mail && onConnect && (
125
+ <button type="button" disabled={connecting} onClick={onConnect} className="shrink-0 rounded-md border border-gray-300 bg-white px-2 py-1 text-[11px] font-medium text-gray-800 hover:bg-gray-50 disabled:opacity-50">
126
+ {connecting ? "Connecting" : mailbox ? "Change" : "Connect"}
127
+ </button>
128
+ )}
129
+ </div>
130
+ );
131
+ })}
132
+ </div>
133
+
134
+ <ChevronRight size={18} className="mt-14 text-gray-300" />
135
+
136
+ <div className="mt-6 rounded-xl border border-gray-300 bg-gray-50 p-4">
137
+ <div className="mb-3 flex items-center gap-3">
138
+ {agent.avatar && <img src={agent.avatar} alt="" className="h-12 w-12 shrink-0 rounded-full border border-gray-200 object-cover" />}
139
+ <div className="min-w-0">
140
+ <div className="text-[15px] font-semibold text-gray-900">{agent.title ?? agent.name}</div>
141
+ {agent.role && <div className="text-[12px] text-gray-600">{agent.role}</div>}
142
+ </div>
143
+ </div>
144
+ <div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12px]">
145
+ <div className="text-gray-500">Model</div>
146
+ <div className="truncate font-mono text-gray-800">{agent.model?.use ?? "not set"}</div>
147
+ <div className="text-gray-500">Reasoning</div>
148
+ <div className="text-gray-800">{agent.model?.reasoning ?? "the model's default"}</div>
149
+ <div className="text-gray-500">Turns</div>
150
+ <div className="text-gray-800">{agent.model?.turns ? `${agent.model.turns} a run` : "the model's default"}</div>
151
+ <div className="text-gray-500">Budget</div>
152
+ <div className="text-gray-800">
153
+ {agent.budget?.perDay ? `${agent.budget.perDay} a day` : "not set"}
154
+ {agent.budget?.perRun ? `, ${agent.budget.perRun} a run` : ""}
155
+ </div>
156
+ <div className="text-gray-500">Memory</div>
157
+ <div className="text-gray-800">{agent.memory?.agent === "on" ? "its own" : "off"}</div>
158
+ </div>
159
+ </div>
160
+
161
+ <ChevronRight size={18} className="mt-14 text-gray-300" />
162
+
163
+ <div className="flex flex-col gap-2">
164
+ <div className={LABEL}>Tasks · what it does</div>
165
+ {agent.tasks.length === 0 && <div className="text-[12px] text-gray-500">None</div>}
166
+ {own.map((uri) => {
167
+ const t = taskLabel(uri, agent.org);
168
+ return (
169
+ <div key={uri} className={TILE} title={uri}>
170
+ <span className={GLYPH}><Workflow size={15} strokeWidth={1.8} /></span>
171
+ <div className="min-w-0">
172
+ <div className="truncate text-[13px] font-medium text-gray-900">{t.name.replace(/-/g, " ")}</div>
173
+ {t.from && <div className="truncate text-[11px] text-gray-500">from {t.from}</div>}
174
+ </div>
175
+ </div>
176
+ );
177
+ })}
178
+ {mcps.length > 0 && <div className={`${LABEL} mt-2`}>From the marketplace</div>}
179
+ {mcps.map((uri) => {
180
+ const m = agent.mcpsAbout![uri];
181
+ return (
182
+ <div key={uri} className={TILE} title={uri}>
183
+ <span className={GLYPH}><Plug size={15} strokeWidth={1.8} /></span>
184
+ <div className="min-w-0">
185
+ <div className="truncate text-[13px] font-medium text-gray-900">{m.title}</div>
186
+ {m.description && <div className="line-clamp-2 text-[11px] text-gray-500">{m.description}</div>}
187
+ </div>
188
+ </div>
189
+ );
190
+ })}
191
+ </div>
192
+ </div>
193
+
194
+ {/* WHO IT IS */}
195
+ <div className={LABEL}>Soul</div>
196
+ {agent.soul.trim() ? (
197
+ <div className="mb-5 space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-4 text-[13px] leading-relaxed text-gray-800">
198
+ {/* Paragraphs as written: a blank line parts them, a line break inside one is a space. */}
199
+ {agent.soul.trim().split(/\n\s*\n/).map((para, i) => (
200
+ <p key={i}>{para.replace(/\s*\n\s*/g, " ")}</p>
201
+ ))}
202
+ </div>
203
+ ) : (
204
+ <p className="mb-5 text-[12px] text-gray-500">No SOUL.md beside agent.yaml.</p>
205
+ )}
206
+
207
+ <div className={LABEL}>Access</div>
208
+ <div className="mb-5 flex flex-wrap gap-1.5">
209
+ {agent.access.length === 0 && <span className="text-[12px] text-gray-500">No one's information</span>}
210
+ {agent.access.map((a) => (
211
+ <span key={a} title={a} className="rounded-full border border-gray-200 bg-white px-2 py-0.5 text-[11px] text-gray-700">
212
+ {ACCESS_WORDS[a] ?? a}
213
+ </span>
214
+ ))}
215
+ </div>
216
+
217
+ <div className={LABEL}>Settings</div>
218
+ <div className="mb-5 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 p-4 text-[12px] text-gray-800">
219
+ {agent.model?.reasoning && (<><div className="text-gray-500">Reasoning</div><div>{agent.model.reasoning}</div></>)}
220
+ {agent.model?.turns && (<><div className="text-gray-500">Turns</div><div>{agent.model.turns} a run</div></>)}
221
+ {agent.model?.fallback && (<><div className="text-gray-500">Fallback model</div><div className="font-mono">{agent.model.fallback}</div></>)}
222
+ <div className="text-gray-500">Check-in</div>
223
+ <div>{agent.checkIn ? `every ${agent.checkIn}` : "only when reached"}</div>
224
+ {agent.askWhenUnsure && (<><div className="text-gray-500">Asks when unsure</div><div>in {agent.askWhenUnsure}</div></>)}
225
+ <div className="text-gray-500">Email</div>
226
+ <div>its connected mailbox, set in Signal</div>
227
+ <div className="text-gray-500">Folder</div>
228
+ <div className="font-mono">{agent.path}</div>
229
+ </div>
230
+ </>
231
+ );
232
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Agents view — the Agents a project authored (docs.unoverse.ai/design/tasks). Left: a
3
- * navigator. Right: the selected Agent's meta, its door, and the Available switch.
2
+ * Agents view — the Agents a project authored. Left: a navigator. Right: the selected Agent.
3
+ * An Agent in the one shape (agents/<name>/agent.yaml and its soul, AGENTS.md § 3) is drawn as a
4
+ * chain read off its file (AgentChain); an older flat Agent file shows its door until converted.
4
5
  *
5
6
  * An Agent is a workflow with no interface of its own, listed on the map so a conversation
6
7
  * can reach for it: the one thing authored for the map alone (`design/<org>/agents/<name>.yaml`).
@@ -13,6 +14,7 @@ import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-hos
13
14
  import { IdentityHeader } from "./IdentityHeader";
14
15
  import { DOC, EMPTY, NAV, NAV_SECTION, PANE, SCREEN, navItem } from "./chrome";
15
16
  import { EmptyLibrary } from "./EmptyLibrary";
17
+ import { AgentChain, type AgentFolderItem } from "./AgentChain";
16
18
 
17
19
  interface AgentItem {
18
20
  /** `<org>/<name>`: the identity every registry row and toggle keys on. */
@@ -37,29 +39,57 @@ interface AgentItem {
37
39
  }
38
40
 
39
41
  const ACCENT = "#7c3aed";
42
+
43
+ /**
44
+ * COPY FOR CANVAS, AN AGENT WITH A VOICE (AGENTS.md § 5: its `voice` trigger names the node). The
45
+ * canvas's own clipboard payload, as Components writes it: only her face (the voice-chat component,
46
+ * her portrait and name). Clicking it starts her call; she works where she was woken, so her tasks
47
+ * show on that canvas.
48
+ */
49
+ export function agentForCanvas(a: AgentFolderItem) {
50
+ if (!a.triggers.some((t) => t.name === "voice" && t.node)) return null;
51
+ const name = a.title ?? a.name;
52
+ // ONE NODE, ITS FACE. Its voice and its work are its own (engine agentKeys/talk.ts): `agent` is
53
+ // its address, and clicking the face starts its call on this canvas.
54
+ const face = {
55
+ id: `${a.name}-face1`,
56
+ type: "Component",
57
+ position: { x: 0, y: 0 },
58
+ data: { label: name, status: "ready", agent: `unoverse://agents/${a.org}/${a.name}`, config: { component: "voice-chat", portrait: a.avatar ?? "", orbSize: "360", assistantName: name, tagline: a.role ?? "" } },
59
+ style: { width: 520 },
60
+ draggable: true,
61
+ };
62
+ return { type: "gravity-workflow", version: 2, nodes: [face], edges: [] };
63
+ }
40
64
  /** An Agent's mark: a bot's head, antenna up, two eyes. */
41
65
  const AGENT_ICON = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ACCENT}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="9" width="16" height="11" rx="3"/><path d="M12 9V5"/><circle cx="12" cy="4" r="1.2" fill="${ACCENT}"/><circle cx="9" cy="14.5" r="1.3" fill="${ACCENT}" stroke="none"/><circle cx="15" cy="14.5" r="1.3" fill="${ACCENT}" stroke="none"/><path d="M2 13v4M22 13v4"/></svg>`)}`;
42
66
 
43
67
  export function AgentsView({ org = "all" }: { org?: string } = {}) {
44
68
  const [agents, setAgents] = useState<AgentItem[]>([]);
69
+ // AGENTS IN THE ONE SHAPE (agents/<name>/agent.yaml, AGENTS.md § 3), beside the older files.
70
+ const [folders, setFolders] = useState<AgentFolderItem[]>([]);
45
71
  const available = useRegistryToggles("agent");
46
72
  const [selected, setSelected] = usePersistentState<string | null>("wb:agents:selected", null);
47
73
  const [error, setError] = useState<string | null>(null);
74
+ const [copied, setCopied] = useState(false);
48
75
 
49
76
  useEffect(() => {
50
77
  fetchUnoverse(`/dev/agents${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`)
51
78
  .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
52
79
  .then((d) => {
53
80
  const list: AgentItem[] = d.items ?? [];
81
+ const own: AgentFolderItem[] = d.folders ?? [];
54
82
  setAgents(list);
55
- const restore = list.find((a) => a.name === selected) ?? list[0];
56
- setSelected(restore ? restore.name : null);
83
+ setFolders(own);
84
+ const keys = [...own.map((f) => f.ref), ...list.map((a) => a.name)];
85
+ setSelected(keys.includes(selected ?? "") ? selected : (keys[0] ?? null));
57
86
  })
58
87
  .catch((e) => setError(String(e?.message ?? e)));
59
88
  // eslint-disable-next-line react-hooks/exhaustive-deps
60
89
  }, [org]);
61
90
 
62
91
  const detail = agents.find((a) => a.name === selected) ?? null;
92
+ const folder = folders.find((f) => f.ref === selected) ?? null;
63
93
 
64
94
  const byCategory = Object.entries(
65
95
  agents.reduce<Record<string, AgentItem[]>>((acc, a) => {
@@ -68,7 +98,7 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
68
98
  }, {}),
69
99
  ).sort(([a], [b]) => a.localeCompare(b));
70
100
 
71
- if (agents.length === 0) {
101
+ if (agents.length === 0 && folders.length === 0) {
72
102
  return (
73
103
  <div className={SCREEN}>
74
104
  <nav className={NAV} />
@@ -87,6 +117,24 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
87
117
  <div className={SCREEN}>
88
118
  {/* NAVIGATOR */}
89
119
  <nav className={NAV}>
120
+ {folders.length > 0 && (
121
+ <div className="mb-3">
122
+ <div className={NAV_SECTION}>Agents</div>
123
+ {folders.map((f) => (
124
+ <button
125
+ key={f.ref}
126
+ onClick={() => setSelected(f.ref)}
127
+ className={`block w-full rounded px-2 py-1.5 text-left transition ${navItem(selected === f.ref)}`}
128
+ >
129
+ <div className="flex min-w-0 items-baseline gap-2 text-sm font-medium">
130
+ <span className="min-w-0 flex-1 truncate">{f.title ?? f.name}</span>
131
+ <span className="shrink-0 text-[10px] text-gray-500">{f.org}</span>
132
+ </div>
133
+ <div className="truncate text-[11px] text-gray-500">{f.role ?? f.description}</div>
134
+ </button>
135
+ ))}
136
+ </div>
137
+ )}
90
138
  {byCategory.map(([cat, items]) => (
91
139
  <div key={cat} className="mb-3">
92
140
  <div className={NAV_SECTION}>{cat}</div>
@@ -109,7 +157,38 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
109
157
 
110
158
  {/* DETAIL */}
111
159
  <div className={PANE}>
112
- {!selected || !detail ? (
160
+ {selected && folder ? (
161
+ <div className={DOC}>
162
+ <IdentityHeader
163
+ right={
164
+ <div className="flex items-center gap-3">
165
+ {agentForCanvas(folder) && (
166
+ <button
167
+ onClick={async () => {
168
+ await navigator.clipboard.writeText(JSON.stringify(agentForCanvas(folder), null, 2));
169
+ setCopied(true);
170
+ setTimeout(() => setCopied(false), 1600);
171
+ }}
172
+ className="rounded border border-gray-300 px-2 py-0.5 text-[11px] font-medium text-gray-600 transition hover:bg-gray-50"
173
+ title="Copy this Agent, with its voice, for a canvas: paste (Cmd+V) on any workflow canvas"
174
+ >
175
+ {copied ? "✓ Copied, paste on canvas" : "⧉ Copy for Canvas"}
176
+ </button>
177
+ )}
178
+ <ToggleSwitch on={available.isEnabled(selected)} onClick={() => available.toggle(selected)} label="Available" />
179
+ </div>
180
+ }
181
+ logoUrl={folder.avatar ?? AGENT_ICON}
182
+ accent={ACCENT}
183
+ name={folder.title ? `${folder.title}${folder.role ? `, ${folder.role}` : ""}` : selected}
184
+ category={folder.category}
185
+ version={folder.version}
186
+ description={folder.description}
187
+ whenToUse={folder.whenToUse}
188
+ />
189
+ <AgentChain agent={folder} />
190
+ </div>
191
+ ) : !selected || !detail ? (
113
192
  <div className={EMPTY}>Select an Agent</div>
114
193
  ) : (
115
194
  <div className={DOC}>
@@ -14,6 +14,7 @@
14
14
  * "run". So this is a neutral chrome surface, not a themed render.
15
15
  */
16
16
  import { useEffect, useState } from "react";
17
+ import Markdown from "markdown-to-jsx";
17
18
  import { fetchUnoverse } from "studio-host";
18
19
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
19
20
  import { IdentityHeader } from "./IdentityHeader";
@@ -182,9 +183,9 @@ export function SkillsView({ org = "all" }: { org?: string } = {}) {
182
183
  {loadingDetail && !detail ? (
183
184
  <div className="text-sm text-gray-500">Loading…</div>
184
185
  ) : (
185
- <pre className="overflow-x-auto whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-4 font-mono text-[12.5px] leading-relaxed text-gray-800">
186
- {detail?.instructions ?? ""}
187
- </pre>
186
+ <div className="rounded-lg border border-gray-200 bg-white p-6 text-sm leading-relaxed text-gray-800">
187
+ <Markdown options={{ overrides: INSTRUCTION_STYLES }}>{detail?.instructions ?? ""}</Markdown>
188
+ </div>
188
189
  )}
189
190
 
190
191
  {detail?.contentHash && (
@@ -196,3 +197,20 @@ export function SkillsView({ org = "all" }: { org?: string } = {}) {
196
197
  </div>
197
198
  );
198
199
  }
200
+
201
+ /** SKILL.md as it reads, not as it is typed: each markdown element styled for the pane. */
202
+ const INSTRUCTION_STYLES = {
203
+ h1: { props: { className: "mb-3 text-lg font-semibold text-gray-900" } },
204
+ h2: { props: { className: "mb-2 mt-6 text-base font-semibold text-gray-900" } },
205
+ h3: { props: { className: "mb-2 mt-4 text-sm font-semibold text-gray-900" } },
206
+ p: { props: { className: "mb-3" } },
207
+ ul: { props: { className: "mb-3 list-disc space-y-1 pl-5" } },
208
+ ol: { props: { className: "mb-3 list-decimal space-y-1 pl-5" } },
209
+ a: { props: { className: "text-blue-600 underline" } },
210
+ code: { props: { className: "rounded bg-gray-100 px-1 py-0.5 font-mono text-[12px]" } },
211
+ pre: { props: { className: "mb-3 overflow-x-auto rounded-lg bg-gray-50 p-3 font-mono text-[12px] [&>code]:bg-transparent [&>code]:p-0" } },
212
+ table: { props: { className: "mb-3 w-full border-collapse text-left text-[13px]" } },
213
+ th: { props: { className: "border-b border-gray-200 px-2 py-1 font-semibold" } },
214
+ td: { props: { className: "border-b border-gray-100 px-2 py-1 align-top" } },
215
+ blockquote: { props: { className: "mb-3 rounded bg-gray-50 px-3 py-2 text-gray-600" } },
216
+ };
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * NOT TemplatesView.tsx: that name is taken by the Apps screen (the legacy noun).
16
16
  */
17
- import { useEffect, useMemo, useState, useSyncExternalStore, useCallback, type MouseEvent as ReactMouseEvent } from "react";
17
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, useCallback, type MouseEvent as ReactMouseEvent } from "react";
18
18
  import { ComponentStore, type ResolvedTheme } from "@unoverse/sdk";
19
19
  import { UnoverseComponent } from "./react/unoverse";
20
20
  import { client, authedFetch, usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
@@ -56,6 +56,9 @@ interface TemplateDef {
56
56
  preview?: Record<string, string[]>;
57
57
  /** The components it holds, one stand-in per item of its own preview data, per state. */
58
58
  heldSeeds?: Record<string, { key: string; component: string; props: Record<string, unknown> }[]>;
59
+ /** The template's own props (the lists it holds, a picture a hook fills): their previews seed
60
+ * the instance, so a layout-level Each has items to walk and a held card a spot to draw in. */
61
+ props?: Record<string, { preview?: unknown; default?: unknown } | null>;
59
62
  }
60
63
 
61
64
  const PREVIEW_CHAT = "preview";
@@ -184,7 +187,14 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
184
187
  type: "COMPONENT_INIT",
185
188
  chatId: PREVIEW_CHAT,
186
189
  nodeId: TEMPLATE_NODE,
187
- component: { type: ref, componentUrl: `unoverse://templates/${ref}`, props: {} },
190
+ // ITS OWN PROPS, seeded as a part's are (`preview ?? default`): a template whose layout walks
191
+ // a list it holds drew nothing here, because the instance arrived empty while the held
192
+ // stand-ins beneath it waited for an Each that never ran (observed 2026-10-23).
193
+ component: {
194
+ type: ref,
195
+ componentUrl: `unoverse://templates/${ref}`,
196
+ props: Object.fromEntries(Object.entries(current.props ?? {}).map(([k, v]) => [k, v?.preview ?? v?.default]).filter(([, v]) => v !== undefined)),
197
+ },
188
198
  });
189
199
  const base = current.defaultLayout ?? (current.layouts ? Object.keys(current.layouts)[0] : undefined);
190
200
  if (base) store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, { state: base });
@@ -246,6 +256,25 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
246
256
  if ((own?.state ?? own?.view ?? own?.defaultState) !== activeState) store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, { state: activeState });
247
257
  }, [store, holdsItems, activeState]);
248
258
 
259
+ // A VIEW OPENING RUNS THE HOOK SCOPED TO IT (docs/design/lifecycle-hooks.md), as the app's stream
260
+ // would: the template's own fields so far as `props`, the state as the layout entered, and what
261
+ // comes back merged into the instance. Once per view per template; a host with no universe
262
+ // to run it (local Studio) fails quietly and the preview keeps its stand-ins.
263
+ const ranHooks = useRef(new Set<string>());
264
+ useEffect(() => {
265
+ if (!uri || !activeState || !client.runTask) return;
266
+ const key = `${uri}#${activeState}`;
267
+ if (ranHooks.current.has(key)) return;
268
+ ranHooks.current.add(key);
269
+ const own = (store.get(PREVIEW_CHAT, TEMPLATE_NODE) ?? {}) as Record<string, unknown>;
270
+ client
271
+ .runTask(uri, "onEnterView", own, activeState)
272
+ .then((fields) => {
273
+ if (fields && Object.keys(fields).length) store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, fields);
274
+ })
275
+ .catch((e: unknown) => console.warn(`[studio] ${uri} onEnterView(${activeState}) did not run:`, e));
276
+ }, [store, uri, activeState]);
277
+
249
278
  // GROUPED BY CATEGORY, exactly as Components groups its rail — the shelf is no longer a
250
279
  // flat list the moment small templates land on it (the `Blocks` shelf: pair, feature,
251
280
  // single, rail, stack, statement) and a page has to be found among them. The category is
@@ -480,7 +509,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
480
509
  );
481
510
  })()}
482
511
 
483
- <div className="flex-1 overflow-auto p-10">
512
+ <div data-print-stage className="flex-1 overflow-auto p-10">
484
513
  <div className="mx-auto max-w-full transition-[width] duration-200" style={{ width: stageWidth ?? "100%" }}>
485
514
  {uri && <UnoverseComponent key={uri} client={client} store={store} chatId={PREVIEW_CHAT} nodeId={TEMPLATE_NODE} theme={theme} />}
486
515
  </div>
@@ -32,7 +32,7 @@ import { NAV, NAV_SECTION, SCREEN, navItem, navSubItem } from "./chrome";
32
32
 
33
33
  // Per-APP test conversation: each app chats in its own conversation id, so testing
34
34
  // one client's app never inherits another's agent history/memory (the "BPP answers
35
- // about SAB" trap — same workflow, polluted shared context).
35
+ // about a bank" trap — same workflow, polluted shared context).
36
36
  // …and a fresh conversation per PAGE LOAD: the nonce is minted once per module load,
37
37
  // so refreshing the page naturally starts a NEW conversation (fresh agent thread,
38
38
  // fresh durable surfaces) — history never outlives the screen it happened on.
@@ -373,7 +373,7 @@ export function TemplatesView({
373
373
  const base = store.getTimeline().length === 0 ? "isEmpty" : "hasMessages";
374
374
  return states.find((s) => s.when === base)?.name ?? null;
375
375
  }, [storeVersion, store, selectedDef]);
376
- // The preview renders under the SELECTED APP'S ORG theme — a SAB app is SAB-red, a
376
+ // The preview renders under the SELECTED APP'S ORG theme — a a bank app is a bank-red, a
377
377
  // BPP app cobalt, whatever the header org scope shows. The header toggle picks the
378
378
  // light/dark variant; the app's org picks whose brand. Falls back to the app-level
379
379
  // theme until the fetch resolves (or for org-less templates).