howone 0.2.3 → 0.2.6

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 (23) hide show
  1. package/package.json +1 -1
  2. package/templates/vite/.howone/skills/howone/01-architect/01-app-generation.md +8 -7
  3. package/templates/vite/.howone/skills/howone/01-architect/02-manifest-codegen.md +121 -436
  4. package/templates/vite/.howone/skills/howone/03-ai-capabilities/04-workflow-operations.md +13 -4
  5. package/templates/vite/.howone/skills/howone/04-app-sdk/01-client-setup.md +94 -261
  6. package/templates/vite/.howone/skills/howone/04-app-sdk/02-entity-operations.md +85 -465
  7. package/templates/vite/.howone/skills/howone/04-app-sdk/03-auth.md +11 -7
  8. package/templates/vite/.howone/skills/howone/04-app-sdk/04-react-integration.md +84 -137
  9. package/templates/vite/.howone/skills/howone/04-app-sdk/05-file-upload.md +66 -273
  10. package/templates/vite/.howone/skills/howone/04-app-sdk/06-raw-http.md +72 -249
  11. package/templates/vite/.howone/skills/howone/04-app-sdk/07-ai-action-calls.md +135 -499
  12. package/templates/vite/.howone/skills/howone/04-app-sdk/08-ai-manifest-handoff.md +49 -196
  13. package/templates/vite/.howone/skills/howone/04-app-sdk/09-extension-boundaries.md +4 -4
  14. package/templates/vite/.howone/skills/howone/04-app-sdk/10-workflow-execute-sse.md +94 -61
  15. package/templates/vite/.howone/skills/howone/04-app-sdk/11-entity-data-access-patterns.md +4 -3
  16. package/templates/vite/.howone/skills/howone/SKILL.md +48 -8
  17. package/templates/vite/.howone/skills/howone/references/common-errors.md +27 -0
  18. package/templates/vite/.howone/skills/howone/references/version-evidence.md +47 -0
  19. package/templates/vite/.howone/skills/howone/scripts/verify-project.mjs +151 -0
  20. package/templates/vite/package.json +1 -1
  21. package/templates/vite/src/App.tsx +9 -5
  22. package/templates/vite/src/lib/sdk.ts +7 -5
  23. package/templates/vite/bun.lock +0 -1478
@@ -1,574 +1,210 @@
1
- # AI Actions
1
+ # AI Action Calls
2
2
 
3
- ## Manifest Contract Read This First
3
+ Use this track only after the AI capability has been applied, synced, submitted to EAX, and synced
4
+ again. `.howone/ai/manifest.json` is the source for action IDs, JSON schemas, and workflow config
5
+ UUIDs.
4
6
 
5
- **`src/lib/sdk.ts` must be generated from `.howone/ai/manifest.json`. Do not write it from memory or from generic examples.**
7
+ ## Binding order
6
8
 
7
- For AI capability and workflow design, read `03-ai-capabilities/` first. This file is only for app-side SDK
8
- bindings and runtime calls after the manifest exists.
9
-
10
- For every capability in `manifest.json`:
11
- 1. Read `name`, `workflowId`, `inputSchema`, `outputSchema`
12
- 2. Generate a zod schema from `inputSchema.properties`
13
- 3. Generate a zod schema from `outputSchema.properties` when an output schema exists
14
- 4. Call `defineAiAction(name, { workflowId, inputSchema, outputSchema })` — **`workflowId` is mandatory**
15
-
16
- Without `workflowId`, the SDK falls back to using the action name as the URL segment. Action names are not UUIDs — the EAX server will reject the call with "invalid input syntax for type uuid".
17
-
18
- Do not mark required manifest output fields as `.optional()` to silence validation. Do not add
19
- `.passthrough()` as a workaround for execution envelopes. A typed `run()` validates and returns the
20
- workflow `finalResult` payload, not the raw execution envelope.
21
-
22
- ## When to Write SDK Bindings
23
-
24
- **Do NOT write `defineAiAction` until `.howone/ai/manifest.json` contains the workflowId for the capability and the external workflow implementation has been submitted/confirmed by the workflow layer.**
25
-
26
- Correct sequence:
27
- 1. `ai-capability-design` — design the capability contract
28
- 2. `sync_ai_artifacts` — sync manifest to disk
29
- 3. `external-ai-capability` — submit workflow create/update to EAX from the synced manifest
30
- 4. Re-read `.howone/ai/manifest.json`; update may have rotated `workflowId`
31
- 5. Write `src/lib/sdk.ts` with the current manifest `workflowId`
32
-
33
- Building without errors does **not** mean the AI workflow binding is correct. A missing `workflowId` causes a runtime UUID error at the EAX execution call.
34
-
35
- ---
36
-
37
- ## Core Concepts
38
-
39
- - `defineAiAction(id, config)` declares a typed AI action from a workflow ID.
40
- - `defineAiActions({ ... })` groups multiple action definitions.
41
- - `withAiActions(client, actions)` binds them onto the composed client as `howone.ai.*`.
42
- - Each bound action exposes `.run()`, `.stream()`, and `.events()`.
43
- - Input/output are validated at runtime using zod schemas.
44
-
45
- ---
46
-
47
- ## Type System
48
-
49
- ### AiActionConfig
50
-
51
- ```ts
52
- type AiActionConfig<TInput, TOutput> = {
53
- workflowId: string // REQUIRED — UUID from manifest.json. Without this, SDK uses action name as URL segment (not a UUID → EAX rejects).
54
- inputSchema?: z.ZodType<TInput> // validates input before calling the workflow
55
- outputSchema?: z.ZodType<TOutput> // validates the workflow finalResult payload for run()
56
- mode?: 'run' | 'stream' | 'events' // default: supports all three modes
57
- }
58
- ```
59
-
60
- ### AiResult (ExecutionResult)
61
-
62
- ```ts
63
- type AiResult = {
64
- success: boolean
65
- runId?: string
66
- /** Terminal outcome of the run */
67
- outcome: 'success' | 'credit_insufficient' | 'run_error' | null
68
- finalResult: Record<string, unknown> | null // run_complete.message
69
- progressLogs: string[] // progress.message lines
70
- totalDuration: number
71
- errors: string[]
72
- events: AiEvent[]
73
- }
74
- ```
75
-
76
- ### AiSession (for stream)
77
-
78
- ```ts
79
- type AiSession = {
80
- result: Promise<AiResult> // resolves when the stream completes
81
- cancel: () => void // abort the request (safe to call multiple times)
82
- signal: AbortSignal
83
- }
84
- ```
85
-
86
- ### AiEvent (SSE events)
87
-
88
- All workflow execute SSE events use the current envelope shape:
89
-
90
- ```ts
91
- type AiEvent = {
92
- id: string
93
- type: 'run_start' | 'progress' | 'run_complete' | 'run_error' | 'credit_insufficient'
94
- event: AiEvent['type']
95
- message: string | Record<string, unknown>
96
- payload?: Record<string, unknown> // present for run_complete as a compatibility alias of message
97
- }
98
- ```
99
-
100
- Stream terminates after exactly one of: `run_complete`, `credit_insufficient`, or `run_error`.
101
- For the full wire protocol, read `04-app-sdk/10-workflow-execute-sse.md`.
102
-
103
- ---
104
-
105
- ## Defining AI Actions
106
-
107
- ### Basic action — always include workflowId from manifest.json
9
+ 1. Read the current `.howone/ai/manifest.json`.
10
+ 2. Convert its input/output JSON Schemas to Zod without weakening required fields.
11
+ 3. Copy the exact manifest `workflowId` UUID.
12
+ 4. Define and compose the action in `src/lib/sdk.ts`.
13
+ 5. Run the project verifier, typecheck, and build.
108
14
 
109
15
  ```ts
110
16
  import { defineAiAction, defineAiActions } from '@howone/sdk'
111
17
  import { z } from 'zod'
112
18
 
113
- // Source: .howone/ai/manifest.json capabilities[0].inputSchema.properties
114
- export const generateStoryInputSchema = z.object({
115
- topic: z.string().min(1),
116
- ageRange: z.enum(['3-5', '6-8', '9-12']),
117
- language: z.string().default('en'),
118
- })
119
- export type GenerateStoryInput = z.infer<typeof generateStoryInputSchema>
120
-
121
- // workflowId from .howone/ai/manifest.json → capabilities[0].workflowId
122
- export const ai = defineAiActions({
123
- generateStory: defineAiAction('generateStory', {
124
- workflowId: 'd69ab648-2c00-4d94-928e-01bd7b2a5bb2', // ← from manifest.json
125
- inputSchema: generateStoryInputSchema,
126
- }),
127
- })
128
- ```
129
-
130
- ### Action with typed output
131
-
132
- ```ts
133
- export const generateStoryOutputSchema = z.object({
134
- title: z.string(),
135
- content: z.string(),
136
- summary: z.string(),
19
+ export const generateImageInputSchema = z.object({
20
+ prompt: z.string().min(1),
21
+ aspectRatio: z.enum(['1:1', '16:9']).optional(),
137
22
  })
138
- export type GenerateStoryOutput = z.infer<typeof generateStoryOutputSchema>
139
23
 
140
- export const ai = defineAiActions({
141
- generateStory: defineAiAction('generateStory', {
142
- workflowId: 'd69ab648-2c00-4d94-928e-01bd7b2a5bb2', // ← from manifest.json
143
- inputSchema: generateStoryInputSchema,
144
- outputSchema: generateStoryOutputSchema,
145
- }),
24
+ export const generateImageOutputSchema = z.object({
25
+ imageUrl: z.string().url(),
146
26
  })
147
- ```
148
27
 
149
- ### Multiple actions — each must have its own workflowId from manifest.json
150
-
151
- ```ts
152
- // Each workflowId is the UUID from .howone/ai/manifest.json for that capability
153
28
  export const ai = defineAiActions({
154
- generateStory: defineAiAction('generateStory', {
29
+ generateImage: defineAiAction('generateImage', {
155
30
  workflowId: 'd69ab648-2c00-4d94-928e-01bd7b2a5bb2',
156
- inputSchema: z.object({ topic: z.string(), language: z.string() }),
157
- }),
158
- translateText: defineAiAction('translateText', {
159
- workflowId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
160
- inputSchema: z.object({ text: z.string(), targetLang: z.string() }),
161
- }),
162
- summarizeArticle: defineAiAction('summarizeArticle', {
163
- workflowId: 'f9e8d7c6-b5a4-3210-fedc-ba9876543210',
164
- inputSchema: z.object({ url: z.string().url(), maxWords: z.number().int().optional() }),
165
- }),
166
- analyzeImage: defineAiAction('analyzeImage', {
167
- workflowId: '11223344-5566-7788-99aa-bbccddeeff00',
168
- inputSchema: z.object({ imageUrl: z.string().url(), prompt: z.string().optional() }),
31
+ inputSchema: generateImageInputSchema,
32
+ outputSchema: generateImageOutputSchema,
169
33
  }),
170
34
  })
171
35
  ```
172
36
 
173
- > **Note**: The UUIDs above are placeholders. Always copy the exact value from `.howone/ai/manifest.json`.
37
+ `workflowId` is required and validated as a UUID when the module loads. There is no action-name
38
+ fallback. Never guess, reuse an ID from another project, or continue with a placeholder.
174
39
 
175
- ---
40
+ Pass Zod schemas, not manifest JSON objects. With an `outputSchema`, `.run()` validates and returns
41
+ `run_complete.message` directly. Without one, `.run()` returns the raw `ExecutionResult`.
176
42
 
177
- ## Calling AI Actions
43
+ ## Four execution surfaces
178
44
 
179
- ### run() typed action result
45
+ ### `run`: exceptions are control flow
180
46
 
181
47
  ```ts
182
- import howone, { type GenerateStoryInput, type GenerateStoryOutput } from '@/lib/sdk'
183
-
184
- async function generateStory(input: GenerateStoryInput) {
185
- const output = await howone.ai.generateStory.run(input)
186
- // output is GenerateStoryOutput when outputSchema is configured.
187
- return output
48
+ try {
49
+ const output = await howone.ai.generateImage.run({ prompt })
50
+ setImageUrl(output.imageUrl)
51
+ } catch (error) {
52
+ if (error instanceof WorkflowExecutionError) {
53
+ console.error(error.outcome, error.runId, error.result.errors)
54
+ } else if (error instanceof AiSchemaValidationError) {
55
+ console.error(error.direction, error.issues)
56
+ }
57
+ throw error
188
58
  }
189
59
  ```
190
60
 
191
- When an action has `outputSchema`, `run()` returns the validated workflow `finalResult` payload.
192
- When an action omits `outputSchema`, `run()` returns the raw `AiResult` execution envelope.
61
+ `run()` rejects for:
193
62
 
194
- ### run() with SSE callbacks
63
+ - input or output schema validation errors;
64
+ - HTTP/network/abort errors;
65
+ - a `run_error` terminal event;
66
+ - a `credit_insufficient` terminal event;
67
+ - a stream that closes without a terminal event.
195
68
 
196
- ```ts
197
- const result = await howone.ai.generateStory.run(input, {
198
- onMessageChunk: (line) => {
199
- appendLog(line)
200
- },
201
- onProgress: (percent, line) => {
202
- if (line?.startsWith('[DISPLAY]')) setStatus(line.replace('[DISPLAY]', '').trim())
203
- if (percent === 100) setProgress(100)
204
- },
205
- onError: (error) => {
206
- console.error('SSE error:', error)
207
- },
208
- })
209
- ```
69
+ Do not check `result.success` after a failed run; failure does not resolve. A successful typed action
70
+ returns the typed output, not the execution envelope.
210
71
 
211
- UI feedback belongs in the frontend app. Do not import or expect SDK toast APIs. Use returned
212
- promises and callbacks to update app-owned state:
72
+ ### `runSafe`: explicit result union
213
73
 
214
- ```ts
215
- setStatus({ type: 'loading', message: 'Generating story...' })
74
+ Use this when UI code prefers data-flow branching:
216
75
 
217
- try {
218
- const output = await howone.ai.generateStory.run(input, {
219
- onProgress: (progress) => setProgress(progress),
220
- })
221
- setStatus({ type: 'success', message: 'Story ready', output })
222
- } catch (error) {
223
- setStatus({
224
- type: 'error',
225
- message: error instanceof Error ? error.message : 'Story generation failed',
226
- })
76
+ ```ts
77
+ const result = await howone.ai.generateImage.runSafe({ prompt })
78
+ if (!result.ok) {
79
+ setError(result.error.message)
80
+ return
227
81
  }
82
+ setImageUrl(result.value.imageUrl)
228
83
  ```
229
84
 
230
- ### stream() — start and control a session
231
-
232
85
  ```ts
233
- function startStream(input: GenerateStoryInput) {
234
- const session = howone.ai.generateStory.stream(input, {
235
- onMessageChunk: (text) => {
236
- setOutput(prev => prev + text)
237
- },
238
- onComplete: (result) => {
239
- console.log('Done:', result.finalResult)
240
- },
241
- onError: (error) => {
242
- console.error('Error:', error)
243
- },
244
- })
245
-
246
- // session.result is a Promise<AiResult>
247
- // session.cancel() aborts the stream
248
- return session
249
- }
250
-
251
- // Cancel mid-stream
252
- const session = startStream(myInput)
253
- setTimeout(() => session.cancel(), 5000)
254
-
255
- // Or await the full result via the session
256
- const result = await session.result
86
+ type AiSafeResult<T> =
87
+ | { ok: true; value: T }
88
+ | { ok: false; error: Error }
257
89
  ```
258
90
 
259
- ### events() async iterable SSE events
91
+ `runSafe()` catches the same errors that `run()` throws. Do not use it to silently ignore failures.
92
+
93
+ ### `stream`: callbacks plus a rejecting result promise
260
94
 
261
95
  ```ts
262
- async function consumeEvents(input: GenerateStoryInput) {
263
- for await (const event of howone.ai.generateStory.events(input)) {
264
- switch (event.type) {
265
- case 'run_start':
266
- setStatus('running')
267
- break
268
- case 'progress':
269
- appendLog(String(event.message))
270
- break
271
- case 'run_complete':
272
- console.log('Final result:', event.message)
273
- break
274
- case 'credit_insufficient':
275
- showCreditError(String(event.message))
276
- break
277
- case 'run_error':
278
- showExecutionError(String(event.message))
279
- break
280
- }
281
- }
96
+ const session = howone.ai.generateImage.stream(
97
+ { prompt },
98
+ {
99
+ onRunStart: () => setPhase('running'),
100
+ onMessageChunk: (line) => appendLog(line),
101
+ onRunComplete: (_event, result) => console.log(result.finalResult),
102
+ onCreditInsufficient: () => setPhase('blocked'),
103
+ onRunError: () => setPhase('failed'),
104
+ onError: (error) => console.error(error),
105
+ },
106
+ )
107
+
108
+ try {
109
+ const execution = await session.result
110
+ console.log(execution.finalResult)
111
+ } finally {
112
+ // cancel is idempotent and safe during cleanup.
113
+ session.cancel()
282
114
  }
283
115
  ```
284
116
 
285
- ---
286
-
287
- ## zod Schema Patterns
288
-
289
- ### JSON Schema → zod mapping
117
+ `stream()` starts immediately. `session.result` resolves only on `run_complete`; it rejects on
118
+ terminal workflow errors, transport errors, and abort. For a typed action, `session.result` remains
119
+ the raw execution result because streaming callbacks/events expose protocol data. Use `.run()` when
120
+ the only desired result is the validated typed output.
290
121
 
291
- Generate zod from `.howone/ai/manifest.json` inputSchema / outputSchema fields:
292
-
293
- | JSON Schema type | zod |
294
- |---|---|
295
- | `string` | `z.string()` |
296
- | `number` | `z.number()` |
297
- | `integer` | `z.number().int()` |
298
- | `boolean` | `z.boolean()` |
299
- | `array of string` | `z.array(z.string())` |
300
- | `array of object` | `z.array(z.object({ ... }))` |
301
- | `object` | `z.object({ ... })` |
302
- | `enum` (string) | `z.enum(['a', 'b', 'c'])` |
303
- | optional field (not in `required[]`) | `.optional()` on the field |
304
- | field with default | `.default(value)` |
305
- | nullable field | `.nullable()` |
306
-
307
- ### Real examples
122
+ ### `events`: ordered async iteration
308
123
 
309
124
  ```ts
310
- // Simple text generation
311
- export const summarizeInputSchema = z.object({
312
- text: z.string().min(1).max(10000),
313
- maxWords: z.number().int().min(10).max(500).optional(),
314
- language: z.string().default('en'),
315
- })
316
-
317
- // Image analysis
318
- export const analyzeImageInputSchema = z.object({
319
- imageUrl: z.string().url(),
320
- prompt: z.string().optional(),
321
- outputFormat: z.enum(['json', 'text', 'markdown']).default('json'),
322
- })
323
-
324
- // Multi-step generation with options
325
- export const generatePostInputSchema = z.object({
326
- topic: z.string().min(1),
327
- tone: z.enum(['professional', 'casual', 'humorous']),
328
- platform: z.enum(['twitter', 'linkedin', 'blog']),
329
- keywords: z.array(z.string()).min(1).max(10),
330
- includeHashtags: z.boolean().default(true),
331
- })
332
-
333
- // Nested object
334
- export const analyzeDataInputSchema = z.object({
335
- dataset: z.array(
336
- z.object({
337
- id: z.string(),
338
- value: z.number(),
339
- label: z.string().optional(),
340
- })
341
- ),
342
- aggregationType: z.enum(['sum', 'average', 'median', 'max', 'min']),
343
- groupBy: z.string().optional(),
344
- })
125
+ try {
126
+ for await (const event of howone.ai.generateImage.events({ prompt })) {
127
+ if (event.type === 'progress') appendLog(String(event.message))
128
+ if (event.type === 'run_complete') setOutput(event.message)
129
+ }
130
+ } catch (error) {
131
+ // On run_error/credit_insufficient, the terminal event was yielded before this rejection.
132
+ setError(error instanceof Error ? error.message : String(error))
133
+ }
345
134
  ```
346
135
 
347
- ---
136
+ The iterable owns and cleans up its session. On terminal workflow failure it yields the terminal
137
+ event first, then rejects on the next iteration step. Breaking out of the loop cancels the request.
348
138
 
349
- ## SSEExecutionOptions — All Callbacks
139
+ ## Callback semantics
350
140
 
351
141
  ```ts
352
- type SSEExecutionOptions = {
353
- // Called for every parsed workflow envelope. channel is always "workflow".
354
- onEvent?: (event: AiEvent, channel: string) => void
355
-
356
- // Lifecycle
142
+ type AiOptions = {
143
+ onEvent?: (event: AiEvent, channel: 'workflow') => void
144
+ onDisplayEvent?: (event: WorkflowEventDisplay) => void
357
145
  onRunStart?: (event: RunStartEvent) => void
358
- onRunComplete?: (event: RunCompleteEvent, result: AiResult) => void
359
- /** Credits / quota exhausted — show top-up UI, NOT a generic error */
360
- onCreditInsufficient?: (event: CreditInsufficientEvent) => void
361
- /** Workflow logic failed — show retry/support UI */
362
- onRunError?: (event: RunErrorEvent) => void
363
-
364
- // Progress log lines from progress.message
365
146
  onMessageChunk?: (text: string, event: ProgressEvent) => void
366
-
367
- // Progress compatibility callback: progress lines fire as (0, message), success fires as (100)
368
147
  onProgress?: (percent: number, message?: string) => void
369
-
370
- // Internal transport log
371
- onLog?: (message: string) => void
372
-
373
- // Called on any error (credit or execution)
374
- onError?: (error: Error) => void
375
-
376
- // Called when the stream closes (success or error)
148
+ onRunComplete?: (event: RunCompleteEvent, result: AiResult) => void
149
+ onRunError?: (event: RunErrorEvent) => void
150
+ onCreditInsufficient?: (event: CreditInsufficientEvent) => void
377
151
  onComplete?: (result: AiResult) => void
378
-
379
- // Abort signal — connect to an AbortController for cancellation
152
+ onError?: (error: Error) => void
380
153
  signal?: AbortSignal
381
-
382
- // Limit-exceeded handler (overrides client-level config)
383
- limitExceeded?: {
384
- onLimitExceeded?: (context: LimitExceededContext) => void
385
- showUpgradeToast?: boolean
386
- upgradeUrl?: string
387
- }
154
+ maxRetainedEvents?: number
155
+ maxRetainedProgressLogs?: number
388
156
  }
389
157
  ```
390
158
 
391
- > The current workflow execute SSE endpoint does not emit `node_start`, `tool_call_end`,
392
- > `state_update`, or `ai_message_chunk`. Do not write generated app code that expects those events.
393
- > `onCreditInsufficient` and `onRunError` are **mutually exclusive** terminal events.
394
- > Do not use a generic `onError` to distinguish them — use the dedicated callbacks.
159
+ Use `onMessageChunk`; old names such as `onStreamChunk`, `onTextChunk`, or legacy multi-channel
160
+ callbacks do not exist. `onEvent` observes every event before terminal-specific callbacks and before
161
+ the result promise rejects. `onComplete` means a terminal event was assembled, including an error
162
+ terminal; inspect `result.outcome`. `onError` then receives the structured error.
395
163
 
396
- ---
164
+ Callbacks receive every event even when retained arrays are bounded. Defaults retain at most 1,000
165
+ raw events and 500 progress log lines, preventing long workflows from growing memory without bound.
397
166
 
398
- ## AiSchemaValidationError
167
+ ## Error classes
399
168
 
400
- Thrown by `run()` when input or output schema validation fails.
401
-
402
- ```ts
403
- import { AiSchemaValidationError } from '@howone/sdk'
404
-
405
- try {
406
- const result = await howone.ai.generateStory.run(input)
407
- } catch (err) {
408
- if (err instanceof AiSchemaValidationError) {
409
- console.error('Validation failed:')
410
- console.error(' Action:', err.actionId) // 'generateStory'
411
- console.error(' Direction:', err.direction) // 'input' | 'output'
412
- console.error(' Issues:', err.issues) // [{ path, message, code }]
413
- }
414
- }
415
- ```
169
+ | Error | Meaning |
170
+ |---|---|
171
+ | `AiWorkflowConfigurationError` | binding has a missing/non-UUID workflow ID |
172
+ | `AiSchemaConfigurationError` | schema does not implement Zod `safeParse` |
173
+ | `AiSchemaValidationError` | input or final output violates the schema |
174
+ | `WorkflowExecutionError` | EAX emitted `run_error` or `credit_insufficient` |
416
175
 
417
- ---
176
+ For credit failures, use `error.outcome === 'credit_insufficient'`. SDK limit callbacks are signals;
177
+ the app owns visible upgrade UI. The SDK does not call `window.confirm` unless legacy
178
+ `showUpgradeToast: true` is explicitly enabled.
418
179
 
419
- ## AI Result Persistence
180
+ ## Persistence
420
181
 
421
- When AI-generated content should be saved to an entity, prefer the SDK persistence helper for
422
- history-style products. It standardizes the pending-first pattern from `02-entity-schema/05-ai-persistence-patterns.md`
423
- without adding UI behavior.
182
+ For history-style products, create a pending entity before calling AI and persist completed/failed
183
+ state with `runAiActionAndPersist()`. Keep workflow execution free of database CRUD; entity writes
184
+ belong to app code.
424
185
 
425
186
  ```ts
426
- import { runAiActionAndPersist } from '@howone/sdk'
427
-
428
- const result = await runAiActionAndPersist({
187
+ await runAiActionAndPersist({
429
188
  entity: howone.entities.Generation,
430
- input: {
431
- prompt: 'Dragons and magic',
432
- ageRange: '6-8',
433
- },
434
- createPending: (input) => ({
435
- prompt: input.prompt,
436
- ageRange: input.ageRange,
437
- status: 'pending',
438
- requestedAt: new Date().toISOString(),
439
- }),
440
- run: (input) => howone.ai.generateStory.run(input),
441
- mapCompleted: ({ output }) => ({
442
- status: 'completed',
443
- title: output.title,
444
- content: output.content,
445
- completedAt: new Date().toISOString(),
446
- }),
189
+ input,
190
+ createPending: ({ prompt }) => ({ prompt, status: 'pending' }),
191
+ run: (value) => howone.ai.generateImage.run(value),
192
+ mapCompleted: ({ output }) => ({ status: 'completed', imageUrl: output.imageUrl }),
447
193
  mapFailed: ({ error }) => ({
448
194
  status: 'failed',
449
- errorMessage: error instanceof Error ? error.message : 'Generation failed',
195
+ errorMessage: error instanceof Error ? error.message : String(error),
450
196
  }),
451
- onStateChange: (state) => {
452
- // app-owned UI callback; SDK does not show toasts
453
- setGenerationState(state.status)
454
- },
455
197
  })
456
198
  ```
457
199
 
458
- Return shape:
459
-
460
- ```ts
461
- type AiPersistenceResult<TRecord, TOutput> =
462
- | { status: 'completed'; record: TRecord; output: TOutput }
463
- | { status: 'failed'; record: TRecord; error: unknown }
464
- ```
465
-
466
- Rules:
467
-
468
- - `createPending` must only return fields declared in the entity schema.
469
- - `mapCompleted` maps durable product fields from AI output to entity update payload.
470
- - `mapFailed` should persist a failure state if the product shows history or retry.
471
- - Use `onStateChange` to update app-owned UI; do not add SDK toast behavior.
472
- - For simple one-shot AI actions that do not need history, call `howone.ai.*.run()` directly.
473
-
474
- ---
475
-
476
- ## React Patterns
477
-
478
- ### One-shot run with loading state
479
-
480
- ```tsx
481
- import { useState } from 'react'
482
- import { AiSchemaValidationError } from '@howone/sdk'
483
- import howone, { type GenerateStoryInput, type GenerateStoryOutput } from '@/lib/sdk'
484
-
485
- function GenerateStoryButton({ input }: { input: GenerateStoryInput }) {
486
- const [loading, setLoading] = useState(false)
487
- const [result, setResult] = useState<GenerateStoryOutput | null>(null)
488
- const [error, setError] = useState<string | null>(null)
489
-
490
- async function handleGenerate() {
491
- setLoading(true)
492
- setError(null)
493
- try {
494
- const output = await howone.ai.generateStory.run(input)
495
- setResult(output)
496
- } catch (err) {
497
- if (err instanceof AiSchemaValidationError) {
498
- setError(`Validation error: ${err.issues.map(i => i.message).join(', ')}`)
499
- } else {
500
- setError(err instanceof Error ? err.message : 'Unknown error')
501
- }
502
- } finally {
503
- setLoading(false)
504
- }
505
- }
506
-
507
- return (
508
- <>
509
- <button onClick={handleGenerate} disabled={loading}>
510
- {loading ? 'Generating...' : 'Generate Story'}
511
- </button>
512
- {result && <div>{result.title}</div>}
513
- {error && <div className="error">{error}</div>}
514
- </>
515
- )
516
- }
517
- ```
518
-
519
- ### Streaming with live text output
520
-
521
- ```tsx
522
- import { useRef, useState } from 'react'
523
- import howone, { type GenerateStoryInput } from '@/lib/sdk'
524
- import type { AiSession } from '@howone/sdk'
525
-
526
- function StreamingStoryGenerator({ input }: { input: GenerateStoryInput }) {
527
- const [text, setText] = useState('')
528
- const [streaming, setStreaming] = useState(false)
529
- const sessionRef = useRef<AiSession | null>(null)
530
-
531
- function startGeneration() {
532
- setText('')
533
- setStreaming(true)
534
-
535
- sessionRef.current = howone.ai.generateStory.stream(input, {
536
- onStreamChunk: (chunk) => setText(prev => prev + chunk),
537
- onComplete: () => setStreaming(false),
538
- onError: (err) => {
539
- console.error(err)
540
- setStreaming(false)
541
- },
542
- })
543
- }
544
-
545
- function cancelGeneration() {
546
- sessionRef.current?.cancel()
547
- setStreaming(false)
548
- }
549
-
550
- return (
551
- <>
552
- <button onClick={startGeneration} disabled={streaming}>Start</button>
553
- <button onClick={cancelGeneration} disabled={!streaming}>Cancel</button>
554
- <pre>{text}</pre>
555
- </>
556
- )
557
- }
558
- ```
559
-
560
- ---
561
-
562
- ## Common Mistakes
200
+ ## Common mistakes
563
201
 
564
- | Mistake | Correct Pattern |
202
+ | Mistake | Fix |
565
203
  |---|---|
566
- | `defineAiAction('generateStory', { inputSchema })` — no `workflowId` | Always include `workflowId` from `manifest.json`; SDK falls back to action name, which is not a UUID → EAX rejects |
567
- | Writing `src/lib/sdk.ts` before `.howone/ai/manifest.json` has a workflowId | Run `ai-capability-design` `sync_ai_artifacts` `external-ai-capability`; only write bindings from the synced manifest |
568
- | Hardcoding `workflowId` from memory or guessing | Always read from `.howone/ai/manifest.json` copy the exact UUID |
569
- | `howone.ai.run.generateStory(input)` | `howone.ai.generateStory.run(input)` |
570
- | Action named `run`, `stream`, or `events` | Rename to e.g. `executeWorkflow`, `streamContent` |
571
- | Passing raw JSON Schema from manifest into `defineAiAction` | Convert JSON Schema fields to Zod first |
572
- | Making every output field `.optional()` or adding `.passthrough()` after validation fails | Keep manifest-required output fields required; inspect `AiSchemaValidationError.issues` and fix the contract/workflow mismatch |
573
- | Reading `raw.finalResult`, `raw.data.result`, or `raw.result` after a typed `.run()` | Use the returned value directly when `outputSchema` is configured |
574
- | Calling `howone.ai.generateStory.run(input)` inside JSX render | Move to event handler or useEffect |
204
+ | Omitting `workflowId` | Re-read the synced manifest; module initialization now throws. |
205
+ | Adding `mode: 'run'` to the action | Every action exposes run/runSafe/stream/events; no mode option exists. |
206
+ | Reading `.finalResult` from a typed `.run()` | Use the returned typed output directly. |
207
+ | Expecting `stream.result` to be typed output | It is the raw execution result. |
208
+ | Swallowing `session.result` | Always await/catch it to avoid unhandled rejection. |
209
+ | Weakening output Zod after validation fails | Fix the manifest/workflow mismatch. |
210
+ | Writing an EAX URL | Set only client `env`; the SDK owns the endpoint mapping. |