@withpica/mcp-server 2.95.3 → 2.96.0

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 (206) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.intro.md +115 -0
  3. package/README.md +188 -204
  4. package/dist/lib/changelog.generated.d.ts +2 -2
  5. package/dist/lib/changelog.generated.d.ts.map +1 -1
  6. package/dist/lib/changelog.generated.js +2 -2
  7. package/dist/lib/changelog.generated.js.map +1 -1
  8. package/dist/prompts/creator-question-atlas.d.ts.map +1 -1
  9. package/dist/prompts/creator-question-atlas.js +11 -0
  10. package/dist/prompts/creator-question-atlas.js.map +1 -1
  11. package/dist/prompts/index.js +2 -2
  12. package/dist/resources/agent-guide.d.ts +50 -4
  13. package/dist/resources/agent-guide.d.ts.map +1 -1
  14. package/dist/resources/agent-guide.js +109 -18
  15. package/dist/resources/agent-guide.js.map +1 -1
  16. package/dist/resources/index.d.ts.map +1 -1
  17. package/dist/resources/index.js +81 -2
  18. package/dist/resources/index.js.map +1 -1
  19. package/dist/resources/llms-primer.d.ts +1 -1
  20. package/dist/resources/llms-primer.d.ts.map +1 -1
  21. package/dist/resources/llms-primer.js +1 -1
  22. package/dist/resources/required-schemas.generated.d.ts +3 -12
  23. package/dist/resources/required-schemas.generated.d.ts.map +1 -1
  24. package/dist/resources/required-schemas.generated.js +3 -18
  25. package/dist/resources/required-schemas.generated.js.map +1 -1
  26. package/dist/resources/required-schemas.source.d.ts.map +1 -1
  27. package/dist/resources/required-schemas.source.js +13 -15
  28. package/dist/resources/required-schemas.source.js.map +1 -1
  29. package/dist/server-instructions.d.ts +2 -2
  30. package/dist/server-instructions.d.ts.map +1 -1
  31. package/dist/server-instructions.js +3 -3
  32. package/dist/skills/skills.generated.d.ts.map +1 -1
  33. package/dist/skills/skills.generated.js +18 -9
  34. package/dist/skills/skills.generated.js.map +1 -1
  35. package/dist/tools/agent-identity.d.ts.map +1 -1
  36. package/dist/tools/agent-identity.js +2 -1
  37. package/dist/tools/agent-identity.js.map +1 -1
  38. package/dist/tools/agreement-types.d.ts.map +1 -1
  39. package/dist/tools/agreement-types.js +23 -11
  40. package/dist/tools/agreement-types.js.map +1 -1
  41. package/dist/tools/agreements.d.ts.map +1 -1
  42. package/dist/tools/agreements.js +17 -9
  43. package/dist/tools/agreements.js.map +1 -1
  44. package/dist/tools/analytics.d.ts.map +1 -1
  45. package/dist/tools/analytics.js +6 -3
  46. package/dist/tools/analytics.js.map +1 -1
  47. package/dist/tools/assets.d.ts.map +1 -1
  48. package/dist/tools/assets.js +20 -13
  49. package/dist/tools/assets.js.map +1 -1
  50. package/dist/tools/audio-files.d.ts.map +1 -1
  51. package/dist/tools/audio-files.js +5 -3
  52. package/dist/tools/audio-files.js.map +1 -1
  53. package/dist/tools/bulk.d.ts.map +1 -1
  54. package/dist/tools/bulk.js +15 -9
  55. package/dist/tools/bulk.js.map +1 -1
  56. package/dist/tools/collaborators.d.ts.map +1 -1
  57. package/dist/tools/collaborators.js +41 -16
  58. package/dist/tools/collaborators.js.map +1 -1
  59. package/dist/tools/comparisons.d.ts.map +1 -1
  60. package/dist/tools/comparisons.js +2 -1
  61. package/dist/tools/comparisons.js.map +1 -1
  62. package/dist/tools/credits.d.ts +4 -3
  63. package/dist/tools/credits.d.ts.map +1 -1
  64. package/dist/tools/credits.js +16 -88
  65. package/dist/tools/credits.js.map +1 -1
  66. package/dist/tools/custody.d.ts.map +1 -1
  67. package/dist/tools/custody.js +43 -10
  68. package/dist/tools/custody.js.map +1 -1
  69. package/dist/tools/dashboard.d.ts.map +1 -1
  70. package/dist/tools/dashboard.js +24 -20
  71. package/dist/tools/dashboard.js.map +1 -1
  72. package/dist/tools/deprecations.d.ts +14 -7
  73. package/dist/tools/deprecations.d.ts.map +1 -1
  74. package/dist/tools/deprecations.js +24 -17
  75. package/dist/tools/deprecations.js.map +1 -1
  76. package/dist/tools/directory.d.ts.map +1 -1
  77. package/dist/tools/directory.js +7 -4
  78. package/dist/tools/directory.js.map +1 -1
  79. package/dist/tools/discovery.d.ts +120 -11
  80. package/dist/tools/discovery.d.ts.map +1 -1
  81. package/dist/tools/discovery.js +414 -38
  82. package/dist/tools/discovery.js.map +1 -1
  83. package/dist/tools/disputes.d.ts.map +1 -1
  84. package/dist/tools/disputes.js +3 -0
  85. package/dist/tools/disputes.js.map +1 -1
  86. package/dist/tools/documents.d.ts.map +1 -1
  87. package/dist/tools/documents.js +9 -6
  88. package/dist/tools/documents.js.map +1 -1
  89. package/dist/tools/enrichment.d.ts.map +1 -1
  90. package/dist/tools/enrichment.js +92 -14
  91. package/dist/tools/enrichment.js.map +1 -1
  92. package/dist/tools/error-envelope.d.ts +44 -0
  93. package/dist/tools/error-envelope.d.ts.map +1 -0
  94. package/dist/tools/error-envelope.js +17 -0
  95. package/dist/tools/error-envelope.js.map +1 -0
  96. package/dist/tools/exports.d.ts.map +1 -1
  97. package/dist/tools/exports.js +24 -9
  98. package/dist/tools/exports.js.map +1 -1
  99. package/dist/tools/groups.d.ts.map +1 -1
  100. package/dist/tools/groups.js +1 -0
  101. package/dist/tools/groups.js.map +1 -1
  102. package/dist/tools/import-documents.d.ts.map +1 -1
  103. package/dist/tools/import-documents.js +3 -2
  104. package/dist/tools/import-documents.js.map +1 -1
  105. package/dist/tools/import.d.ts.map +1 -1
  106. package/dist/tools/import.js +71 -38
  107. package/dist/tools/import.js.map +1 -1
  108. package/dist/tools/index.d.ts +71 -4
  109. package/dist/tools/index.d.ts.map +1 -1
  110. package/dist/tools/index.js +160 -41
  111. package/dist/tools/index.js.map +1 -1
  112. package/dist/tools/licensing.d.ts.map +1 -1
  113. package/dist/tools/licensing.js +6 -2
  114. package/dist/tools/licensing.js.map +1 -1
  115. package/dist/tools/memory.d.ts.map +1 -1
  116. package/dist/tools/memory.js +8 -4
  117. package/dist/tools/memory.js.map +1 -1
  118. package/dist/tools/metadata.d.ts +7 -1
  119. package/dist/tools/metadata.d.ts.map +1 -1
  120. package/dist/tools/metadata.js +61 -12
  121. package/dist/tools/metadata.js.map +1 -1
  122. package/dist/tools/multimedia.d.ts.map +1 -1
  123. package/dist/tools/multimedia.js +7 -3
  124. package/dist/tools/multimedia.js.map +1 -1
  125. package/dist/tools/notes.d.ts.map +1 -1
  126. package/dist/tools/notes.js +9 -6
  127. package/dist/tools/notes.js.map +1 -1
  128. package/dist/tools/notifications.d.ts.map +1 -1
  129. package/dist/tools/notifications.js +16 -27
  130. package/dist/tools/notifications.js.map +1 -1
  131. package/dist/tools/people.d.ts.map +1 -1
  132. package/dist/tools/people.js +8 -5
  133. package/dist/tools/people.js.map +1 -1
  134. package/dist/tools/projects.d.ts.map +1 -1
  135. package/dist/tools/projects.js +16 -12
  136. package/dist/tools/projects.js.map +1 -1
  137. package/dist/tools/recording-custody.d.ts.map +1 -1
  138. package/dist/tools/recording-custody.js +9 -5
  139. package/dist/tools/recording-custody.js.map +1 -1
  140. package/dist/tools/recording-samples.d.ts.map +1 -1
  141. package/dist/tools/recording-samples.js +25 -6
  142. package/dist/tools/recording-samples.js.map +1 -1
  143. package/dist/tools/recordings.d.ts +10 -0
  144. package/dist/tools/recordings.d.ts.map +1 -1
  145. package/dist/tools/recordings.js +32 -19
  146. package/dist/tools/recordings.js.map +1 -1
  147. package/dist/tools/recovery-hints.d.ts.map +1 -1
  148. package/dist/tools/recovery-hints.js +58 -18
  149. package/dist/tools/recovery-hints.js.map +1 -1
  150. package/dist/tools/release-rich.js +5 -5
  151. package/dist/tools/release-rich.js.map +1 -1
  152. package/dist/tools/releases.d.ts.map +1 -1
  153. package/dist/tools/releases.js +21 -12
  154. package/dist/tools/releases.js.map +1 -1
  155. package/dist/tools/royalties.d.ts.map +1 -1
  156. package/dist/tools/royalties.js +12 -6
  157. package/dist/tools/royalties.js.map +1 -1
  158. package/dist/tools/send.d.ts.map +1 -1
  159. package/dist/tools/send.js +7 -4
  160. package/dist/tools/send.js.map +1 -1
  161. package/dist/tools/sessions.d.ts.map +1 -1
  162. package/dist/tools/sessions.js +8 -4
  163. package/dist/tools/sessions.js.map +1 -1
  164. package/dist/tools/settings.d.ts.map +1 -1
  165. package/dist/tools/settings.js +13 -6
  166. package/dist/tools/settings.js.map +1 -1
  167. package/dist/tools/share-links.d.ts.map +1 -1
  168. package/dist/tools/share-links.js +7 -3
  169. package/dist/tools/share-links.js.map +1 -1
  170. package/dist/tools/share-send.d.ts.map +1 -1
  171. package/dist/tools/share-send.js +15 -5
  172. package/dist/tools/share-send.js.map +1 -1
  173. package/dist/tools/shows.d.ts +31 -0
  174. package/dist/tools/shows.d.ts.map +1 -0
  175. package/dist/tools/shows.js +307 -0
  176. package/dist/tools/shows.js.map +1 -0
  177. package/dist/tools/skills.d.ts.map +1 -1
  178. package/dist/tools/skills.js +7 -0
  179. package/dist/tools/skills.js.map +1 -1
  180. package/dist/tools/split-sheets.d.ts.map +1 -1
  181. package/dist/tools/split-sheets.js +19 -12
  182. package/dist/tools/split-sheets.js.map +1 -1
  183. package/dist/tools/statements.js +2 -2
  184. package/dist/tools/statements.js.map +1 -1
  185. package/dist/tools/storage-config.d.ts.map +1 -1
  186. package/dist/tools/storage-config.js +5 -3
  187. package/dist/tools/storage-config.js.map +1 -1
  188. package/dist/tools/subscription.d.ts +162 -26
  189. package/dist/tools/subscription.d.ts.map +1 -1
  190. package/dist/tools/subscription.js +479 -241
  191. package/dist/tools/subscription.js.map +1 -1
  192. package/dist/tools/sync-placements.d.ts.map +1 -1
  193. package/dist/tools/sync-placements.js +10 -5
  194. package/dist/tools/sync-placements.js.map +1 -1
  195. package/dist/tools/telegram.d.ts.map +1 -1
  196. package/dist/tools/telegram.js +16 -24
  197. package/dist/tools/telegram.js.map +1 -1
  198. package/dist/tools/users.d.ts.map +1 -1
  199. package/dist/tools/users.js +12 -6
  200. package/dist/tools/users.js.map +1 -1
  201. package/dist/tools/works.d.ts.map +1 -1
  202. package/dist/tools/works.js +13 -10
  203. package/dist/tools/works.js.map +1 -1
  204. package/package.json +8 -4
  205. package/server.json +2 -2
  206. package/.claude/settings.local.json +0 -5
@@ -1,16 +1,20 @@
1
1
  // Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any -- ToolExecutor signature uses Record<string, any> */
3
3
  /**
4
- * Subscription Tools — ADR-210 Phase 2.
4
+ * Subscription + billing tools — ADR-210 Phase 2, extended by the MPP pay
5
+ * rail (spec 2026-08-24 WS-B).
5
6
  *
6
- * Two MCP tools that surface the explicit billing read + action layer
7
+ * Three MCP tools that surface the explicit billing read + action layer
7
8
  * complementing the ambient `_meta.session_state.billing_slice` shipped
8
9
  * in Phase 1:
9
10
  *
10
11
  * - pica_subscription_status (read; readOnlyHint=true)
11
- * Wraps `GET /api/admin/subscription`, flattens the response into
12
- * the 9-field shape ADR-210 § Phase 2 specifies, and composes a
13
- * templated `summary` line the agent uses verbatim.
12
+ * Wraps `GET /api/admin/subscription` and flattens it into the card
13
+ * below (`SubscriptionStatusFlat`), composing a templated `summary`
14
+ * line the agent uses verbatim. It is no longer the 9 fields ADR-210
15
+ * § Phase 2 specified: two trial fields came off on 2026-08-21, and
16
+ * WS-B added `pricing` + `held`, each present only when the route
17
+ * measured it. Read the interface, not a count.
14
18
  *
15
19
  * - pica_subscription_manage (action; readOnlyHint=false)
16
20
  * Single-verb dispatcher over the existing
@@ -21,10 +25,20 @@
21
25
  * advertises `capabilities.elicitation.url` per ADR-200 Phase 1
22
26
  * (capability-gated; `feedback_mcp_sdk_capability_gates_elicitation.md`).
23
27
  *
24
- * No new Stripe SDK calls — both routes are reused as-is.
28
+ * - pica_billing_pay (read; readOnlyHint=true)
29
+ * Wraps `POST /api/admin/billing/pay-link`. Mints a link for one
30
+ * offer — resident_month / unlock / settle — and never charges: an
31
+ * agent POSTs the link with a Machine Payments credential, or a
32
+ * person opens it and pays by card. The refusals the ROUTE itself
33
+ * composes — billing disabled, nothing held, offer moved — come back
34
+ * as structured results rather than thrown ApiErrors. A 4xx from the
35
+ * auth wrapper in front of it is quoted, never re-interpreted; a 4xx
36
+ * carrying nothing quotable, and everything from 500 up, is re-thrown
37
+ * (`payRefusal` below is the single place that decides which).
38
+ *
39
+ * No new Stripe SDK calls — every route here is reused as-is.
25
40
  */
26
- import { randomUUID } from "node:crypto";
27
- import { clientSupportsUrlElicitation } from "@withpica/mcp-utils";
41
+ import { ApiError, } from "@withpica/mcp-sdk";
28
42
  // ADR-205 D7: enterprise is sales-led, not bookable through Checkout.
29
43
  // Mirror the route-layer allowlist here for a better tool-side error
30
44
  // message before the request leaves the agent.
@@ -36,19 +50,78 @@ const VALID_PAID_TIERS = [
36
50
  ];
37
51
  const VALID_ACTIONS = ["subscribe", "upgrade", "manage", "cancel"];
38
52
  /**
39
- * Compose the flat 9-field response from the existing GET admin route's
40
- * shape. Mirrors `getOrgBillingSlice`'s "no meaningful cap" rule for
41
- * `capacity_pct` (0 whenever current_tier is null OR enterprise) and
42
- * carries through `overflow.recommendedTier` (already
43
- * `inferTierFromCount`-derived) per Stage 1 Q2.
53
+ * The offers `POST /api/admin/billing/pay-link` accepts. Checked here as well
54
+ * as at the route because an unknown offer is a mistake the agent can fix
55
+ * from the tool's own answer, without spending a round trip on a 400.
56
+ */
57
+ const VALID_PAY_OFFERS = [
58
+ "resident_month",
59
+ "unlock",
60
+ "settle",
61
+ ];
62
+ /**
63
+ * Where a refused mint sends the agent next, by the route's error code.
64
+ * Mirrored in `RECOVERY_HINTS.pica_billing_pay` (recovery-hints.ts), which is
65
+ * what `pica_tool_details` reads; this copy is what the refusal itself
66
+ * carries, so an agent that never opens tool details still gets the pointer.
67
+ */
68
+ const NEXT_TOOL_FOR_PAY_ERROR = {
69
+ nothing_held_for_entity: "pica_dashboard_briefing",
70
+ offer_no_longer_applies: "pica_subscription_status",
71
+ };
72
+ /**
73
+ * The pay-link route's own flag-off body, verbatim
74
+ * (`app/api/admin/billing/pay-link/route.ts`: `{ error: "billing not enabled" }`).
75
+ *
76
+ * Named rather than inlined because it is the ONE 403 that licenses a
77
+ * statement about money, and a reader needs to be able to grep both ends of
78
+ * that coupling. If the route ever reworded it, this comparison stops
79
+ * matching and every 403 falls through to the access-refusal branch below —
80
+ * which says less, but says nothing false.
81
+ */
82
+ const BILLING_DISABLED_ERROR = "billing not enabled";
83
+ function isRecord(value) {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value);
85
+ }
86
+ /** The first candidate that is a non-blank string. */
87
+ function firstString(...candidates) {
88
+ for (const candidate of candidates) {
89
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
90
+ return candidate;
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+ /** The stored billing state in the vocabulary ADR-276 actually uses. */
96
+ export function planFor(billingState) {
97
+ if (billingState === "active")
98
+ return "resident";
99
+ if (billingState === "hibernated")
100
+ return "paused";
101
+ return "free_runway";
102
+ }
103
+ /**
104
+ * Compose `SubscriptionStatusFlat` from the GET admin route's shape: eight
105
+ * fields that are always present (`billing_state`, `plan`, `current_tier`,
106
+ * `capacity_used`, `capacity_limit`, `capacity_pct`, `recommended_tier`,
107
+ * `summary`) plus `pricing` and `held`, each emitted ONLY when the route
108
+ * measured it.
109
+ *
110
+ * ⚠️ This opened by quoting a FIELD COUNT until 2026-08-26, and that count had
111
+ * been wrong since 08-21 — `trial_days_remaining` and `trial_ends_at` came off
112
+ * then, and WS-B added the two optional keys. A count is the one thing a
113
+ * docstring cannot keep true across a field change, and it rots silently
114
+ * because nothing compiles against it. Do not reintroduce one: name the fields
115
+ * (above) or point at the interface, which the compiler keeps honest.
116
+ *
117
+ * Mirrors `getOrgBillingSlice`'s "no meaningful cap" rule for `capacity_pct`
118
+ * (0 whenever current_tier is null OR enterprise) and carries through
119
+ * `overflow.recommendedTier` (already `inferTierFromCount`-derived) per
120
+ * Stage 1 Q2.
44
121
  *
45
122
  * Exported for unit tests.
46
123
  */
47
124
  export function flattenStatus(status) {
48
- const trialEndsAt = status.trialEndsAt;
49
- const trialDaysRemaining = status.billingState === "trial" && trialEndsAt
50
- ? Math.max(0, Math.ceil((new Date(trialEndsAt).getTime() - Date.now()) / 86_400_000))
51
- : null;
52
125
  const capacityUsed = status.overflow?.currentCount ?? 0;
53
126
  const capacityLimit = status.overflow?.capacity ?? null;
54
127
  const recommendedTier = status.overflow?.recommendedTier ?? null;
@@ -63,10 +136,14 @@ export function flattenStatus(status) {
63
136
  const capacityPct = hasMeaningfulCap
64
137
  ? Math.round((capacityUsed / capacityLimit) * 100)
65
138
  : 0;
139
+ // WS-B: both arrive `null` when billing enforcement is off, and are absent
140
+ // entirely against a deployment whose route predates them. Either way there
141
+ // is nothing to say, so neither key is emitted.
142
+ const pricing = status.pricing ?? undefined;
143
+ const held = status.held ?? undefined;
66
144
  return {
67
145
  billing_state: status.billingState,
68
- trial_days_remaining: trialDaysRemaining,
69
- trial_ends_at: trialEndsAt,
146
+ plan: planFor(status.billingState),
70
147
  current_tier: status.tier,
71
148
  capacity_used: capacityUsed,
72
149
  capacity_limit: capacityLimit,
@@ -74,57 +151,232 @@ export function flattenStatus(status) {
74
151
  recommended_tier: recommendedTier,
75
152
  summary: composeSummary({
76
153
  billing_state: status.billingState,
77
- trial_days_remaining: trialDaysRemaining,
78
- current_tier: status.tier,
79
154
  capacity_used: capacityUsed,
80
- capacity_limit: capacityLimit,
81
- recommended_tier: recommendedTier,
155
+ pricing,
156
+ held,
82
157
  }),
158
+ ...(pricing ? { pricing } : {}),
159
+ ...(held ? { held } : {}),
83
160
  };
84
161
  }
162
+ /** `n` with the noun pluralised — "1 song", "7 songs". */
163
+ function plural(n, noun) {
164
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
165
+ }
85
166
  /**
86
- * Server-side template per Stage 1 Q1 option A. Predictable wording,
87
- * less variance across connectors. Phase 2.5 may add agent
88
- * paraphrasing if telemetry shows demand.
167
+ * The price half of the summary, built ENTIRELY from the figures the route
168
+ * sent. No amount of money appears here on purpose: the money is worded once,
169
+ * by the pay-link route's offer labels (`formatMoney` over the org's own
170
+ * currency, whose minor-unit conventions differ from ICU's for four of the
171
+ * ten supported currencies). A second wording here would be a second home for
172
+ * the price — the defect FIX_LOG 2026-08-20 records as having had ten.
173
+ *
174
+ * Returns `null` when there is nothing measured to say, so the caller appends
175
+ * nothing rather than a sentence about absence.
176
+ *
177
+ * Exported for unit tests.
178
+ */
179
+ export function composePriceLine(pricing, held) {
180
+ const clauses = [];
181
+ if (pricing) {
182
+ clauses.push(`Deep processing is free for your first ` +
183
+ `${plural(pricing.freeRunwayDeepProcesses, "song")}, and a resident ` +
184
+ `month includes ${plural(pricing.residentIncludedDeepProcesses, "song")}.`);
185
+ }
186
+ // count === 0 is the common case and says nothing worth a sentence; saying
187
+ // "0 findings are held" invites an agent to offer a pay link nobody needs.
188
+ if (held && held.count > 0) {
189
+ clauses.push(`${plural(held.count, "finding")} across ` +
190
+ `${plural(held.entities, "work")} are held until one of the offers ` +
191
+ `is paid — ask for a pay link to see the price and how to pay it.`);
192
+ }
193
+ return clauses.length > 0 ? clauses.join(" ") : null;
194
+ }
195
+ /**
196
+ * The sentence an agent says out loud. Server-side template (Stage 1 Q1 option
197
+ * A) so wording does not vary across connectors.
198
+ *
199
+ * ⚠️ REWRITTEN 2026-08-21. Every branch was false under ADR-276:
200
+ *
201
+ * trial → "You're on a 9-day window of your trial. Today's catalogue
202
+ * fits the solo tier." — there is no trial and no tier ladder
203
+ * hibernated → "Your trial has ended and the catalogue is read-only.
204
+ * Subscribe to restore writes."
205
+ * — hibernation gates `pica_audio_analyze` and nothing else;
206
+ * the catalogue is NOT read-only, and there is nothing to
207
+ * subscribe to
208
+ * active → "Currently on pro. 412 of 4,000 slots used."
209
+ * — the capacity ladder was superseded
210
+ *
211
+ * 239 of 241 production organisations sit at `billing_state = 'trial'`, so the
212
+ * first branch was what an agent said to almost every user pica has. The screen
213
+ * had already been corrected; this had not, which is the recurring shape here —
214
+ * see `.claude/rules/canonical-model.md` on `WORK_NO_WRITER`.
215
+ *
216
+ * Capacity is deliberately not mentioned: no cap is enforced under ADR-276, and
217
+ * quoting a limit implies one. The work count is stated as a plain fact instead.
89
218
  *
90
219
  * Exported for unit tests.
91
220
  */
92
221
  export function composeSummary(fields) {
93
- const { billing_state, trial_days_remaining, current_tier, capacity_used, capacity_limit, recommended_tier, } = fields;
94
- const slotsClause = `${capacity_used} active work${capacity_used === 1 ? "" : "s"}`;
95
- if (billing_state === "trial") {
96
- let daysClause;
97
- if (trial_days_remaining === null) {
98
- daysClause = "your trial";
99
- }
100
- else if (trial_days_remaining === 0) {
101
- daysClause = "the final day of your trial";
102
- }
103
- else if (trial_days_remaining === 1) {
104
- daysClause = "1 day left of your trial";
105
- }
106
- else {
107
- daysClause = `a ${trial_days_remaining}-day window of your trial`;
222
+ const { billing_state, capacity_used } = fields;
223
+ const works = `${capacity_used} active work${capacity_used === 1 ? "" : "s"}`;
224
+ const priceLine = composePriceLine(fields.pricing, fields.held);
225
+ const withPrice = (base) => priceLine ? `${base} ${priceLine}` : base;
226
+ if (billing_state === "hibernated") {
227
+ return withPrice(`Deep-processing is paused on this catalogue because a cycle went unpaid. ` +
228
+ `Nothing has been deleted and you can still read and export everything ` +
229
+ `(${works} held). Becoming a resident again switches deep-processing back on.`);
230
+ }
231
+ if (billing_state === "active") {
232
+ return withPrice(`You're a resident — one flat fee per 30-day cycle, nothing auto-renews. ${works}.`);
233
+ }
234
+ // trial === the free runway. No clock, no card, no countdown.
235
+ return withPrice(`You're on the free runway: no card, no countdown, and you are not charged ` +
236
+ `for holding a catalogue. ${works} so far.`);
237
+ }
238
+ /**
239
+ * Turn a pay-link 4xx into the structured refusal an agent can act on, or
240
+ * `null` when the caller must keep propagating it — 5xx, timeouts, network
241
+ * failures, and any 403 whose body carries no message worth quoting.
242
+ *
243
+ * The 403 branch is the delicate one: exactly one 403 on this path is about
244
+ * money, and the rest are about access. See its comment.
245
+ *
246
+ * Exported for unit tests.
247
+ */
248
+ export function payRefusal(err, offer, entityId) {
249
+ if (!(err instanceof ApiError))
250
+ return null;
251
+ if (err.status !== 403 && err.status !== 400 && err.status !== 409) {
252
+ return null;
253
+ }
254
+ const body = parsePayErrorBody(err);
255
+ const code = typeof body.error === "string" ? body.error : undefined;
256
+ if (err.status === 403) {
257
+ // ⚠️ ONLY the route's own flag-off body may license a claim about money.
258
+ //
259
+ // This branch answered every 403 with "nothing is owed", and the route is
260
+ // not the only thing on this path that answers 403. It sits behind
261
+ // `withDualAuth`, whose scope denial is
262
+ // `{ error: { code: "INSUFFICIENT_SCOPE", message, remedy } }` — an
263
+ // OBJECT, so the string `code` above is undefined — and the
264
+ // team-permission guard answers `{ error: "<message>", code: "TEAM_..." }`.
265
+ // Both mean "this connection may not ask", which is not a statement about
266
+ // a bill, and the agent was relaying it as one.
267
+ if (code === BILLING_DISABLED_ERROR) {
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text",
272
+ text: "pica is not charging on this deployment, so there is no pay link " +
273
+ "to mint and nothing is owed. Everything the catalogue does is " +
274
+ "available; no offer applies.",
275
+ },
276
+ ],
277
+ structuredContent: {
278
+ kind: "pica.error.billing_not_enabled",
279
+ version: 1,
280
+ data: { offer, reason: BILLING_DISABLED_ERROR },
281
+ },
282
+ isError: true,
283
+ };
108
284
  }
109
- const tierHint = recommended_tier
110
- ? ` Today's catalogue fits the ${recommended_tier} tier.`
111
- : "";
112
- return `You're on ${daysClause}. ${slotsClause} so far.${tierHint}`;
285
+ // Any other 403 is an ACCESS refusal. Quote the refuser's own words and
286
+ // make no claim about what is or is not owed — the call never reached the
287
+ // billing engine, so nothing here knows.
288
+ const nested = isRecord(body.error) ? body.error : undefined;
289
+ const message = firstString(nested?.message, code);
290
+ // No quotable message means no honest account of WHY. Returning null hands
291
+ // the error back to the executor to re-throw, exactly as a 500 is handled:
292
+ // an opaque failure is better information than a confident wrong sentence.
293
+ if (!message)
294
+ return null;
295
+ const remedy = firstString(nested?.remedy, body.remedy);
296
+ return {
297
+ content: [
298
+ {
299
+ type: "text",
300
+ text: `pica refused to mint a pay link: ${message}. That is an access ` +
301
+ `refusal and says nothing about what is or is not owed.` +
302
+ (remedy ? ` ${remedy}` : ""),
303
+ },
304
+ ],
305
+ structuredContent: {
306
+ kind: "pica.error.forbidden",
307
+ version: 1,
308
+ data: {
309
+ offer,
310
+ ...(entityId ? { entity_id: entityId } : {}),
311
+ error: firstString(nested?.code, body.code) ?? "forbidden",
312
+ message,
313
+ ...(remedy ? { next_step: remedy } : {}),
314
+ retry_safe: false,
315
+ },
316
+ },
317
+ isError: true,
318
+ };
113
319
  }
114
- if (billing_state === "hibernated") {
115
- return `Your trial has ended and the catalogue is read-only. Subscribe to restore writes (${slotsClause} currently held).`;
320
+ const offers = Array.isArray(body.offers)
321
+ ? body.offers
322
+ : undefined;
323
+ let text;
324
+ if (code === "nothing_held_for_entity") {
325
+ text =
326
+ "nothing is held on that work, so there is nothing to unlock. Check " +
327
+ "what is actually waiting across the catalogue before offering to pay.";
116
328
  }
117
- // active
118
- if (current_tier === "enterprise") {
119
- return `On the enterprise plan (unbounded). ${slotsClause}.`;
329
+ else if (code === "offer_no_longer_applies") {
330
+ const list = offers?.map((o) => o.label).join("; ");
331
+ text = list
332
+ ? `that offer no longer applies. What is on the table now: ${list}.`
333
+ : "that offer no longer applies, and no other offer applies either.";
334
+ }
335
+ else {
336
+ text = `that pay link could not be minted: ${code ?? err.message}.`;
337
+ }
338
+ return {
339
+ content: [{ type: "text", text }],
340
+ structuredContent: {
341
+ kind: "pica.error.offer_unavailable",
342
+ version: 1,
343
+ data: {
344
+ offer,
345
+ ...(entityId ? { entity_id: entityId } : {}),
346
+ error: code ?? "pay_link_refused",
347
+ ...(offers ? { offers } : {}),
348
+ next_tool: (code ? NEXT_TOOL_FOR_PAY_ERROR[code] : undefined) ??
349
+ "pica_subscription_status",
350
+ retry_safe: false,
351
+ },
352
+ },
353
+ isError: true,
354
+ };
355
+ }
356
+ /**
357
+ * Pull the route's JSON body back out of an `ApiError`. The SDK folds the
358
+ * response text into the message (`"API request failed: 400 <body>"`, and
359
+ * `"Authentication failed: 403 <body>"` on the auth path) rather than
360
+ * carrying a structured body — the same extraction `duplicates.ts` and
361
+ * `integrity.ts` do. Returns `{}` when the body is absent or not JSON, so
362
+ * every caller reads a `code` of `undefined` rather than throwing twice.
363
+ *
364
+ * Exported for unit tests.
365
+ */
366
+ export function parsePayErrorBody(err) {
367
+ const match = err.message.match(/^(?:API request failed|Authentication failed):\s*\d{3}\s*([\s\S]*)$/);
368
+ const text = match?.[1]?.trim();
369
+ if (!text)
370
+ return {};
371
+ try {
372
+ const parsed = JSON.parse(text);
373
+ return parsed && typeof parsed === "object"
374
+ ? parsed
375
+ : {};
376
+ }
377
+ catch {
378
+ return {};
120
379
  }
121
- const limitClause = capacity_limit === null
122
- ? slotsClause
123
- : `${capacity_used} of ${capacity_limit} slots used`;
124
- const tierClause = current_tier
125
- ? `Currently on ${current_tier}.`
126
- : "Tier unclassified.";
127
- return `${tierClause} ${limitClause}.`;
128
380
  }
129
381
  export class SubscriptionTools {
130
382
  pica;
@@ -139,15 +391,16 @@ export class SubscriptionTools {
139
391
  scope: "admin",
140
392
  audiences: ["stdio"],
141
393
  tier: "read",
142
- description: "Use when the user asks: 'what's my plan?', 'what tier am I on?', 'show my subscription'. " +
143
- "Read the org's current billing posture. Use when the user asks " +
144
- "'what's my plan?', 'how many days left on the trial?', 'any " +
145
- "room left in my tier?', or before nudging them to upgrade. " +
146
- "Returns billing_state (trial / active / hibernated), trial " +
147
- "days remaining, current tier, capacity (used / limit / %), " +
148
- "the smallest tier the catalogue fits today, and a one-line " +
149
- "summary the agent can use verbatim. Complement to the ambient " +
150
- "_meta.session_state.billing_slice; use this tool when an " +
394
+ description: "Use when the user asks: 'what's my plan?', 'am I paying for this?', " +
395
+ "'what does pica cost me?'. Read the org's current billing posture. " +
396
+ "Returns `plan` free_runway (walked in, never charged, no clock), " +
397
+ "resident (paying one flat fee per 30-day cycle), or paused (a cycle " +
398
+ "lapsed, so deep-processing is off) alongside the stored " +
399
+ "billing_state, the active work count, and a one-line summary to use " +
400
+ "verbatim. There is NO trial and NO tier ladder: pica is free to walk " +
401
+ "in and residency is opted into one cycle at a time, so never tell a " +
402
+ "user their trial is ending or offer to upgrade a tier. Complement to " +
403
+ "the ambient _meta.session_state.billing_slice; use this tool when an " +
151
404
  "explicit answer is needed.",
152
405
  workflows: "infrastructure",
153
406
  vernacular_kind: "creator-entry",
@@ -171,15 +424,14 @@ export class SubscriptionTools {
171
424
  scope: "admin",
172
425
  audiences: ["stdio"],
173
426
  tier: "write",
174
- description: "Mint a Stripe-hosted URL for a billing action. " +
175
- "action='subscribe' or 'upgrade' opens Stripe Checkout for the " +
176
- "given tier (one of solo / indie / pro / established " +
177
- "enterprise is sales-led). action='manage' or 'cancel' opens " +
178
- "the Stripe Billing Portal where the customer can update " +
179
- "payment methods, change tier, or cancel. Returns the URL, " +
180
- "an ISO expires_at, and `surface` ('checkout' or 'portal'). " +
181
- "Subscribe / upgrade without `tier` returns an error referencing " +
182
- "pica_subscription_status so the agent can read recommended_tier first.",
427
+ description: "Retired every action refuses. pica no longer sells a subscription " +
428
+ "or a capacity tier, so there is nothing here to subscribe to, " +
429
+ "upgrade, or cancel. pica is free to walk in and use; residency is a " +
430
+ "single one-off charge for a 30-day cycle that the user confirms " +
431
+ "themselves, and nothing auto-renews, so there is no recurring " +
432
+ "mandate to manage. Direct the user to the pricing page or their " +
433
+ "billing settings instead. Kept callable only so an agent that " +
434
+ "invokes it receives an explanation rather than an unknown-tool error.",
183
435
  workflows: "infrastructure",
184
436
  vernacular_kind: "specialist",
185
437
  vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
@@ -195,8 +447,9 @@ export class SubscriptionTools {
195
447
  tier: {
196
448
  type: "string",
197
449
  enum: [...VALID_PAID_TIERS],
198
- description: "Required for subscribe / upgrade. Solo: 250 active works. " +
199
- "Indie: 1,000. Pro: 4,000. Established: 15,000.",
450
+ description: "Ignored. pica no longer sells capacity tiers; the enum is " +
451
+ "retained only so an existing caller's argument still " +
452
+ "validates rather than erroring on an unknown property.",
200
453
  },
201
454
  currency: {
202
455
  type: "string",
@@ -216,8 +469,121 @@ export class SubscriptionTools {
216
469
  },
217
470
  executor: this.manage.bind(this),
218
471
  },
472
+ {
473
+ definition: {
474
+ name: "pica_billing_pay",
475
+ // read:catalog, NOT write:finances — and deliberately so. Minting a
476
+ // link mints a capability, not a charge: nothing is billed until
477
+ // whoever holds the link pays it, so a member fetching a link for
478
+ // the owner to pay is the intended flow. The route gates the same
479
+ // way (app/api/admin/billing/pay-link/route.ts).
480
+ scope: "read:catalog",
481
+ audiences: ["stdio", "http"],
482
+ tier: "read",
483
+ description: "Use when the user asks: 'how do i pay pica?', 'what does pica cost?'. " +
484
+ "Also use when pica is holding findings, or a deep process was " +
485
+ "refused for runway. Mints a pay link for one offer: resident_month " +
486
+ "(one month of residency; shows every held finding within the " +
487
+ "allowance), unlock (one work's held findings, needs entity_id), or " +
488
+ "settle (a resident's tab). Never charges by itself: an agent POSTs " +
489
+ "the link with a Machine Payments credential (Link agent wallet or " +
490
+ "Tempo), or a person opens it in a browser and pays by card. Returns " +
491
+ "the offers with amounts derived from pica's constants and the " +
492
+ "current held count. Refuses when billing enforcement is off.",
493
+ workflows: "infrastructure",
494
+ vernacular_kind: "creator-entry",
495
+ inputSchema: {
496
+ type: "object",
497
+ properties: {
498
+ offer: {
499
+ type: "string",
500
+ enum: [...VALID_PAY_OFFERS],
501
+ description: "resident_month (a 30-day cycle), unlock (one work's held " +
502
+ "findings), or settle (what a resident already owes).",
503
+ },
504
+ entity_id: {
505
+ type: "string",
506
+ description: "The work to unlock (offer = unlock).",
507
+ },
508
+ },
509
+ required: ["offer"],
510
+ additionalProperties: false,
511
+ },
512
+ annotations: {
513
+ // readOnly is honest here: this mints a link and changes nothing
514
+ // an org can see. idempotentHint is false because each mint is a
515
+ // fresh token with a fresh expiry, not the same link returned twice.
516
+ readOnlyHint: true,
517
+ destructiveHint: false,
518
+ idempotentHint: false,
519
+ openWorldHint: false,
520
+ },
521
+ },
522
+ executor: this.pay.bind(this),
523
+ },
219
524
  ];
220
525
  }
526
+ /**
527
+ * `pica_billing_pay` — hand the caller a link, and never a charge.
528
+ *
529
+ * The refusals the ROUTE composes (the flag is off; nothing is held on that
530
+ * work; that offer no longer applies) become structured results rather than
531
+ * thrown ApiErrors — a throw reaches the agent as UNKNOWN_ERROR, which
532
+ * reads as "pica is broken" when the honest answer is "there is nothing to
533
+ * pay for". `payRefusal` decides; everything it declines (5xx, timeouts,
534
+ * network failures, and a 403 with nothing quotable in it) is re-thrown
535
+ * unchanged, because an outage dressed as a tidy refusal is the worse lie.
536
+ */
537
+ async pay(args) {
538
+ const offer = args.offer;
539
+ if (!offer || !VALID_PAY_OFFERS.includes(offer)) {
540
+ return {
541
+ content: [
542
+ {
543
+ type: "text",
544
+ text: `Error: offer must be one of ${VALID_PAY_OFFERS.join(", ")}.`,
545
+ },
546
+ ],
547
+ structuredContent: {
548
+ kind: "pica.error.invalid_argument",
549
+ version: 1,
550
+ data: { argument: "offer", valid: [...VALID_PAY_OFFERS] },
551
+ },
552
+ isError: true,
553
+ };
554
+ }
555
+ const entityId = typeof args.entity_id === "string" ? args.entity_id : undefined;
556
+ let link;
557
+ try {
558
+ link = await this.pica.billing.mintPayLink({
559
+ offer,
560
+ entity_id: entityId,
561
+ });
562
+ }
563
+ catch (err) {
564
+ const refusal = payRefusal(err, offer, entityId);
565
+ if (refusal)
566
+ return refusal;
567
+ throw err;
568
+ }
569
+ // The label is the ONE place the amount is worded — composed server-side
570
+ // in the org's own currency. Quote it; never re-format `amount_minor`.
571
+ const label = link.offers?.find((o) => o.offer === link.offer)?.label ??
572
+ link.offer;
573
+ return {
574
+ content: [
575
+ {
576
+ type: "text",
577
+ text: `${label}: ${link.pay_url} (expires ${link.expires_at}). ${link.how}`,
578
+ },
579
+ ],
580
+ structuredContent: {
581
+ kind: "pica.card.pay_link",
582
+ version: 1,
583
+ data: link,
584
+ },
585
+ };
586
+ }
221
587
  async status(_args) {
222
588
  const status = await this.pica.subscription.getStatus();
223
589
  if (!status) {
@@ -247,7 +613,7 @@ export class SubscriptionTools {
247
613
  },
248
614
  };
249
615
  }
250
- async manage(args, ctx) {
616
+ async manage(args) {
251
617
  const action = args.action;
252
618
  if (!action || !VALID_ACTIONS.includes(action)) {
253
619
  return {
@@ -268,175 +634,47 @@ export class SubscriptionTools {
268
634
  isError: true,
269
635
  };
270
636
  }
271
- const isCheckoutAction = action === "subscribe" || action === "upgrade";
272
- if (isCheckoutAction) {
273
- const tier = args.tier;
274
- if (!tier) {
275
- // Stage 1 Q3 / coordinator-pinned: return a recoverable error
276
- // with `next_tool` so the agent can chain to status first
277
- // rather than auto-pick a tier.
278
- return {
279
- content: [
280
- {
281
- type: "text",
282
- text: `subscribe and upgrade require a tier. Call ` +
283
- `pica_subscription_status to see the recommended tier ` +
284
- `for this org's current capacity, then retry with that tier.`,
285
- },
286
- ],
287
- structuredContent: {
288
- kind: "pica.error.missing_argument",
289
- version: 1,
290
- data: {
291
- argument: "tier",
292
- next_tool: "pica_subscription_status",
293
- },
294
- },
295
- isError: true,
296
- };
297
- }
298
- if (!VALID_PAID_TIERS.includes(tier)) {
299
- return {
300
- content: [
301
- {
302
- type: "text",
303
- text: `Error: tier must be one of ${VALID_PAID_TIERS.join(", ")}. ` +
304
- `Enterprise is sales-led — contact PICA directly.`,
305
- },
306
- ],
307
- structuredContent: {
308
- kind: "pica.error.invalid_argument",
309
- version: 1,
310
- data: {
311
- argument: "tier",
312
- valid: [...VALID_PAID_TIERS],
313
- },
314
- },
315
- isError: true,
316
- };
317
- }
318
- try {
319
- const result = await this.pica.subscription.mintCheckout({
320
- tier,
321
- currency: typeof args.currency === "string" ? args.currency : "gbp",
322
- });
323
- return this.buildLinkResult({
324
- url: result.checkoutUrl,
325
- expiresAt: result.expiresAt,
326
- surface: "checkout",
327
- name: `Subscribe to PICA (${tier})`,
328
- description: `Open Stripe Checkout to subscribe to the ${tier} tier.`,
329
- ctx,
330
- });
331
- }
332
- catch (err) {
333
- const message = err instanceof Error ? err.message : "checkout mint failed";
334
- return {
335
- content: [
336
- { type: "text", text: `Could not mint checkout: ${message}` },
337
- ],
338
- structuredContent: {
339
- kind: "pica.error.checkout_mint_failed",
340
- version: 1,
341
- data: { error: message },
342
- },
343
- isError: true,
344
- };
345
- }
346
- }
347
- // manage / cancel → portal
348
- try {
349
- const result = await this.pica.subscription.mintPortal();
350
- return this.buildLinkResult({
351
- url: result.portalUrl,
352
- expiresAt: result.expiresAt,
353
- surface: "portal",
354
- name: action === "cancel" ? "Cancel subscription" : "Manage subscription",
355
- description: action === "cancel"
356
- ? "Open the Stripe Billing Portal to cancel your subscription."
357
- : "Open the Stripe Billing Portal to update payment, change tier, or cancel.",
358
- ctx,
359
- });
360
- }
361
- catch (err) {
362
- const message = err instanceof Error ? err.message : "portal mint failed";
363
- return {
364
- content: [
365
- {
366
- type: "text",
367
- text: `Could not open billing portal: ${message}`,
368
- },
369
- ],
370
- structuredContent: {
371
- kind: "pica.error.portal_mint_failed",
372
- version: 1,
373
- data: {
374
- error: message,
375
- // The route returns "No Stripe customer found — subscribe first"
376
- // when the org has no stripeCustomerId; surface a recovery hint
377
- // so the agent can chain.
378
- next_tool: message.toLowerCase().includes("subscribe first") ||
379
- message.toLowerCase().includes("no stripe customer")
380
- ? "pica_subscription_manage"
381
- : undefined,
382
- },
383
- },
384
- isError: true,
385
- };
386
- }
387
- }
388
- /**
389
- * Universal-floor return per ADR-200 Phase 1: every successful manage
390
- * response carries a `resource_link` so any compliant MCP client can
391
- * render or open the URL. Clients that advertise
392
- * `capabilities.elicitation.url` additionally receive a server-issued
393
- * elicitation/create request — the gate is mandatory because
394
- * `elicitInput` with `mode: "url"` throws synchronously against
395
- * non-supporters (`feedback_mcp_sdk_capability_gates_elicitation.md`).
396
- */
397
- async buildLinkResult(opts) {
398
- const { url, expiresAt, surface, name, description, ctx } = opts;
399
- const summary = surface === "checkout"
400
- ? `${name} ready. Open ${url} to complete checkout. Link expires ${expiresAt}.`
401
- : `${name} ready. Open ${url} to manage your subscription. Link expires ${expiresAt}.`;
402
- const content = [
403
- { type: "text", text: summary },
404
- {
405
- type: "resource_link",
406
- uri: url,
407
- name,
408
- description,
409
- mimeType: "text/html",
410
- },
411
- ];
412
- // Capability-gated elicitation/create url — silently degrade on
413
- // decline / cancel / timeout; the resource_link in the response
414
- // remains usable.
415
- if (clientSupportsUrlElicitation(ctx?.server)) {
416
- try {
417
- await ctx.server.elicitInput({
418
- mode: "url",
419
- url,
420
- elicitationId: `pica-subscription-${surface}-${randomUUID()}`,
421
- message: description,
422
- }, { timeout: 60_000 });
423
- }
424
- catch {
425
- // User declined / cancelled / elicitation timed out. Not an error
426
- // — the resource_link below is the universal-floor fallback.
427
- }
428
- }
637
+ // ⚠️ THIS TOOL NO LONGER MINTS A CHECKOUT OR A PORTAL LINK.
638
+ //
639
+ // Until 2026-08-21 `action: "subscribe", tier: "indie"` minted a LIVE Stripe
640
+ // Checkout for a £49/month RECURRING subscription — from an agent, in
641
+ // production. The tier price IDs are configured on Production and the Stripe
642
+ // prices were active, so this was a working till for a product pica stopped
643
+ // selling when ADR-276 superseded ADR-205 on 2026-06-26.
644
+ //
645
+ // It also contradicted the Terms of Service, which state that pica does not
646
+ // store a recurring mandate and does not auto-renew. A live path capable of
647
+ // creating one made that sentence false.
648
+ //
649
+ // Nobody was ever charged through it (0 successful charges, 0 subscriptions
650
+ // of any status), which is why this is a refusal and not an incident.
651
+ //
652
+ // Residency is charged by the billing engine when the deep-process gate
653
+ // blocks — a one-off `mode: "payment"` Checkout the user confirms per cycle,
654
+ // priced from RESIDENT_FEE_MINOR_UNITS with inline `price_data`. There is no
655
+ // "subscribe on demand" endpoint to expose, and inventing one here would be
656
+ // the same mistake in the other direction.
657
+ const retiredMessage = "pica does not sell a subscription or a capacity tier — there is nothing " +
658
+ "to subscribe to, upgrade, or cancel. pica is free to walk in and use: " +
659
+ "holding a catalogue, registering works, browsing and searching cost " +
660
+ "nothing, and there is no trial clock. If the user has gone past the free " +
661
+ "deep-processing allowance, pica offers residency at that moment — one " +
662
+ "small flat fee for a 30-day cycle, priced for their region, which they " +
663
+ "confirm each time. Nothing auto-renews, so nothing needs cancelling. " +
664
+ "Point them at the pricing page or their billing settings.";
429
665
  return {
430
- content,
666
+ content: [{ type: "text", text: retiredMessage }],
431
667
  structuredContent: {
432
- kind: "pica.card.subscription_link",
668
+ kind: "pica.error.retired_action",
433
669
  version: 1,
434
670
  data: {
435
- url,
436
- expires_at: expiresAt,
437
- surface,
671
+ action,
672
+ reason: "subscriptions_and_tiers_retired",
673
+ pricing_url: "https://withpica.com/pricing",
674
+ settings_url: "https://withpica.com/settings?tab=subscription",
438
675
  },
439
676
  },
677
+ isError: true,
440
678
  };
441
679
  }
442
680
  }