@synap-core/workspace-templates 0.23.0 → 0.24.1

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 (53) hide show
  1. package/README.md +69 -59
  2. package/automations/bookmark-enrichment.catalog.json +16 -0
  3. package/automations/github-pr-events.catalog.json +18 -0
  4. package/automations/ship-a-change-checks-green.catalog.json +18 -0
  5. package/automations/ship-a-change-checks-red.catalog.json +18 -0
  6. package/automations/ship-a-change-deployed.catalog.json +18 -0
  7. package/automations/ship-a-change-pr-merged.catalog.json +18 -0
  8. package/automations/ship-a-change-pr-opened.catalog.json +18 -0
  9. package/automations/url-bookmark-capture.catalog.json +17 -0
  10. package/capabilities/stripe.capability.json +1163 -87
  11. package/capabilities/unipile-linkedin.capability.json +30 -7
  12. package/dist/{define-CPS0n8BV.d.ts → define-DqoItRiR.d.ts} +40 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +159 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/wire.d.ts +1 -1
  17. package/package.json +3 -3
  18. package/skills/app-factory-design-review/SKILL.md +8 -2
  19. package/skills/bookmark-enrichment.catalog.json +16 -0
  20. package/skills/design-critique/SKILL.md +3 -0
  21. package/skills/mapping-the-server.catalog.json +18 -0
  22. package/skills/routing-to-channels.catalog.json +16 -0
  23. package/src/catalog-safety.test.ts +1 -1
  24. package/src/content-os.template.test.ts +27 -0
  25. package/src/content-os.yaml +43 -0
  26. package/src/crm-messages-cadence.template.test.ts +83 -1
  27. package/src/crm.yaml +111 -4
  28. package/src/define.ts +16 -0
  29. package/src/reserved-profile-slugs.test.ts +8 -10
  30. package/src/retired-goal-kinds.test.ts +6 -12
  31. package/src/stripe-capability.test.ts +192 -0
  32. package/src/template-documents.test.ts +158 -0
  33. package/src/templates.ts +92 -4
  34. package/src/types.ts +32 -1
  35. package/src/unipile-linkedin-find-profile.test.ts +183 -0
  36. package/src/validate.ts +81 -0
  37. package/views/bookmark-views-pack.view.json +39 -0
  38. package/views/event-views-pack.view.json +45 -0
  39. package/views/task-views-pack.view.json +53 -0
  40. package/packs/bookmark-views-pack.json +0 -33
  41. package/packs/crm-pack.json +0 -76
  42. package/packs/customer-profile.json +0 -28
  43. package/packs/deep-research.playbook.json +0 -30
  44. package/packs/event-views-pack.json +0 -33
  45. package/packs/habit-profile.json +0 -27
  46. package/packs/hr-pack.json +0 -54
  47. package/packs/invoice-profile.json +0 -28
  48. package/packs/knowledge-base-pack.json +0 -56
  49. package/packs/meeting-profile.json +0 -29
  50. package/packs/product-profile.json +0 -30
  51. package/packs/project-management-pack.json +0 -49
  52. package/packs/recipe-profile.json +0 -29
  53. package/packs/task-views-pack.json +0 -39
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The Stripe capability (session 92e83544, "Stripe capability: read-write,
3
+ * governed"): reads run, every write is reviewed, and the call can only ever
4
+ * reach https://api.stripe.com/v1/.
5
+ *
6
+ * Pinned here, where the verbs are authored:
7
+ * - a GET verb declares `metadata.readOnly: true` (the gate runs it at once);
8
+ * a verb that writes (POST, DELETE) never does, so it stays on the propose
9
+ * grant the pod seeds for every capability tool (`GRANT_DEFAULT_EXEC_MODE`)
10
+ * and its provider call re-checks the tool gate (`isReadMethod`);
11
+ * - a write verb declares no read intent (list_records / fetch_record), and
12
+ * its name or description carries a whole-word write signal, so the pod's
13
+ * `deriveVerbKind` files it as an `action`, never a `read`;
14
+ * - the tool's host is fixed (`baseUrl`), its bodies are form-encoded
15
+ * (`config.bodyEncoding: "form"`, Stripe v1 takes no JSON body);
16
+ * - every path is under `/v1/`; the generic verbs take their resource path
17
+ * through `encode: "path"` (the pod refuses "." / ".." / empty segments);
18
+ * - every Stripe-native field travels through `bodyFrom` (`fields` on a typed
19
+ * verb, `params` on a generic one), never a `body` template that would send
20
+ * an absent optional as an empty value (which Stripe reads as "unset");
21
+ * - the eight read verbs live pods already run are all still there.
22
+ *
23
+ * NOT covered: Stripe accepting the calls (checked against the API reference,
24
+ * docs.stripe.com/api, not the live account; no write is ever sent to it), and
25
+ * the pod's encoder and gate (backend: form-encode.test.ts,
26
+ * execute-provider-verb.body-from.test.ts, form-body.vault.test.ts).
27
+ */
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { dirname, join, resolve } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { describe, expect, it } from "vitest";
32
+
33
+ const manifest = JSON.parse(
34
+ readFileSync(join(__dirname, "..", "capabilities", "stripe.capability.json"), "utf8"),
35
+ );
36
+ type Verb = {
37
+ name: string;
38
+ intent?: string;
39
+ kind: string;
40
+ description: string;
41
+ requires: string[];
42
+ parameters?: Record<string, string>;
43
+ metadata?: { readOnly?: boolean };
44
+ providerSpec: {
45
+ method: string;
46
+ pathTemplate: string;
47
+ body?: unknown;
48
+ bodyFrom?: string;
49
+ paramMapping?: Record<string, { encode?: string; required?: boolean }>;
50
+ };
51
+ };
52
+ const verbs: Verb[] = manifest.spec.skills;
53
+ const isRead = (v: Verb) => /^(GET|HEAD)$/i.test(v.providerSpec.method);
54
+ const reads = verbs.filter(isRead);
55
+ const writes = verbs.filter((v) => !isRead(v));
56
+
57
+ // A mirror of the pod's own word lists (synap-backend create-from-definition.ts
58
+ // VERB_WRITE_SIGNALS / VERB_READ_SIGNALS): name first, then description,
59
+ // whole words, write signals before read signals. Held equal to the pod's
60
+ // source by the "mirror" describe below.
61
+ const WRITE_SIGNALS = ["send", "invite", "post", "create", "write", "update", "delete", "reply", "message", "email", "publish", "comment", "add", "remove", "set", "run", "execute"];
62
+ const READ_SIGNALS = ["search", "list", "get", "fetch", "read", "find", "lookup", "query", "pull"];
63
+ const words = (t: string) => new Set(t.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
64
+ const classify = (t: string) => {
65
+ const w = words(t);
66
+ if (WRITE_SIGNALS.some((s) => w.has(s))) return "action";
67
+ if (READ_SIGNALS.some((s) => w.has(s))) return "read";
68
+ return null;
69
+ };
70
+ const derivedKind = (v: Verb) => classify(v.name) ?? classify(v.description) ?? "action";
71
+
72
+ describe("stripe capability: reads run, writes are reviewed", () => {
73
+ it("has reads and writes", () => {
74
+ expect(reads.length).toBeGreaterThan(10);
75
+ expect(writes.length).toBeGreaterThan(10);
76
+ });
77
+
78
+ it("every GET verb is read-only (runs at once)", () => {
79
+ expect(reads.filter((v) => v.metadata?.readOnly !== true).map((v) => v.name)).toEqual([]);
80
+ });
81
+
82
+ it("no write verb is read-only: each stays on the propose grant", () => {
83
+ expect(writes.filter((v) => v.metadata?.readOnly === true).map((v) => v.name)).toEqual([]);
84
+ });
85
+
86
+ it("a write declares no read intent and the pod files it as an action", () => {
87
+ expect(writes.filter((v) => v.intent === "list_records" || v.intent === "fetch_record").map((v) => v.name)).toEqual([]);
88
+ expect(writes.filter((v) => derivedKind(v) !== "action").map((v) => v.name)).toEqual([]);
89
+ expect(reads.filter((v) => derivedKind(v) !== "read").map((v) => v.name)).toEqual([]);
90
+ });
91
+
92
+ it("covers what the founder asked for: tax settings, tax registrations, payment links, checkout, refunds, payouts", () => {
93
+ const has = (method: string, path: string) =>
94
+ writes.some((v) => v.providerSpec.method === method && v.providerSpec.pathTemplate === path);
95
+ expect(has("POST", "/v1/tax/settings")).toBe(true);
96
+ expect(has("POST", "/v1/tax/registrations")).toBe(true);
97
+ expect(has("POST", "/v1/payment_links")).toBe(true);
98
+ expect(has("POST", "/v1/checkout/sessions")).toBe(true);
99
+ expect(has("POST", "/v1/refunds")).toBe(true);
100
+ expect(has("POST", "/v1/payouts")).toBe(true);
101
+ expect(has("DELETE", "/v1/subscriptions/{{subscriptionId}}")).toBe(true);
102
+ });
103
+
104
+ it("keeps the eight read verbs live pods already run", () => {
105
+ const names = new Set(reads.map((v) => v.name));
106
+ for (const n of ["stripe_list_customers", "stripe_get_customer", "stripe_list_charges", "stripe_list_invoices", "stripe_list_subscriptions", "stripe_list_products", "stripe_list_payouts", "stripe_get_balance"])
107
+ expect(names.has(n), n).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe("stripe capability: one host, form bodies, paths under /v1/", () => {
112
+ const tool = manifest.spec.tools.find((t: { name: string }) => t.name === "stripe");
113
+
114
+ it("the tool's host is fixed and its bodies are form-encoded", () => {
115
+ expect(tool.config.baseUrl).toBe("https://api.stripe.com");
116
+ expect(tool.config.bodyEncoding).toBe("form");
117
+ expect(tool.credentialRef).toBe("stripe");
118
+ expect(verbs.every((v) => v.requires.length === 1 && v.requires[0] === "stripe")).toBe(true);
119
+ });
120
+
121
+ it("every path starts with /v1/; every path parameter is encoded (uri for an id, path for a generic resource path)", () => {
122
+ for (const v of verbs) {
123
+ expect(v.providerSpec.pathTemplate.startsWith("/v1/"), v.name).toBe(true);
124
+ for (const [, name] of v.providerSpec.pathTemplate.matchAll(/\{\{(\w+)\}\}/g)) {
125
+ const m = v.providerSpec.paramMapping?.[name!];
126
+ expect(m?.required, `${v.name}.${name}`).toBe(true);
127
+ expect(["uri", "path"], `${v.name}.${name}`).toContain(m?.encode);
128
+ }
129
+ }
130
+ });
131
+
132
+ it("the generic verbs are one per method, each taking a /v1/-relative path", () => {
133
+ const generic = verbs.filter((v) => v.providerSpec.pathTemplate === "/v1/{{path}}");
134
+ expect(generic.map((v) => `${v.name} ${v.providerSpec.method}`).sort()).toEqual([
135
+ "stripe_api_delete DELETE",
136
+ "stripe_api_read GET",
137
+ "stripe_api_write POST",
138
+ ]);
139
+ for (const v of generic) {
140
+ expect(v.providerSpec.paramMapping?.path).toEqual({ required: true, encode: "path" });
141
+ expect(v.providerSpec.bodyFrom).toBe("params");
142
+ }
143
+ });
144
+
145
+ it("Stripe's own fields travel only through bodyFrom (no body template sending an absent optional as empty)", () => {
146
+ for (const v of verbs) expect(v.providerSpec.body, v.name).toBeUndefined();
147
+ for (const v of writes) {
148
+ expect(v.providerSpec.bodyFrom, v.name).toBeDefined();
149
+ expect(v.parameters?.[v.providerSpec.bodyFrom!], v.name).toMatch(/^object\??$/);
150
+ }
151
+ });
152
+
153
+ it("the connect copy names the restricted key and the permissions to grant", () => {
154
+ const param = manifest.spec.params.find((p: { name: string }) => p.name === "stripeApiKey");
155
+ expect(param.description).toMatch(/RESTRICTED/);
156
+ expect(param.description).toMatch(/Write only/);
157
+ expect(param.description).toMatch(/Tax Settings, Tax Registrations/);
158
+ expect(manifest.spec.description).toMatch(/ADAPTIVE PRICING/);
159
+ });
160
+ });
161
+
162
+ // ── The mirror: the word lists above equal the pod's ─────────────────────────
163
+ // Read from synap-backend's source (a sibling checkout), so a word the pod
164
+ // adds or drops turns this red instead of the classification above drifting
165
+ // unseen. Skips, with the reason logged, when synap-backend is not a sibling.
166
+ const POD_SOURCE = resolve(
167
+ dirname(fileURLToPath(import.meta.url)),
168
+ "../../../../synap-backend/packages/api/src/services/capabilities/create-from-definition.ts",
169
+ );
170
+ const hasPodSource = existsSync(POD_SOURCE);
171
+ if (!hasPodSource) console.warn(`[stripe signals mirror] SKIP: ${POD_SOURCE} is absent`);
172
+
173
+ /** The string literals of `const <name> = [ ... ]` in `src`, in order. */
174
+ function podList(src: string, name: string): string[] {
175
+ const m = new RegExp(`const ${name}\\s*=\\s*\\[([^\\]]*)\\]`).exec(src);
176
+ expect(m, `${name} is declared in create-from-definition.ts`).not.toBeNull();
177
+ return [...m![1]!.matchAll(/"([^"]+)"/g)].map((x) => x[1]!);
178
+ }
179
+
180
+ describe.skipIf(!hasPodSource)("stripe capability: the word lists mirror the pod's", () => {
181
+ const src = hasPodSource ? readFileSync(POD_SOURCE, "utf8") : "";
182
+ it("WRITE_SIGNALS equals VERB_WRITE_SIGNALS", () => {
183
+ const pod = podList(src, "VERB_WRITE_SIGNALS");
184
+ expect(pod.length).toBeGreaterThan(5);
185
+ expect(WRITE_SIGNALS).toEqual(pod);
186
+ });
187
+ it("READ_SIGNALS equals VERB_READ_SIGNALS", () => {
188
+ const pod = podList(src, "VERB_READ_SIGNALS");
189
+ expect(pod.length).toBeGreaterThan(3);
190
+ expect(READ_SIGNALS).toEqual(pod);
191
+ });
192
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Templates carry documents (`documents[]` + sidebar items
3
+ * `{kind: "document", documentSlug}`, plan 9ad4b2fc §5 lane 12). The pod
4
+ * installs them with the views (synap-backend `utils/template-documents.ts`);
5
+ * this file pins the authoring half:
6
+ *
7
+ * (1) the catalog schema accepts `documents[]` and refuses an unknown key in one;
8
+ * (2) both converters carry the documents verbatim, and a template WITHOUT
9
+ * documents compiles to a definition with no `documents` key (so every
10
+ * shipped template's body and content hash stay what they were);
11
+ * (3) `validateTemplate` refuses a bad slug, a duplicate slug, a missing
12
+ * title, an unknown scope, and a document sidebar item naming a document
13
+ * neither the template nor a base it COMPOSES onto declares (a `require`
14
+ * base keeps its own space, so its documents are not reachable);
15
+ * (4) no shipped template declares documents while the control plane's
16
+ * publish door cannot carry them (it strips `documents` and refuses a
17
+ * `kind: "document"` sidebar item): the set is derived from `src/*.yaml`.
18
+ */
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { describe, expect, it } from "vitest";
23
+ import { workspaceSpecSchema } from "../schema/workspace.ts";
24
+ import { toPackageDefinition, toWorkspaceDefinition } from "./define.ts";
25
+ import { readYamlTemplates } from "./generate.mjs";
26
+ import type { WorkspaceYaml } from "./types.ts";
27
+ import { validateTemplate } from "./validate.ts";
28
+
29
+ const base = (overrides: Partial<WorkspaceYaml> = {}): WorkspaceYaml =>
30
+ ({
31
+ meta: {
32
+ slug: "docs-demo",
33
+ name: "Docs demo",
34
+ description: "d",
35
+ icon: "x",
36
+ color: "violet",
37
+ },
38
+ workspace: { name: "Docs demo", description: "d" },
39
+ profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
40
+ ...overrides,
41
+ }) as WorkspaceYaml;
42
+
43
+ const DOCS = [
44
+ {
45
+ slug: "space-home",
46
+ title: "Space home",
47
+ content: 'Welcome.\n\n::synap-cell{cellKey="lens-section"}',
48
+ scope: "space" as const,
49
+ },
50
+ { slug: "notes", title: "Notes", content: "" },
51
+ ];
52
+
53
+ const withDocs = base({
54
+ documents: DOCS,
55
+ layout: {
56
+ sidebarItems: [{ kind: "document", documentSlug: "space-home", label: "Home" }],
57
+ },
58
+ });
59
+
60
+ const rules = (tpl: WorkspaceYaml, opts = {}) =>
61
+ validateTemplate(tpl, opts).errors.map((e) => e.rule);
62
+
63
+ describe("templates carry documents", () => {
64
+ it("(1) the catalog schema accepts documents[] and refuses an unknown key in one", () => {
65
+ const { meta: _meta, ...spec } = withDocs;
66
+ expect(workspaceSpecSchema.safeParse(spec).success).toBe(true);
67
+ const bad = { ...spec, documents: [{ ...DOCS[1], body: "typo" }] };
68
+ expect(workspaceSpecSchema.safeParse(bad).success).toBe(false);
69
+ });
70
+
71
+ it("(2) both converters carry the documents; a template without any emits no key", () => {
72
+ expect(toPackageDefinition(withDocs).documents).toEqual(DOCS);
73
+ expect(toWorkspaceDefinition(withDocs).definition.documents).toEqual(DOCS);
74
+ expect(toPackageDefinition(withDocs).layoutConfig?.sidebarItems).toEqual([
75
+ { kind: "document", documentSlug: "space-home", label: "Home" },
76
+ ]);
77
+ expect("documents" in toPackageDefinition(base())).toBe(false);
78
+ expect("documents" in toWorkspaceDefinition(base()).definition).toBe(false);
79
+ });
80
+
81
+ it("(3a) a clean template with documents validates", () => {
82
+ const r = validateTemplate(withDocs, {});
83
+ expect(r, JSON.stringify(r.errors)).toEqual({ ok: true, errors: [] });
84
+ });
85
+
86
+ it("(3b) each document rule fires", () => {
87
+ expect(rules(base({ documents: [{ ...DOCS[1], slug: "Not Kebab" }] }))).toContain(
88
+ "document-slug-invalid"
89
+ );
90
+ expect(rules(base({ documents: [DOCS[1], { ...DOCS[1], title: "Again" }] }))).toContain(
91
+ "document-slug-duplicate"
92
+ );
93
+ expect(rules(base({ documents: [{ ...DOCS[1], title: " " }] }))).toContain(
94
+ "document-title-required"
95
+ );
96
+ expect(
97
+ rules(base({ documents: [{ ...DOCS[1], scope: "project" as never }] }))
98
+ ).toContain("document-scope-unknown");
99
+ expect(
100
+ rules(
101
+ base({
102
+ documents: DOCS,
103
+ layout: { sidebarItems: [{ kind: "document", documentSlug: "missing" }] },
104
+ })
105
+ )
106
+ ).toContain("sidebar-document-unknown");
107
+ expect(
108
+ rules(base({ layout: { sidebarItems: [{ kind: "document" }] } }))
109
+ ).toContain("sidebar-document-unknown");
110
+ });
111
+
112
+ it("(3c) an overlay may name a document of a base it composes onto, not of one it requires", () => {
113
+ const baseTpl = base({ documents: DOCS });
114
+ const overlay = (relation: "compose" | "require") =>
115
+ base({
116
+ meta: { ...base().meta, slug: "docs-overlay" },
117
+ dependencies: [{ slug: "docs-demo", relation }],
118
+ layout: { sidebarItems: [{ kind: "document", documentSlug: "notes" }] },
119
+ });
120
+ const resolveDep = (slug: string) => (slug === "docs-demo" ? baseTpl : undefined);
121
+ expect(rules(overlay("compose"), { resolveDep })).not.toContain(
122
+ "sidebar-document-unknown"
123
+ );
124
+ expect(rules(overlay("require"), { resolveDep })).toContain(
125
+ "sidebar-document-unknown"
126
+ );
127
+ });
128
+
129
+ it("(4) no shipped template declares documents while the CP publish door cannot carry them", () => {
130
+ const yamls = readYamlTemplates() as Array<{ file: string; data: WorkspaceYaml }>;
131
+ expect(yamls.length).toBeGreaterThanOrEqual(25);
132
+ const declaring = yamls
133
+ .filter(
134
+ ({ data }) =>
135
+ (data.documents?.length ?? 0) > 0 ||
136
+ (data.layout?.sidebarItems ?? []).some((i) => i.kind === "document")
137
+ )
138
+ .map(({ file }) => file);
139
+ if (declaring.length === 0) return;
140
+ // A template uses them: the CP's packageDefinitionSchema must slot both.
141
+ const here = dirname(fileURLToPath(import.meta.url));
142
+ const cpPackages = join(here, "../../../../synap-control-plane-api/src/routes/packages.ts");
143
+ expect(existsSync(cpPackages), `${declaring.join(", ")} declare documents; ${cpPackages} is needed to check the CP carries them`).toBe(true);
144
+ const src = readFileSync(cpPackages, "utf8");
145
+ const start = src.indexOf("export const packageDefinitionSchema = z.object(");
146
+ expect(start).toBeGreaterThan(-1);
147
+ const body = src.slice(start, src.indexOf("\n});", start));
148
+ expect(body, `${declaring.join(", ")} declare documents, which the CP publish door strips`).toMatch(/\n documents:/);
149
+ // The sidebar ITEM's kind enum is the one listing "profile" (the nested
150
+ // `surface` enum already lists "document"; it is not the item's kind).
151
+ const itemKinds = [...body.matchAll(/kind: z\.enum\(\[([^\]]*)\]\)/g)]
152
+ .map((m) => m[1]!)
153
+ .filter((list) => list.includes('"profile"'));
154
+ expect(itemKinds.length, "the CP sidebar item kind enum was not found").toBeGreaterThan(0);
155
+ for (const list of itemKinds)
156
+ expect(list, `${declaring.join(", ")} name a document sidebar item, which the CP publish door refuses`).toContain('"document"');
157
+ });
158
+ });
package/src/templates.ts CHANGED
@@ -11071,6 +11071,31 @@ const DATA = {
11071
11071
  "executor": "is-agent",
11072
11072
  "status": "active"
11073
11073
  },
11074
+ {
11075
+ "name": "Write the caption",
11076
+ "description": "Draft the caption of one post from its brief and its script, as a draft in its script document that the person edits and saves.",
11077
+ "goalTemplate": "Draft the caption of this post ({post}). Read the post: its hook (post-hook), its call to action (post-cta), its pillar, its journey stage, its platform (post-platform) and its format. Read its script, the post's own document: its slides (the S1, S2 lines or the slide table) and any caption already written under \"## Caption\". Write ONE caption in the brand voice (the pod's brand kit) that says what the slides leave out: a first line that stands on its own before the platform cuts it (Instagram shows about 125 characters before \"more\", LinkedIn about 210 before \"see more\"), then two or three short lines, and the call to action last. No hashtag wall, no emoji run, no claim the script does not make, and no line that starts with \"##\". Put it in the script document as its \"## Caption draft\" section: replace that section if it is there, else add it at the end, and change no other line (synap_update_document with the revision you read). Never write or change the \"## Caption\" section on your own: it is the person's. Then ask the person to approve your draft: ONE confirm ask on this session (owner the person), its prompt the caption itself, \"Use this caption\" recommended. On yes, put the draft in the \"## Caption\" section (replace its words, or add it) and remove the \"## Caption draft\" section, nothing else. On no, leave the draft where it is (they may edit it and save it on the post's page in Content Studio, which also makes it the caption). Never move the post's status. Close this session once the person answered.",
11078
+ "subjectProfile": {
11079
+ "profileSlug": "post"
11080
+ },
11081
+ "expectedOutputs": [
11082
+ {
11083
+ "kind": "document",
11084
+ "label": "Caption draft",
11085
+ "relationToSubject": "none"
11086
+ }
11087
+ ],
11088
+ "params": [
11089
+ {
11090
+ "name": "post",
11091
+ "type": "entity",
11092
+ "label": "Post",
11093
+ "required": true
11094
+ }
11095
+ ],
11096
+ "executor": "is-agent",
11097
+ "status": "active"
11098
+ },
11074
11099
  {
11075
11100
  "name": "Repurpose",
11076
11101
  "description": "Turn a published post into new posts for other formats and platforms, each linked to the original.",
@@ -13525,7 +13550,7 @@ const DATA = {
13525
13550
  {
13526
13551
  "name": "Draft a first message",
13527
13552
  "description": "Draft one personal first message for a lead, citing its buying signal or a past exchange, in the person's language, as a proposed message in their channel for a person to approve; the agent never sends.",
13528
- "goalTemplate": "Draft the first message to the lead {lead} on {channel}. READ: the lead's score fields (icpScore, buyingSignal, buyingSignalSource, qualificationNotes) as they are, never rescoring them; its channels, past messages and notes in the pod; and the outreach campaign given ({campaign}), if any: its value proposition and call to action are the angle. WHO: a person lead is written to directly; for a company lead, write to the person who decides there (from the pod, else its public decider), and when no decider is known ask the person who to write to. DRAFT: one first message on channel {channel}, in the campaign's angle when one is given. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. Its message is personal: it cites the buying signal and what it showed, or a past exchange, never a generic template; short; in the person's language, French by default, and in French it says vous. It offers one useful next step, never pressure. Draft one message only: a person reviews it in the channel, edits it if needed and approves it. NEXT ACTION: set the lead's nextAction to \"Send the first message to <name> on LinkedIn\" (\"Send the first message to <name> by email\" on email), naming the person written to, and its nextActionAt to today. Never move the lead's stage while drafting. SENT: once the message is sent, follow the cadence, so \"Follow up on leads in outreach\" drafts the next one. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction \"Follow up with <name> if no reply\" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction \"Follow up with <name> a last time if no reply\" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction \"No reply after two follow-ups: nurture or drop <name>\" and nextActionAt the sent day + 7 days, and when that date passes with no reply, \"Follow up on leads in outreach\" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and \"Follow up on leads in outreach\" reads it and proposes the next step.",
13553
+ "goalTemplate": "Draft the first message to the lead {lead} on {channel}. READ: the lead's score fields (icpScore, buyingSignal, buyingSignalSource, qualificationNotes) as they are, never rescoring them; its channels, past messages and notes in the pod; and the outreach campaign given ({campaign}), if any: its value proposition and call to action are the angle. WHO: a person lead is written to directly; for a company lead, write to the person who decides there (from the pod, else its public decider), and when no decider is known ask the person who to write to. DRAFT: one first message on channel {channel}, in the campaign's angle when one is given. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. A LinkedIn message is drafted whatever their reach: the pod holds a draft to someone who is not a connection yet, shows it with the next step and lets it go out once they connect, so never hold a draft back for want of a connection. Draft it, then, in this order: (1) a person with no linkedinUrl: find their profile with linkedin_find_profile (their full name and company); on a match, propose its linkedinUrl and linkedinProviderId on the person for a person to approve; on none or several, ask the person to choose among its candidates (the process \"Find their LinkedIn\" does this for one lead). (2) Read their linkedinConnection, which only the pod writes (never set it or linkedinInvitedAt): connected, nothing more; none or not set, ask to connect with linkedin_send_invite (it refuses someone already connected or already invited: then nothing more); pending, the draft waits for them; ignored, draft by email instead when they have an email address. While the draft waits for them to connect, set the lead's nextAction to \"Wait for <name> to accept the LinkedIn request\" and its nextActionAt 7 days later. An email message needs none of this. Its message is personal: it cites the buying signal and what it showed, or a past exchange, never a generic template; short; in the person's language, French by default, and in French it says vous. It offers one useful next step, never pressure. Draft one message only: a person reviews it in the channel, edits it if needed and approves it. NEXT ACTION: set the lead's nextAction to \"Send the first message to <name> on LinkedIn\" (\"Send the first message to <name> by email\" on email), naming the person written to, and its nextActionAt to today. Never move the lead's stage while drafting. SENT: once the message is sent, follow the cadence, so \"Follow up on leads in outreach\" drafts the next one. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction \"Follow up with <name> if no reply\" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction \"Follow up with <name> a last time if no reply\" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction \"No reply after two follow-ups: nurture or drop <name>\" and nextActionAt the sent day + 7 days, and when that date passes with no reply, \"Follow up on leads in outreach\" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and \"Follow up on leads in outreach\" reads it and proposes the next step.",
13529
13554
  "subjectProfile": {
13530
13555
  "profileSlug": "lead"
13531
13556
  },
@@ -13579,7 +13604,7 @@ const DATA = {
13579
13604
  "criteria": [
13580
13605
  {
13581
13606
  "key": "first-message-one-draft",
13582
- "statement": "One proposed message, filed through messaging.send to the person written to (for a company, its decider), waits for review in that person's channel, holds the message text, and follows the campaign's angle when one was given.",
13607
+ "statement": "One proposed message, filed through messaging.send to the person written to (for a company, its decider), waits for review in that person's channel, holds the message text, and follows the campaign's angle when one was given, whatever the person's LinkedIn reach; on LinkedIn to someone not yet a connection, the pod holds it, and their profile is found and the connection asked for or awaited, in the DRAFT RULES order.",
13583
13608
  "check": {
13584
13609
  "kind": "judge"
13585
13610
  }
@@ -13628,10 +13653,73 @@ const DATA = {
13628
13653
  }
13629
13654
  ]
13630
13655
  },
13656
+ {
13657
+ "name": "Find their LinkedIn",
13658
+ "description": "Find one lead's LinkedIn profile by their full name and company and propose its URL and member id on the person for a person to approve, or offer up to 3 candidates to choose from; nothing is sent.",
13659
+ "goalTemplate": "Find the LinkedIn profile of the lead {lead}. WHO: a person lead is looked up themselves; for a company lead, the person who decides there, from the pod; when no decider is known, ask the person who it is. When that person already has a linkedinUrl, say so and stop. FIND: run linkedin_find_profile with their full name, their company and the LinkedIn account unipile_list_accounts lists (when several are connected, ask the person which one reads). On a match, propose its linkedinUrl and linkedinProviderId on the person through the entity door, for a person to approve; never write them directly, and never set linkedinConnection or linkedinInvitedAt, which only the pod writes. On none, several or more results than it read, ask the person to choose among its candidates (at most 3, each with its name, headline and profile URL) or to give the URL themselves, then propose the one chosen the same way. Never send a message or a connection request, never draft one, and never move the lead's stage.",
13660
+ "subjectProfile": {
13661
+ "profileSlug": "lead"
13662
+ },
13663
+ "activators": [
13664
+ {
13665
+ "on": "manual",
13666
+ "mode": "propose"
13667
+ }
13668
+ ],
13669
+ "params": [
13670
+ {
13671
+ "name": "lead",
13672
+ "type": "entity",
13673
+ "label": "Lead",
13674
+ "required": true
13675
+ }
13676
+ ],
13677
+ "executor": "is-agent",
13678
+ "status": "active",
13679
+ "requiredIntents": [
13680
+ "enrich_entity"
13681
+ ],
13682
+ "stages": [
13683
+ {
13684
+ "key": "find",
13685
+ "name": "Find the profile",
13686
+ "category": "started",
13687
+ "goal": "Run linkedin_find_profile for the lead (a company lead's decider) with their full name and company; on a match propose linkedinUrl and linkedinProviderId on the person, otherwise ask the person to choose among up to 3 candidates or give the URL.",
13688
+ "criteria": [
13689
+ {
13690
+ "key": "find-linkedin-proposed",
13691
+ "statement": "The person's linkedinUrl and linkedinProviderId are proposed on them for a person to approve (a strict match, or the candidate the person chose), or the person was asked to choose among at most 3 candidates; nothing was written directly.",
13692
+ "check": {
13693
+ "kind": "judge"
13694
+ }
13695
+ },
13696
+ {
13697
+ "key": "find-linkedin-reader-only",
13698
+ "statement": "No message or connection request was sent or drafted, linkedinConnection and linkedinInvitedAt were not set, and the lead's stage was not moved.",
13699
+ "check": {
13700
+ "kind": "judge"
13701
+ }
13702
+ }
13703
+ ]
13704
+ },
13705
+ {
13706
+ "key": "confirm",
13707
+ "name": "Person confirms",
13708
+ "category": "completed",
13709
+ "goal": "The person approves the proposed profile, or picks a candidate or gives the URL, and the pod stores linkedinUrl and linkedinProviderId on the person; the lead's next step is then to ask to connect.",
13710
+ "gate": {
13711
+ "kind": "human"
13712
+ },
13713
+ "onFail": {
13714
+ "toStage": "find"
13715
+ }
13716
+ }
13717
+ ]
13718
+ },
13631
13719
  {
13632
13720
  "name": "Follow up on leads in outreach",
13633
13721
  "description": "Each weekday, read the channels of the leads in outreach; summarise a reply and propose the next step, or, when the follow-up is due with no reply, draft the next message in the angle of its outreach campaign as a proposed message for a person to approve; the agent never sends.",
13634
- "goalTemplate": "Follow up on the leads in outreach. For each, read its channels (synap_get_channel, mode person: for a company lead, the channels of the person who decides there) and its notes. A REPLY is an inbound message after our last outbound one in a channel. With a reply, draft nothing: summarise it for a person; when they show interest, propose the next step (prepare the free estimate, \"Prepare a pre-sale estimate\", or book a meeting) and propose moving the lead to engaged; when they decline, propose nurturing or disqualified with their reason (in disqualificationReason). Unless you propose dropping them, write the next step on the lead as nextAction, one imperative line, and nextActionAt, the date they named or today. Never open a deal and never answer the reply yourself; a person does. With no reply, and only when the lead's next action date is today or past and no proposed message to it is still waiting, draft the next message, to the lead (for a company, the person who decides there) and in the angle of the lead's outreach campaign: follow its value proposition and call to action, short and in the lead's language, offering the next useful step, never pressure. For a lead with no campaign, ask the person which one. Draft at most two follow-ups per lead: a lead already sent two follow-ups gets no draft, never a third follow-up; when its last date passes in silence, propose moving it to nurturing through the status door, with its expected status. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. For each draft, set the lead's `nextAction` to send it (e.g. \"Send the drafted message to Ana on LinkedIn\") and `nextActionAt` to today. The person reviews each proposed message in its channel, edits it if needed and approves it; the agent never sends. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction \"Follow up with <name> if no reply\" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction \"Follow up with <name> a last time if no reply\" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction \"No reply after two follow-ups: nurture or drop <name>\" and nextActionAt the sent day + 7 days, and when that date passes with no reply, \"Follow up on leads in outreach\" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and \"Follow up on leads in outreach\" reads it and proposes the next step.",
13722
+ "goalTemplate": "Follow up on the leads in outreach. For each, read its channels (synap_get_channel, mode person: for a company lead, the channels of the person who decides there) and its notes. A REPLY is an inbound message after our last outbound one in a channel. With a reply, draft nothing: summarise it for a person; when they show interest, propose the next step (prepare the free estimate, \"Prepare a pre-sale estimate\", or book a meeting) and propose moving the lead to engaged; when they decline, propose nurturing or disqualified with their reason (in disqualificationReason). Unless you propose dropping them, write the next step on the lead as nextAction, one imperative line, and nextActionAt, the date they named or today. Never open a deal and never answer the reply yourself; a person does. With no reply, and only when the lead's next action date is today or past and no proposed message to it is still waiting, draft the next message, to the lead (for a company, the person who decides there) and in the angle of the lead's outreach campaign: follow its value proposition and call to action, short and in the lead's language, offering the next useful step, never pressure. For a lead with no campaign, ask the person which one. Draft at most two follow-ups per lead: a lead already sent two follow-ups gets no draft, never a third follow-up; when its last date passes in silence, propose moving it to nurturing through the status door, with its expected status. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. A LinkedIn message is drafted whatever their reach: the pod holds a draft to someone who is not a connection yet, shows it with the next step and lets it go out once they connect, so never hold a draft back for want of a connection. Draft it, then, in this order: (1) a person with no linkedinUrl: find their profile with linkedin_find_profile (their full name and company); on a match, propose its linkedinUrl and linkedinProviderId on the person for a person to approve; on none or several, ask the person to choose among its candidates (the process \"Find their LinkedIn\" does this for one lead). (2) Read their linkedinConnection, which only the pod writes (never set it or linkedinInvitedAt): connected, nothing more; none or not set, ask to connect with linkedin_send_invite (it refuses someone already connected or already invited: then nothing more); pending, the draft waits for them; ignored, draft by email instead when they have an email address. While the draft waits for them to connect, set the lead's nextAction to \"Wait for <name> to accept the LinkedIn request\" and its nextActionAt 7 days later. An email message needs none of this. For each draft, set the lead's `nextAction` to send it (e.g. \"Send the drafted message to Ana on LinkedIn\") and `nextActionAt` to today. The person reviews each proposed message in its channel, edits it if needed and approves it; the agent never sends. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction \"Follow up with <name> if no reply\" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction \"Follow up with <name> a last time if no reply\" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction \"No reply after two follow-ups: nurture or drop <name>\" and nextActionAt the sent day + 7 days, and when that date passes with no reply, \"Follow up on leads in outreach\" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and \"Follow up on leads in outreach\" reads it and proposes the next step.",
13635
13723
  "subjectProfile": {
13636
13724
  "profileSlug": "lead",
13637
13725
  "statusProperty": "leadStage"
@@ -13718,7 +13806,7 @@ const DATA = {
13718
13806
  {
13719
13807
  "name": "Personalized Outreach",
13720
13808
  "description": "Run one outreach campaign — write the angle and its offer, pick the people, draft one personal first message each as a proposed message in their channel; a person approves the list, then approves each message, which the pod sends.",
13721
- "goalTemplate": "Run this outreach campaign ({campaign}) on {channel}; each stage moves its status. If its type is not outreach, stop and say so. PREPARE: write the target audience, the value proposition and the call to action. Pick the people to reach from the pod (and public sources when the pod has too few), resolving each one before creating anything, link each as a member of the campaign with the lead role (lead source cold-outreach when it has none), and for each draft one first message to that person in this campaign's angle, specific to them — their company, role and any past message — never a generic template. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. START: a person approves the audience, the offer and the list, then reviews each proposed message in its channel and approves it; nothing goes out before they approve it, and the agent never sends. Once a message is sent, follow the cadence, so \"Follow up on leads in outreach\" drafts the next one. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction \"Follow up with <name> if no reply\" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction \"Follow up with <name> a last time if no reply\" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction \"No reply after two follow-ups: nurture or drop <name>\" and nextActionAt the sent day + 7 days, and when that date passes with no reply, \"Follow up on leads in outreach\" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and \"Follow up on leads in outreach\" reads it and proposes the next step. FINISH: when every person written to has replied or their follow-ups ended in silence, record in the campaign notes how many messages were sent (the outbound messages in their channels), how many got a reply and how many led to a booked meeting. Pausing or cancelling is a person's call.",
13809
+ "goalTemplate": "Run this outreach campaign ({campaign}) on {channel}; each stage moves its status. If its type is not outreach, stop and say so. PREPARE: write the target audience, the value proposition and the call to action. Pick the people to reach from the pod (and public sources when the pod has too few), resolving each one before creating anything, link each as a member of the campaign with the lead role (lead source cold-outreach when it has none), and for each draft one first message to that person in this campaign's angle, specific to them — their company, role and any past message — never a generic template. DRAFT RULES: each draft is one proposed message in the channel of the person it is written to, filed through the pod's send door: run messaging.send with to = that person's entity id (for a company lead, its decider: when that person is not in the pod, create them through the entity door with only their own fields, such as name, role and LinkedIn URL, never the company's website, domain or email), text = the message, and provider linkedin or email. Read their channel first (synap_get_channel, mode person) for what was already said. The pod files it for a person to review in that channel; it goes out only once they approve it. Never call a tool's own send verb (Unipile, Gmail), never create an outreach touch, a note or a task holding the message, and never use synap_capture for it. Never send. A LinkedIn message is drafted whatever their reach: the pod holds a draft to someone who is not a connection yet, shows it with the next step and lets it go out once they connect, so never hold a draft back for want of a connection. Draft it, then, in this order: (1) a person with no linkedinUrl: find their profile with linkedin_find_profile (their full name and company); on a match, propose its linkedinUrl and linkedinProviderId on the person for a person to approve; on none or several, ask the person to choose among its candidates (the process \"Find their LinkedIn\" does this for one lead). (2) Read their linkedinConnection, which only the pod writes (never set it or linkedinInvitedAt): connected, nothing more; none or not set, ask to connect with linkedin_send_invite (it refuses someone already connected or already invited: then nothing more); pending, the draft waits for them; ignored, draft by email instead when they have an email address. While the draft waits for them to connect, set the lead's nextAction to \"Wait for <name> to accept the LinkedIn request\" and its nextActionAt 7 days later. An email message needs none of this. START: a person approves the audience, the offer and the list, then reviews each proposed message in its channel and approves it; nothing goes out before they approve it, and the agent never sends. Once a message is sent, follow the cadence, so \"Follow up on leads in outreach\" drafts the next one. CADENCE: when a message is sent (a person approved its proposed message, so it is an outbound message in the channel), set its lead's lastContactedAt to the sent day, count the messages sent to the lead (the outbound messages in its channels, read with synap_get_channel mode person: for a company lead, the channels of the person written to there), and set the lead's next action: after the first message, nextAction \"Follow up with <name> if no reply\" and nextActionAt the sent day + 3 days; after the first follow-up, nextAction \"Follow up with <name> a last time if no reply\" and nextActionAt the sent day + 7 days; after the second follow-up, nextAction \"No reply after two follow-ups: nurture or drop <name>\" and nextActionAt the sent day + 7 days, and when that date passes with no reply, \"Follow up on leads in outreach\" proposes nurturing. Never a third follow-up. Propose moving a lead at new or researching to outreach through the status door, with its expected status. A reply stops the cadence: a reply is an inbound message after our last outbound one in the channel, and \"Follow up on leads in outreach\" reads it and proposes the next step. FINISH: when every person written to has replied or their follow-ups ended in silence, record in the campaign notes how many messages were sent (the outbound messages in their channels), how many got a reply and how many led to a booked meeting. Pausing or cancelling is a person's call.",
13722
13810
  "subjectProfile": {
13723
13811
  "profileSlug": "campaign",
13724
13812
  "statusProperty": "campaign-status"
package/src/types.ts CHANGED
@@ -266,6 +266,26 @@ export interface TemplateView {
266
266
  groupBy?: string;
267
267
  }
268
268
 
269
+ /**
270
+ * A document a template carries. Mirrors `WorkspaceDocumentInput`
271
+ * (synap-backend `@synap-core/types/workspaces/definition`) VERBATIM; the pod
272
+ * installs it through `utils/template-documents.ts` (the one create door,
273
+ * system-authored, owned by the installer, identified in the space by `slug`).
274
+ */
275
+ export interface TemplateDocument {
276
+ /** Stable id inside the template, kebab-case. Never rename it: the space knows the document by it. */
277
+ slug: string;
278
+ /** The document's name (its one title). */
279
+ title: string;
280
+ /** Markdown body; may carry `synap-cell` / `synap-section` embeds. */
281
+ content: string;
282
+ /**
283
+ * What its embeds read (a living document's scope): `space` = the space it
284
+ * is installed in, `pod` = the whole pod. Absent = no scope.
285
+ */
286
+ scope?: "space" | "pod";
287
+ }
288
+
269
289
  export interface TemplateEntityLink {
270
290
  sourceProfileSlug: string;
271
291
  targetProfileSlug: string;
@@ -358,10 +378,12 @@ export interface TemplateSidebarSurface {
358
378
  }
359
379
 
360
380
  export interface TemplateSidebarItem {
361
- kind: "app" | "view" | "profile" | "external" | "cell";
381
+ kind: "app" | "view" | "profile" | "external" | "cell" | "document";
362
382
  appId?: string;
363
383
  viewName?: string;
364
384
  viewId?: string;
385
+ /** kind "document": the `documents[].slug` it opens (the pod stores its id beside it). */
386
+ documentSlug?: string;
365
387
  profileSlug?: string;
366
388
  url?: string;
367
389
  cellKey?: string;
@@ -1075,6 +1097,15 @@ export interface WorkspaceYaml {
1075
1097
  /** Curated views — only the ones that add value */
1076
1098
  views?: TemplateView[];
1077
1099
 
1100
+ /**
1101
+ * Documents the space carries (plan 9ad4b2fc §5 lane 12, P4: a pinned set of
1102
+ * documents hosts a space package). Installed by the pod with the views:
1103
+ * created once per `slug` in the space, never rewritten by an update. A
1104
+ * sidebar item `{kind: "document", documentSlug}` opens one. See
1105
+ * {@link TemplateDocument}.
1106
+ */
1107
+ documents?: TemplateDocument[];
1108
+
1078
1109
  /** Schema-level links between entity types */
1079
1110
  entityLinks?: TemplateEntityLink[];
1080
1111