@positronic/template-new-project 0.0.77 → 0.0.78

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.
@@ -12,7 +12,7 @@ The Brain DSL provides a fluent, type-safe API for building stateful AI workflow
12
12
 
13
13
  The brain function provides full type safety through its fluent API. State types are automatically inferred as you build your brain, and options can be validated at runtime using schemas.
14
14
 
15
- For runtime options validation, use the `withOptionsSchema` method with a Zod schema:
15
+ For runtime options validation, use the `withOptions` method with a Zod schema:
16
16
 
17
17
  ```typescript
18
18
  import { z } from 'zod';
@@ -23,7 +23,7 @@ const optionsSchema = z.object({
23
23
  });
24
24
 
25
25
  const myBrain = brain('My Brain')
26
- .withOptionsSchema(optionsSchema)
26
+ .withOptions(optionsSchema)
27
27
  .step('Process', ({ options }) => {
28
28
  // options is fully typed based on the schema
29
29
  if (options.verbose) {
@@ -81,39 +81,33 @@ brain('AI Education Assistant')
81
81
  context: 'We are creating an educational example',
82
82
  }))
83
83
  .prompt('Generate explanation', {
84
- template: ({ state: { topic, context } }) =>
84
+ message: ({ state: { topic, context } }) =>
85
85
  `<%= '${context}' %>. Please provide a brief, beginner-friendly explanation of <%= '${topic}' %>.`,
86
- outputSchema: {
87
- schema: z.object({
88
- explanation: z.string().describe('A clear explanation of the topic'),
89
- keyPoints: z.array(z.string()).describe('3-5 key points about the topic'),
90
- difficulty: z.enum(['beginner', 'intermediate', 'advanced']).describe('The difficulty level'),
91
- }),
92
- name: 'topicExplanation' as const,
93
- },
86
+ outputSchema: z.object({
87
+ explanation: z.string().describe('A clear explanation of the topic'),
88
+ keyPoints: z.array(z.string()).describe('3-5 key points about the topic'),
89
+ difficulty: z.enum(['beginner', 'intermediate', 'advanced']).describe('The difficulty level'),
90
+ }),
94
91
  })
95
92
  .step('Format output', ({ state }) => ({
96
93
  ...state,
97
94
  formattedOutput: {
98
95
  topic: state.topic,
99
- explanation: state.topicExplanation.explanation || '',
100
- summary: `This explanation covers <%= '${state.topicExplanation.keyPoints?.length || 0}' %> key points at a <%= '${state.topicExplanation.difficulty || \'unknown\'}' %> level.`,
101
- points: state.topicExplanation.keyPoints || [],
96
+ explanation: state.explanation || '',
97
+ summary: `This explanation covers <%= '${state.keyPoints?.length || 0}' %> key points at a <%= '${state.difficulty || \'unknown\'}' %> level.`,
98
+ points: state.keyPoints || [],
102
99
  },
103
100
  }))
104
101
  .prompt(
105
102
  'Generate follow-up questions',
106
103
  {
107
- template: ({ state: { formattedOutput } }) =>
104
+ message: ({ state: { formattedOutput } }) =>
108
105
  `Based on this explanation about <%= '${formattedOutput.topic}' %>: "<%= '${formattedOutput.explanation}' %>"
109
-
106
+
110
107
  Generate 3 thoughtful follow-up questions that a student might ask.`,
111
- outputSchema: {
112
- schema: z.object({
113
- questions: z.array(z.string()).length(3).describe('Three follow-up questions'),
114
- }),
115
- name: 'followUpQuestions' as const,
116
- },
108
+ outputSchema: z.object({
109
+ questions: z.array(z.string()).length(3).describe('Three follow-up questions'),
110
+ }),
117
111
  },
118
112
  // Optional: Transform the response before merging with state
119
113
  ({ state, response }) => ({
@@ -128,10 +122,11 @@ brain('AI Education Assistant')
128
122
  ```
129
123
 
130
124
  Key points about prompt steps:
131
- - The `template` function receives the current state and resources, returning the prompt string
132
- - Templates can be async to load resources: `async (state, resources) => { ... }`
125
+ - The `message` function receives the current state and resources, returning the prompt string
126
+ - The `message` function can be async to load resources: `async ({ state, resources }) => { ... }`
133
127
  - `outputSchema` defines the structure using Zod schemas
134
- - The `name` property determines where the response is stored in state
128
+ - The schema result is spread directly onto state (`{ ...state, ...result }`)
129
+ - To namespace, wrap your schema in a parent key (e.g., `z.object({ plan: z.object({ ... }) })`)
135
130
  - You can optionally provide a transform function as the third parameter
136
131
  - Type inference works throughout - TypeScript knows about your schema types
137
132
 
@@ -147,23 +142,17 @@ const smartModel = createAnthropicClient({ model: 'claude-sonnet-4-5-20250929' }
147
142
 
148
143
  brain('Multi-Model Brain')
149
144
  .prompt('Quick summary', {
150
- template: ({ state: { document } }) => `Summarize this briefly: <%= '${document}' %>`,
151
- outputSchema: {
152
- schema: z.object({ summary: z.string() }),
153
- name: 'quickSummary' as const,
154
- },
145
+ message: ({ state: { document } }) => `Summarize this briefly: <%= '${document}' %>`,
146
+ outputSchema: z.object({ summary: z.string() }),
155
147
  client: fastModel, // Use a fast, cheap model for summarization
156
148
  })
157
149
  .prompt('Deep analysis', {
158
- template: ({ state: { quickSummary } }) =>
159
- `Analyze the implications of this summary: <%= '${quickSummary.summary}' %>`,
160
- outputSchema: {
161
- schema: z.object({
162
- insights: z.array(z.string()),
163
- risks: z.array(z.string()),
164
- }),
165
- name: 'analysis' as const,
166
- },
150
+ message: ({ state: { summary } }) =>
151
+ `Analyze the implications of this summary: <%= '${summary}' %>`,
152
+ outputSchema: z.object({
153
+ insights: z.array(z.string()),
154
+ risks: z.array(z.string()),
155
+ }),
167
156
  client: smartModel, // Use a more capable model for analysis
168
157
  });
169
158
  ```
@@ -181,17 +170,13 @@ const subBrain = brain('Sub Process').step('Transform', ({ state }) => ({
181
170
 
182
171
  const mainBrain = brain('Main Process')
183
172
  .step('Prepare', () => ({ value: 10 }))
184
- .brain(
185
- 'Run Sub Process',
186
- subBrain,
187
- ({ state, brainState }) => ({
188
- ...state,
189
- processed: brainState.result,
190
- }),
191
- (state) => ({ input: state.value }) // Initial state for sub-brain
192
- );
173
+ .brain('Run Sub Process', subBrain, {
174
+ initialState: ({ state }) => ({ input: state.value }),
175
+ });
193
176
  ```
194
177
 
178
+ The inner brain's final state is spread directly onto the outer state (e.g., `state.result` will be `20`). `initialState` is optional (defaults to `{}`) and can be a static object or a function receiving `{ state, options, ...plugins }`. To namespace, design the inner brain to return its results under a single key.
179
+
195
180
  ## Guard Clauses
196
181
 
197
182
  Use `.guard()` to short-circuit a brain when a condition isn't met. If the predicate returns `true`, execution continues normally. If it returns `false`, all remaining steps are skipped and the brain completes with the current state.
@@ -204,9 +189,8 @@ brain('email-checker')
204
189
  })
205
190
  .guard(({ state }) => state.emails.some(e => e.important))
206
191
  // everything below only runs if guard passes
207
- .ui('Review emails', { ... })
208
- .step('Notify and wait', ...)
209
- .step('Handle response', ...);
192
+ .page('Review emails', (ctx) => ({ ..., formSchema: ... }))
193
+ // form data auto-merges onto state
210
194
  ```
211
195
 
212
196
  Key points:
@@ -237,11 +221,11 @@ Each step receives these parameters:
237
221
  - `client` - AI client for generating structured objects
238
222
  - `resources` - Loaded resources (files, documents, etc.)
239
223
  - `options` - Runtime options passed to the brain
240
- - `response` - Webhook response data (available after `.wait()` completes)
241
- - `page` - Generated page object (available after `.ui()` step)
242
224
  - `pages` - Pages service for HTML page management
243
225
  - `env` - Runtime environment containing `origin` (base URL) and `secrets` (typed secrets object)
244
- - Custom services (if configured with `.withServices()` or `createBrain()`)
226
+ - Custom plugin-provided values (if configured with `.withPlugin()` or `createBrain()`)
227
+
228
+ > **Note**: `response` is only available inside `.handle()` callbacks after `.wait()`. For `.page()` with `formSchema`, the response is spread directly onto state. See [Page Steps](#page-steps) and [Webhooks](#webhooks) for details.
245
229
 
246
230
  ## Configuration Methods
247
231
 
@@ -251,7 +235,7 @@ Options provide runtime configuration for your brains, allowing different behavi
251
235
 
252
236
  #### Typing Options
253
237
 
254
- To use options in your brain, define a Zod schema with `withOptionsSchema`:
238
+ To use options in your brain, define a Zod schema with `withOptions`:
255
239
 
256
240
  ```typescript
257
241
  import { z } from 'zod';
@@ -263,9 +247,9 @@ const notificationSchema = z.object({
263
247
  includeTimestamp: z.boolean().default(true)
264
248
  });
265
249
 
266
- // Use withOptionsSchema to add runtime validation
250
+ // Use withOptions to add runtime validation
267
251
  const notificationBrain = brain('Notification Brain')
268
- .withOptionsSchema(notificationSchema)
252
+ .withOptions(notificationSchema)
269
253
  .step('Send Alert', async ({ state, options, slack }) => {
270
254
  // TypeScript knows the exact shape of options from the schema
271
255
  const message = options.includeTimestamp
@@ -304,7 +288,7 @@ px brain run my-brain -o "webhook=https://example.com/api?key=value"
304
288
 
305
289
  Options are passed as simple key=value pairs and are available as strings in your brain.
306
290
 
307
- #### Options vs Services vs Initial State
291
+ #### Options vs Plugins vs Initial State
308
292
 
309
293
  Understanding when to use each:
310
294
 
@@ -313,8 +297,8 @@ Understanding when to use each:
313
297
  - Don't change during execution
314
298
  - Examples: `slackChannel`, `apiEndpoint`, `debugMode`
315
299
 
316
- - **Services**: External dependencies and side effects (clients, loggers, databases)
317
- - Configure once with `.withServices()`
300
+ - **Plugins**: External dependencies and side effects (clients, loggers, databases)
301
+ - Configure once with `.withPlugin()` or `createBrain()`
318
302
  - Available in all steps
319
303
  - Not serializable
320
304
  - Examples: `slackClient`, `database`, `logger`
@@ -336,11 +320,9 @@ const notificationSchema = z.object({
336
320
  });
337
321
 
338
322
  const notificationBrain = brain('Smart Notifier')
339
- .withOptionsSchema(notificationSchema)
340
- .withServices({
341
- slack: slackClient,
342
- email: emailClient
343
- })
323
+ .withOptions(notificationSchema)
324
+ .withPlugin(slack)
325
+ .withPlugin(email)
344
326
  .step('Process Alert', ({ state, options }) => ({
345
327
  ...state,
346
328
  formattedMessage: options.includeDetails === 'true'
@@ -383,20 +365,16 @@ expect(mockSlack.post).toHaveBeenCalledWith('#test-channel', expect.any(String))
383
365
  expect(mockEmail.send).toHaveBeenCalled(); // High priority triggers email
384
366
  ```
385
367
 
386
- ### Service Injection
368
+ ### Plugin Injection
387
369
 
388
- The `withServices` method provides dependency injection for your brains, making external services available throughout the workflow while maintaining testability.
370
+ The `withPlugin` method provides dependency injection for your brains, making plugin-provided values available throughout the workflow while maintaining testability.
389
371
 
390
372
  #### Basic Usage
391
373
 
392
374
  ```typescript
393
- interface MyServices {
394
- logger: Logger;
395
- database: Database;
396
- }
397
-
398
- const brainWithServices = brain('Service Brain')
399
- .withServices<MyServices>({ logger, database })
375
+ const brainWithPlugins = brain('Plugin Brain')
376
+ .withPlugin(logger)
377
+ .withPlugin(database)
400
378
  .step('Log and Save', async ({ state, logger, database }) => {
401
379
  logger.info('Processing state');
402
380
  await database.save(state);
@@ -404,9 +382,9 @@ const brainWithServices = brain('Service Brain')
404
382
  });
405
383
  ```
406
384
 
407
- #### Where Services Are Available
385
+ #### Where Plugin Values Are Available
408
386
 
409
- Services are destructured alongside other parameters in:
387
+ Plugin-provided values are destructured alongside other parameters in:
410
388
 
411
389
  1. **Step Actions**:
412
390
  ```typescript
@@ -419,49 +397,28 @@ Services are destructured alongside other parameters in:
419
397
  2. **Prompt Reduce Functions**:
420
398
  ```typescript
421
399
  .prompt('Generate', {
422
- template: ({ state }) => 'Generate something',
423
- outputSchema: { schema, name: 'result' as const }
400
+ message: ({ state }) => 'Generate something',
401
+ outputSchema: schema,
424
402
  }, async ({ state, response, logger, database }) => {
425
403
  logger.info('Saving AI response');
426
- await database.save({ ...state, result: response });
404
+ await database.save({ ...state, ...response });
427
405
  return state;
428
406
  })
429
407
  ```
430
408
 
431
- 3. **Nested Brain Reducers**:
409
+ 3. **Nested Brain Config**:
432
410
  ```typescript
433
- .brain('Run Sub-Brain', subBrain, ({ state, brainState, logger }) => {
434
- logger.info('Sub-brain completed');
435
- return { ...state, subResult: brainState };
436
- })
411
+ .brain('Run Sub-Brain', subBrain)
437
412
  ```
438
413
 
439
414
  #### Real-World Example
440
415
 
441
416
  ```typescript
442
- // Define service interfaces
443
- interface Services {
444
- api: {
445
- fetchData: (id: string) => Promise<Data>;
446
- submitResult: (result: any) => Promise<void>;
447
- };
448
- cache: {
449
- get: (key: string) => Promise<any>;
450
- set: (key: string, value: any) => Promise<void>;
451
- };
452
- metrics: {
453
- track: (event: string, properties?: any) => void;
454
- time: (label: string) => () => void;
455
- };
456
- }
457
-
458
- // Create a brain with multiple services
417
+ // Create a brain with multiple plugins
459
418
  const analysisBrain = brain('Data Analysis')
460
- .withServices<Services>({
461
- api: apiClient,
462
- cache: redisClient,
463
- metrics: analyticsClient
464
- })
419
+ .withPlugin(api)
420
+ .withPlugin(cache)
421
+ .withPlugin(metrics)
465
422
  .step('Start Timing', ({ metrics }) => {
466
423
  const endTimer = metrics.time('analysis_duration');
467
424
  return { startTime: Date.now(), endTimer };
@@ -477,27 +434,26 @@ const analysisBrain = brain('Data Analysis')
477
434
  return { ...state, data };
478
435
  })
479
436
  .prompt('Analyze Data', {
480
- template: ({ state: { data } }) => `Analyze this data: <%= '${JSON.stringify(data)}' %>`,
481
- outputSchema: {
482
- schema: z.object({
483
- insights: z.array(z.string()),
484
- confidence: z.number()
485
- }),
486
- name: 'analysis' as const
487
- }
437
+ message: ({ state: { data } }) => `Analyze this data: <%= '${JSON.stringify(data)}' %>`,
438
+ outputSchema: z.object({
439
+ insights: z.array(z.string()),
440
+ confidence: z.number()
441
+ }),
488
442
  })
489
443
  .step('Save Results', async ({ state, api, cache, metrics }) => {
444
+ const { insights, confidence } = state;
445
+
490
446
  // Save to cache for next time
491
- await cache.set('analysis_result', state.analysis);
447
+ await cache.set('analysis_result', { insights, confidence });
492
448
 
493
449
  // Submit to API
494
- await api.submitResult(state.analysis);
450
+ await api.submitResult({ insights, confidence });
495
451
 
496
452
  // Track completion
497
453
  state.endTimer(); // End the timer
498
454
  metrics.track('analysis_complete', {
499
- insights_count: state.analysis.insights.length,
500
- confidence: state.analysis.confidence,
455
+ insights_count: insights.length,
456
+ confidence,
501
457
  from_cache: state.fromCache
502
458
  });
503
459
 
@@ -505,9 +461,9 @@ const analysisBrain = brain('Data Analysis')
505
461
  });
506
462
  ```
507
463
 
508
- #### Testing with Services
464
+ #### Testing with Plugins
509
465
 
510
- Services make testing easier by allowing you to inject mocks:
466
+ Plugins make testing easier by allowing you to inject mocks:
511
467
 
512
468
  ```typescript
513
469
  // In your test file
@@ -524,7 +480,8 @@ const mockDatabase = {
524
480
  };
525
481
 
526
482
  const testBrain = brain('Test Brain')
527
- .withServices({ logger: mockLogger, database: mockDatabase })
483
+ .withPlugin(mockLoggerPlugin)
484
+ .withPlugin(mockDatabasePlugin)
528
485
  .step('Do Something', async ({ logger, database }) => {
529
486
  logger.info('Fetching data');
530
487
  const data = await database.find('123');
@@ -536,7 +493,7 @@ const result = await runBrainTest(testBrain, {
536
493
  client: createMockClient()
537
494
  });
538
495
 
539
- // Verify service calls
496
+ // Verify plugin calls
540
497
  expect(mockLogger.info).toHaveBeenCalledWith('Fetching data');
541
498
  expect(mockDatabase.find).toHaveBeenCalledWith('123');
542
499
  expect(result.finalState.data).toEqual({ id: '123', name: 'Test' });
@@ -544,57 +501,61 @@ expect(result.finalState.data).toEqual({ id: '123', name: 'Test' });
544
501
 
545
502
  #### Important Notes
546
503
 
547
- - Call `withServices` before defining any steps
548
- - Services are typed - TypeScript knows exactly which services are available
549
- - Services are not serialized - they're for side effects and external interactions
550
- - Each brain instance maintains its own service references
504
+ - Call `withPlugin` before defining any steps
505
+ - Plugin-provided values are typed - TypeScript knows exactly which values are available
506
+ - Plugin values are not serialized - they're for side effects and external interactions
507
+ - Each brain instance maintains its own plugin references
551
508
 
552
- ### Tool Configuration with `withTools()`
509
+ ### Tool-Calling Prompt Loops
553
510
 
554
- The `withTools()` method registers tools that can be used by agent steps:
511
+ Use `.prompt()` with a `loop` property to run an LLM with tools. The LLM calls tools iteratively until it calls the auto-generated `done` tool:
555
512
 
556
513
  ```typescript
557
514
  import { z } from 'zod';
515
+ import { generatePage, waitForWebhook } from '@positronic/core';
558
516
 
559
- const brainWithTools = brain('Tool Brain')
560
- .withTools({
561
- fetchData: {
562
- description: 'Fetch data from an external API',
563
- inputSchema: z.object({
564
- endpoint: z.string(),
565
- params: z.record(z.string()).optional()
566
- }),
567
- execute: async ({ endpoint, params }) => {
568
- const url = new URL(endpoint);
569
- if (params) {
570
- Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
517
+ brain('Tool Brain')
518
+ .prompt('Fetch and Save', () => ({
519
+ system: 'You can fetch and save data.',
520
+ message: 'Fetch user data and save the summary.',
521
+ outputSchema: z.object({ summary: z.string() }),
522
+ loop: {
523
+ tools: {
524
+ fetchData: {
525
+ description: 'Fetch data from an external API',
526
+ inputSchema: z.object({
527
+ endpoint: z.string(),
528
+ params: z.record(z.string()).optional()
529
+ }),
530
+ execute: async ({ endpoint, params }) => {
531
+ const url = new URL(endpoint);
532
+ if (params) {
533
+ Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
534
+ }
535
+ const response = await fetch(url);
536
+ return response.json();
537
+ }
538
+ },
539
+ saveToDatabase: {
540
+ description: 'Save data to the database',
541
+ inputSchema: z.object({
542
+ table: z.string(),
543
+ data: z.any()
544
+ }),
545
+ execute: async ({ table, data }) => {
546
+ return { success: true, id: 'generated-id' };
547
+ }
571
548
  }
572
- const response = await fetch(url);
573
- return response.json();
574
- }
549
+ },
575
550
  },
576
- saveToDatabase: {
577
- description: 'Save data to the database',
578
- inputSchema: z.object({
579
- table: z.string(),
580
- data: z.any()
581
- }),
582
- execute: async ({ table, data }) => {
583
- // Database save logic
584
- return { success: true, id: 'generated-id' };
585
- }
586
- }
587
- })
588
- .brain('Data Agent', {
589
- system: 'You can fetch and save data.',
590
- prompt: 'Fetch user data and save the summary.'
591
- // Tools defined with withTools() are automatically available
592
- });
551
+ }));
593
552
  ```
594
553
 
554
+ Tools are explicit on each `.prompt()` — there's no global tool registration.
555
+
595
556
  ### Component Configuration with `withComponents()`
596
557
 
597
- The `withComponents()` method registers custom UI components for use in `.ui()` steps:
558
+ The `withComponents()` method registers custom UI components for use in `.page()` steps:
598
559
 
599
560
  ```typescript
600
561
  const brainWithComponents = brain('Custom UI Brain')
@@ -629,17 +590,17 @@ const brainWithComponents = brain('Custom UI Brain')
629
590
  }
630
591
  }
631
592
  })
632
- .ui('Dashboard', {
633
- template: ({ state }) => `
593
+ .page('Dashboard', ({ state }) => ({
594
+ prompt: `
634
595
  Create a dashboard using CustomCard components to display:
635
596
  - User name: <%= '${state.userName}' %>
636
597
  - Account status: <%= '${state.status}' %>
637
598
  Use DataTable to show recent activity.
638
599
  `,
639
- responseSchema: z.object({
600
+ formSchema: z.object({
640
601
  acknowledged: z.boolean()
641
- })
642
- });
602
+ }),
603
+ }));
643
604
  ```
644
605
 
645
606
  ### Typed Store with `withStore()`
@@ -714,9 +675,9 @@ await store.has('key'); // Returns boolean
714
675
  You can declare store fields at the project level so all brains share the same store shape:
715
676
 
716
677
  ```typescript
717
- // brain.ts
678
+ // src/brain.ts
718
679
  export const brain = createBrain({
719
- services: { slack },
680
+ plugins: [slack],
720
681
  store: {
721
682
  processedCount: z.number(),
722
683
  userSettings: { type: z.object({ notifications: z.boolean() }), perUser: true },
@@ -727,7 +688,7 @@ export const brain = createBrain({
727
688
  Or declare per-brain stores for brain-specific data:
728
689
 
729
690
  ```typescript
730
- // brains/my-brain.ts
691
+ // src/brains/my-brain.ts
731
692
  export default brain('my-brain')
732
693
  .withStore({ counter: z.number() })
733
694
  .step('Increment', async ({ store }) => {
@@ -739,18 +700,15 @@ export default brain('my-brain')
739
700
 
740
701
  ### Using `createBrain()` for Project Configuration
741
702
 
742
- For project-wide configuration, use `createBrain()` in your `brain.ts` file:
703
+ For project-wide configuration, use `createBrain()` in your `src/brain.ts` file:
743
704
 
744
705
  ```typescript
745
- // brain.ts
706
+ // src/brain.ts
746
707
  import { createBrain } from '@positronic/core';
747
708
  import { z } from 'zod';
748
709
 
749
710
  export const brain = createBrain({
750
- services: {
751
- logger: console,
752
- api: apiClient
753
- },
711
+ plugins: [logger, api],
754
712
  tools: {
755
713
  search: {
756
714
  description: 'Search the web',
@@ -771,7 +729,7 @@ export const brain = createBrain({
771
729
  });
772
730
  ```
773
731
 
774
- All brains created with this factory will have access to the configured services, tools, components, and store.
732
+ All brains created with this factory will have access to the configured plugins, tools, components, and store.
775
733
 
776
734
  #### Typing Initial State and Options
777
735
 
@@ -931,14 +889,11 @@ Resources are also available in prompt templates:
931
889
 
932
890
  ```typescript
933
891
  brain('Template Example').prompt('Generate Content', {
934
- template: async ({ state, resources }) => {
892
+ message: async ({ state, resources }) => {
935
893
  const template = await resources.prompts.customerSupport.load();
936
894
  return template.replace('{{issue}}', state.issue);
937
895
  },
938
- outputSchema: {
939
- schema: z.object({ response: z.string() }),
940
- name: 'supportResponse' as const,
941
- },
896
+ outputSchema: z.object({ response: z.string() }),
942
897
  });
943
898
  ```
944
899
 
@@ -997,7 +952,7 @@ When prompts become more than a sentence or two, extract them into separate file
997
952
  For complex brains, organize your code into folders:
998
953
 
999
954
  ```
1000
- brains/
955
+ src/brains/
1001
956
  ├── hn-bot/
1002
957
  │ ├── brain.ts # Main brain definition
1003
958
  │ └── ai-filter-prompt.ts # Complex prompt configuration
@@ -1009,7 +964,7 @@ brains/
1009
964
  When you extract a prompt to a separate file, you'll need to explicitly specify the state type:
1010
965
 
1011
966
  ```typescript
1012
- // brains/hn-bot/ai-filter-prompt.ts
967
+ // src/brains/hn-bot/ai-filter-prompt.ts
1013
968
  import { z } from 'zod';
1014
969
  import type { Resources } from '@positronic/core';
1015
970
 
@@ -1025,7 +980,7 @@ interface FilterPromptState {
1025
980
 
1026
981
  // Export the prompt configuration
1027
982
  export const aiFilterPrompt = {
1028
- template: async ({ state, resources }: { state: FilterPromptState, resources: Resources }) => {
983
+ message: async ({ state, resources }: { state: FilterPromptState, resources: Resources }) => {
1029
984
  // Load a prompt template from resources
1030
985
  const template = await resources.prompts.hnFilter.load();
1031
986
 
@@ -1038,17 +993,14 @@ export const aiFilterPrompt = {
1038
993
  .replace('{{articleList}}', articleList)
1039
994
  .replace('{{preferences}}', state.userPreferences || 'No specific preferences');
1040
995
  },
1041
- outputSchema: {
1042
- schema: z.object({
1043
- selectedArticles: z.array(z.number()).describe('Indices of selected articles'),
1044
- reasoning: z.string().describe('Brief explanation of selections'),
1045
- }),
1046
- name: 'filterResults' as const,
1047
- },
996
+ outputSchema: z.object({
997
+ selectedArticles: z.array(z.number()).describe('Indices of selected articles'),
998
+ reasoning: z.string().describe('Brief explanation of selections'),
999
+ }),
1048
1000
  };
1049
1001
 
1050
- // brains/hn-bot/brain.ts
1051
- import { brain } from '../brain.js';
1002
+ // src/brains/hn-bot/brain.ts
1003
+ import { brain } from '../../brain.js';
1052
1004
  import { aiFilterPrompt } from './ai-filter-prompt.js';
1053
1005
 
1054
1006
  export default brain('HN Article Filter')
@@ -1059,62 +1011,154 @@ export default brain('HN Article Filter')
1059
1011
  })
1060
1012
  .prompt('Filter Articles', aiFilterPrompt)
1061
1013
  .step('Format Results', ({ state }) => ({
1062
- selectedArticles: state.filterResults.selectedArticles.map(
1014
+ selectedArticles: state.selectedArticles.map(
1063
1015
  i => state.articles[i]
1064
1016
  ),
1065
- reasoning: state.filterResults.reasoning,
1017
+ reasoning: state.reasoning,
1066
1018
  }));
1067
1019
  ```
1068
1020
 
1069
1021
  ### When to Extract Prompts
1070
1022
 
1071
1023
  Extract prompts to separate files when:
1072
- - The template is more than 2-3 lines
1024
+ - The message is more than 2-3 lines
1073
1025
  - The prompt uses complex logic or formatting
1074
- - You need to load resources or templates
1026
+ - You need to load resources
1075
1027
  - The prompt might be reused in other brains
1076
1028
  - You want to test the prompt logic separately
1077
1029
 
1078
- ## Iterating Over Items
1030
+ ## JSX Templates
1079
1031
 
1080
- When you need to run the same prompt, brain, or agent over multiple items, use the `over` option.
1032
+ Templates can be written as JSX instead of template literal strings. This improves readability for complex prompts with conditionals, loops, and multi-line content. Prettier formats JSX automatically, keeping your prompts properly indented within the builder chain.
1081
1033
 
1082
- ### Prompt Iterate
1034
+ ### Basic Usage
1083
1035
 
1084
- Run the same prompt once per item in a list:
1036
+ Rename your brain file from `.ts` to `.tsx` and return JSX from the message function:
1085
1037
 
1086
- ```typescript
1087
- brain('Item Processor')
1088
- .step('Initialize', () => ({
1089
- items: [
1090
- { id: 1, title: 'First item' },
1091
- { id: 2, title: 'Second item' },
1092
- { id: 3, title: 'Third item' }
1093
- ]
1094
- }))
1095
- .prompt('Summarize Items', {
1096
- template: ({ item }) => `Summarize this item: <%= '${item.title}' %>`,
1097
- outputSchema: {
1098
- schema: z.object({ summary: z.string() }),
1099
- name: 'summaries' as const
1038
+ ```tsx
1039
+ // src/brains/analyze.tsx
1040
+ import { brain } from '../brain.js';
1041
+ import { z } from 'zod';
1042
+
1043
+ export default brain('analyze')
1044
+ .prompt('Analyze', {
1045
+ message: ({ state: { topic, context } }) => (
1046
+ <>
1047
+ Analyze the following topic: {topic}
1048
+
1049
+ Context: {context}
1050
+
1051
+ Please provide:
1052
+ - A summary
1053
+ - Key insights
1054
+ - Recommendations
1055
+ </>
1056
+ ),
1057
+ outputSchema: z.object({
1058
+ summary: z.string(),
1059
+ insights: z.array(z.string()),
1060
+ recommendations: z.array(z.string()),
1061
+ }),
1062
+ });
1063
+ ```
1064
+
1065
+ No `render()` call is needed — the runner handles JSX rendering internally. Old string messages still work, so this is fully opt-in.
1066
+
1067
+ ### Conditionals
1068
+
1069
+ Use `&&` for boolean conditions and ternaries when you need both branches or when the condition could be a falsy non-boolean (like `0` or `""`):
1070
+
1071
+ ```tsx
1072
+ // && works when the condition is strictly boolean
1073
+ message: ({ state: { user, isVIP } }) => (
1074
+ <>
1075
+ Create a greeting for {user.name}.
1076
+ {isVIP && <>This is a VIP customer. Use premium language.</>}
1077
+ </>
1078
+ )
1079
+
1080
+ // Ternary for either/or content
1081
+ message: ({ state: { user, tier } }) => (
1082
+ <>
1083
+ Create a greeting for {user.name}.
1084
+ {tier === 'premium'
1085
+ ? <>Use premium, personalized language.</>
1086
+ : <>Use friendly, standard language.</>
1100
1087
  }
1101
- }, {
1102
- over: ({ state }) => state.items,
1103
- error: (item, error) => ({ summary: 'Failed to summarize' })
1104
- })
1105
- .step('Process Results', ({ state }) => ({
1106
- ...state,
1107
- // summaries is an IterateResult — use .values, .items, .entries, .filter(), .map()
1108
- processedSummaries: state.summaries.map((item, response) => ({
1109
- id: item.id,
1110
- summary: response.summary
1111
- }))
1112
- }));
1088
+ </>
1089
+ )
1090
+ ```
1091
+
1092
+ **Watch out for non-boolean falsy values.** `{count && <>...</>}` renders `"0"` when count is 0 — use `{count > 0 && <>...</>}` or a ternary instead.
1093
+
1094
+ ### Loops
1095
+
1096
+ Use `.map()` naturally inside JSX:
1097
+
1098
+ ```tsx
1099
+ message: ({ state: { items } }) => (
1100
+ <>
1101
+ Review the following items:
1102
+ {items.map(item => (
1103
+ <>
1104
+ - {item.name}: {item.description}
1105
+ </>
1106
+ ))}
1107
+ </>
1108
+ )
1109
+ ```
1110
+
1111
+ ### Reusable Prompt Components
1112
+
1113
+ Extract common prompt sections into function components:
1114
+
1115
+ ```tsx
1116
+ const CategoryInstructions = ({ categories }: { categories: string[] }) => (
1117
+ <>
1118
+ Valid categories: {categories.join(', ')}
1119
+ Always pick exactly one. If unsure, pick "other".
1120
+ </>
1121
+ );
1122
+
1123
+ // Use in a message
1124
+ message: ({ state: { email, categories } }) => (
1125
+ <>
1126
+ Categorize this email:
1127
+ From: {email.from}
1128
+ Subject: {email.subject}
1129
+
1130
+ <CategoryInstructions categories={categories} />
1131
+ </>
1132
+ )
1113
1133
  ```
1114
1134
 
1115
- Prompt iterate also supports per-step `client` overrides (see Prompt Steps above), so you can use a different model for processing.
1135
+ ### Async Components
1116
1136
 
1117
- ### Brain Iterate
1137
+ Function components can be async, which is useful for loading resources:
1138
+
1139
+ ```tsx
1140
+ const Resource = async ({ from }: { from: any }) => {
1141
+ const content = await from.loadText();
1142
+ return <>{content}</>;
1143
+ };
1144
+
1145
+ message: ({ state, resources }) => (
1146
+ <>
1147
+ Summarize this document using the guidelines below:
1148
+
1149
+ <Resource from={resources.guidelines} />
1150
+
1151
+ Document:
1152
+ {state.document}
1153
+ </>
1154
+ )
1155
+ ```
1156
+
1157
+ ## Iterating Over Items
1158
+
1159
+ When you need to run the same operation over multiple items, use `.map()`. You can iterate a prompt directly, or iterate a brain for more complex per-item logic.
1160
+
1161
+ ### Basic `.map()` with a Brain
1118
1162
 
1119
1163
  Run a nested brain once per item:
1120
1164
 
@@ -1129,11 +1173,11 @@ brain('Process All Items')
1129
1173
  .step('Initialize', () => ({
1130
1174
  items: [{ value: 1 }, { value: 2 }, { value: 3 }]
1131
1175
  }))
1132
- .brain('Process Each', processBrain, {
1176
+ .map('Process Each', 'results', {
1177
+ run: processBrain,
1133
1178
  over: ({ state }) => state.items,
1134
- initialState: (item) => ({ value: item.value }),
1135
- outputKey: 'results' as const,
1136
- error: (item, error) => ({ value: item.value, failed: true }),
1179
+ initialState: (item) => ({ value: item.value, result: 0 }),
1180
+ error: (item, error) => ({ value: item.value, result: 0 }),
1137
1181
  })
1138
1182
  .step('Use Results', ({ state }) => ({
1139
1183
  ...state,
@@ -1142,58 +1186,101 @@ brain('Process All Items')
1142
1186
  }));
1143
1187
  ```
1144
1188
 
1145
- ### Agent Iterate
1189
+ ### Iterating a Prompt
1146
1190
 
1147
- Run an agent config once per item. The `configFn` receives the item as its first argument:
1191
+ Use `.map()` with `prompt: { message, outputSchema }` to run a prompt per item:
1148
1192
 
1149
1193
  ```typescript
1194
+ brain('Item Processor')
1195
+ .step('Initialize', () => ({
1196
+ items: [
1197
+ { id: 1, title: 'First item' },
1198
+ { id: 2, title: 'Second item' },
1199
+ { id: 3, title: 'Third item' },
1200
+ ]
1201
+ }))
1202
+ .map('Summarize Items', 'summaries', {
1203
+ prompt: {
1204
+ message: ({ item }) => `Summarize this item: <%= '${item.title}' %>`,
1205
+ outputSchema: z.object({ summary: z.string() }),
1206
+ },
1207
+ over: ({ state }) => state.items,
1208
+ error: () => ({ summary: 'Failed to summarize' }),
1209
+ })
1210
+ .step('Process Results', ({ state }) => ({
1211
+ ...state,
1212
+ processedSummaries: state.summaries.map((item, result) => ({
1213
+ id: item.id,
1214
+ summary: result.summary,
1215
+ })),
1216
+ }));
1217
+ ```
1218
+
1219
+ The `message` function receives `{ item, state, options, resources }` where `item` is the current iteration item. The result per item is `z.infer` of the `outputSchema`. You can also pass `client` to use a different LLM client for the prompt.
1220
+
1221
+ ### Iterating an Agent
1222
+
1223
+ To iterate an agent over items, wrap it in a brain and use `.map()`:
1224
+
1225
+ ```typescript
1226
+ const researchBrain = brain('Research Single')
1227
+ .brain('Research', ({ state, tools }) => ({
1228
+ system: 'You are a research assistant.',
1229
+ prompt: `Research this topic: <%= '${state.name}' %>`,
1230
+ tools: { search: tools.search },
1231
+ outputSchema: z.object({ summary: z.string() }),
1232
+ }));
1233
+
1150
1234
  brain('Research Topics')
1151
1235
  .step('Initialize', () => ({
1152
1236
  topics: [{ name: 'AI' }, { name: 'Robotics' }]
1153
1237
  }))
1154
- .brain('Research Each', (item, { state, tools }) => ({
1155
- system: 'You are a research assistant.',
1156
- prompt: `Research this topic: <%= '${item.name}' %>`,
1157
- tools: {
1158
- search: tools.search,
1159
- },
1160
- outputSchema: {
1161
- schema: z.object({ summary: z.string() }),
1162
- name: 'research' as const,
1163
- },
1164
- }), {
1238
+ .map('Research Each', 'results', {
1239
+ run: researchBrain,
1165
1240
  over: ({ state }) => state.topics,
1166
- outputKey: 'results' as const,
1241
+ initialState: (topic) => ({ name: topic.name }),
1167
1242
  })
1168
1243
  .step('Use Results', ({ state }) => ({
1169
1244
  ...state,
1170
- // results is an IterateResult — use .values to get just the results
1171
1245
  summaries: state.results.values.map(result => result.summary),
1172
1246
  }));
1173
1247
  ```
1174
1248
 
1175
- ### Iterate Options
1249
+ ### `.map()` Options
1176
1250
 
1177
- All iterate variants share these options:
1251
+ `.map()` has two modes: **brain mode** (run an inner brain per item) and **prompt mode** (run a prompt per item).
1178
1252
 
1179
- - `over: (context) => T[] | Promise<T[]>` - Function returning the array to iterate over. Receives the full step context (`{ state, options, client, resources, services, ... }`) — the same context object that step actions receive. Most commonly you'll destructure just `{ state }`, but you can access options, services, or any other context field. Can be async.
1180
- - `error: (item, error) => Result | null` - Fallback when an item fails. Return `null` to skip the item entirely.
1253
+ Note: The `stateKey` is now the 2nd argument to `.map()`: `.map('title', 'stateKey', { ... })`.
1181
1254
 
1182
- Brain and agent iterate also require:
1255
+ **Common options** (both modes):
1183
1256
 
1184
- - `outputKey: string` - Key under which results are stored in state (use `as const` for type inference)
1257
+ - `over: (context) => T[] | Promise<T[]>` - Function returning the array to iterate over. Receives the full step context (`{ state, options, client, resources, ... }`) — the same context object that step actions receive. Most commonly you'll destructure just `{ state }`, but you can access options, plugin-provided values, or any other context field. Can be async.
1258
+ - `error: (item, error) => Result | null` - Optional fallback when an item fails. Return `null` to skip the item entirely.
1185
1259
 
1186
- Brain iterate additionally requires:
1260
+ **Brain mode** (use `run`):
1187
1261
 
1262
+ - `run: Brain` - The inner brain to execute for each item
1188
1263
  - `initialState: (item, outerState) => State` - Function to create the inner brain's initial state from each item
1189
1264
 
1190
- #### Accessing options and services in `over`
1265
+ **Prompt mode** (use `prompt: { message, outputSchema }`):
1191
1266
 
1192
- Since `over` receives the full step context, you can use options or services to determine which items to iterate over:
1267
+ - `prompt.message: (context) => string` - Message function. Receives `{ item, state, options, resources }` where `item` is the current iteration item.
1268
+ - `prompt.outputSchema: ZodSchema` - Zod schema for the LLM output.
1269
+ - `client?: ObjectGenerator` - Optional per-step LLM client override.
1270
+
1271
+ #### Accessing options and plugins in `over`
1272
+
1273
+ Since `over` receives the full step context, you can use options or plugin-provided values to determine which items to iterate over:
1193
1274
 
1194
1275
  ```typescript
1276
+ const processItemBrain = brain('Process Single')
1277
+ .step('Process', ({ state }) => ({
1278
+ ...state,
1279
+ result: `Processed item <%= '${state.id}' %>`,
1280
+ }));
1281
+
1195
1282
  brain('Dynamic Processor')
1196
- .withOptionsSchema(z.object({ category: z.string() }))
1283
+ .withOptions(z.object({ category: z.string() }))
1197
1284
  .step('Load items', () => ({
1198
1285
  items: [
1199
1286
  { id: 1, category: 'a' },
@@ -1201,14 +1288,10 @@ brain('Dynamic Processor')
1201
1288
  { id: 3, category: 'a' },
1202
1289
  ]
1203
1290
  }))
1204
- .prompt('Process', {
1205
- template: ({ item }) => `Process item <%= '${item.id}' %>`,
1206
- outputSchema: {
1207
- schema: z.object({ result: z.string() }),
1208
- name: 'results' as const,
1209
- },
1210
- }, {
1291
+ .map('Process', 'results', {
1292
+ run: processItemBrain,
1211
1293
  over: ({ state, options }) => state.items.filter(i => i.category === options.category),
1294
+ initialState: (item) => ({ id: item.id, result: '' }),
1212
1295
  })
1213
1296
  ```
1214
1297
 
@@ -1224,155 +1307,148 @@ By default, results are stored as an `IterateResult` — a collection that wraps
1224
1307
  - **`.map((item, result) => value)`** — maps over both item and result, returns a plain array
1225
1308
  - **`for...of`** — iterates as `[item, result]` tuples (backward compatible with destructuring)
1226
1309
 
1227
- For prompts, the key comes from `outputSchema.name`. For brain and agent iterate, it comes from `outputKey`.
1310
+ The key always comes from `stateKey`.
1228
1311
 
1229
- ## Agent Steps
1312
+ ## Prompt Steps with Tool-Calling Loops
1230
1313
 
1231
- For complex AI workflows that require tool use, use the `.brain()` method with an agent configuration:
1314
+ For complex AI workflows that require tool use, use `.prompt()` with a `loop` property. The LLM calls tools iteratively until it calls the auto-generated `done` tool with data matching the `outputSchema`:
1232
1315
 
1233
1316
  ```typescript
1234
1317
  brain('Research Assistant')
1235
1318
  .step('Initialize', () => ({
1236
1319
  query: 'What are the latest developments in AI?'
1237
1320
  }))
1238
- .brain('Research Agent', {
1321
+ .prompt('Research', ({ state }) => ({
1239
1322
  system: 'You are a helpful research assistant with access to search tools.',
1240
- prompt: ({ query }) => `Research this topic: <%= '${query}' %>`,
1241
- tools: {
1242
- search: {
1243
- description: 'Search the web for information',
1244
- inputSchema: z.object({
1245
- query: z.string().describe('The search query')
1246
- }),
1247
- execute: async ({ query }) => {
1248
- // Implement search logic
1249
- const results = await searchWeb(query);
1250
- return { results };
1323
+ message: `Research this topic: <%= '${state.query}' %>`,
1324
+ outputSchema: z.object({
1325
+ findings: z.array(z.string()),
1326
+ summary: z.string(),
1327
+ }),
1328
+ loop: {
1329
+ tools: {
1330
+ search: {
1331
+ description: 'Search the web for information',
1332
+ inputSchema: z.object({
1333
+ query: z.string().describe('The search query')
1334
+ }),
1335
+ execute: async ({ query }) => {
1336
+ const results = await searchWeb(query);
1337
+ return { results };
1338
+ }
1339
+ },
1340
+ summarize: {
1341
+ description: 'Summarize a piece of text',
1342
+ inputSchema: z.object({
1343
+ text: z.string().describe('Text to summarize')
1344
+ }),
1345
+ execute: async ({ text }) => {
1346
+ return { summary: text.slice(0, 100) + '...' };
1347
+ }
1251
1348
  }
1252
1349
  },
1253
- summarize: {
1254
- description: 'Summarize a piece of text',
1255
- inputSchema: z.object({
1256
- text: z.string().describe('Text to summarize')
1257
- }),
1258
- execute: async ({ text }) => {
1259
- return { summary: text.slice(0, 100) + '...' };
1260
- }
1261
- }
1350
+ maxTokens: 10000,
1262
1351
  },
1263
- maxTokens: 10000,
1264
- })
1265
- .step('Format Results', ({ state, brainState }) => ({
1352
+ }))
1353
+ .step('Format Results', ({ state }) => ({
1266
1354
  ...state,
1267
- researchResults: brainState.response
1355
+ researchResults: state.summary,
1268
1356
  }));
1269
1357
  ```
1270
1358
 
1271
- ### Agent Configuration Options
1359
+ ### Prompt Config (with loop)
1360
+
1361
+ - `message: string | TemplateReturn` - The user prompt sent to the LLM
1362
+ - `system?: string | TemplateReturn` - System prompt (optional, works with or without loop)
1363
+ - `outputSchema: ZodSchema` - **Required.** Structured output schema. With `loop`, generates a terminal `done` tool. Without `loop`, used for single-shot structured output.
1364
+ - `loop.tools: Record<string, Tool>` - Tools available to the LLM
1365
+ - `loop.maxTokens?: number` - Maximum cumulative tokens across all iterations
1366
+ - `loop.maxIterations?: number` - Maximum loop iterations (default: 100)
1367
+ - `loop.toolChoice?: 'auto' | 'required' | 'none'` - Tool choice strategy (default: `'required'`)
1272
1368
 
1273
- - `system: string` - System prompt for the agent
1274
- - `prompt: string | ((state) => string)` - User prompt (can be a function)
1275
- - `tools: Record<string, ToolDefinition>` - Tools available to the agent
1276
- - `outputSchema: { schema, name }` - Structured output schema (see below)
1277
- - `maxTokens: number` - Maximum tokens for the agent response
1278
- - `maxIterations: number` - Maximum agent loop iterations (default: 100)
1369
+ Without `loop`, `.prompt()` makes a single `generateObject()` call — no tools, no iteration.
1279
1370
 
1280
1371
  ### Tool Definition
1281
1372
 
1282
1373
  Each tool requires:
1283
1374
  - `description: string` - What the tool does
1284
1375
  - `inputSchema: ZodSchema` - Zod schema for the tool's input
1285
- - `execute: (input, context) => Promise<any>` - Function to execute when the tool is called
1286
- - `terminal?: boolean` - If true, calling this tool ends the agent loop
1376
+ - `execute?: (input, context) => Promise<any>` - Function to execute when the tool is called
1377
+ - `terminal?: boolean` - If true, calling this tool ends the loop
1287
1378
 
1288
1379
  ### Tool Webhooks (waitFor)
1289
1380
 
1290
- Tools can pause agent execution and wait for external events by returning `{ waitFor: webhook(...) }` from their `execute` function. This is useful for human-in-the-loop workflows where the agent needs to wait for approval, external API callbacks, or other asynchronous events.
1381
+ Tools can pause execution and wait for external events by returning `{ waitFor: webhook(...) }` from their `execute` function:
1291
1382
 
1292
1383
  ```typescript
1293
1384
  import approvalWebhook from '../webhooks/approval.js';
1294
1385
 
1295
1386
  brain('Support Ticket Handler')
1296
- .brain('Handle Support Request', {
1387
+ .prompt('Handle Request', ({ state }) => ({
1297
1388
  system: 'You are a support agent. Escalate complex issues for human review.',
1298
- prompt: ({ ticket }) => `Handle this support ticket: <%= '${ticket.description}' %>`,
1299
- tools: {
1300
- escalateToHuman: {
1301
- description: 'Escalate the ticket to a human reviewer for approval',
1302
- inputSchema: z.object({
1303
- summary: z.string().describe('Summary of the issue'),
1304
- recommendation: z.string().describe('Your recommended action'),
1305
- }),
1306
- execute: async ({ summary, recommendation }, context) => {
1307
- // Send notification to human reviewer (e.g., via Slack, email)
1308
- await notifyReviewer({ summary, recommendation, ticketId: context.state.ticketId });
1309
-
1310
- // Return waitFor to pause until the webhook fires
1311
- return {
1312
- waitFor: approvalWebhook(context.state.ticketId),
1313
- };
1389
+ message: `Handle this support ticket: <%= '${state.ticket.description}' %>`,
1390
+ outputSchema: z.object({
1391
+ resolution: z.string().describe('How the ticket was resolved'),
1392
+ }),
1393
+ loop: {
1394
+ tools: {
1395
+ escalateToHuman: {
1396
+ description: 'Escalate the ticket to a human reviewer for approval',
1397
+ inputSchema: z.object({
1398
+ summary: z.string().describe('Summary of the issue'),
1399
+ recommendation: z.string().describe('Your recommended action'),
1400
+ }),
1401
+ execute: async ({ summary, recommendation }, context) => {
1402
+ await notifyReviewer({ summary, recommendation, ticketId: context.state.ticketId });
1403
+ return { waitFor: approvalWebhook(context.state.ticketId) };
1404
+ },
1314
1405
  },
1315
1406
  },
1316
- resolveTicket: {
1317
- description: 'Mark the ticket as resolved',
1318
- inputSchema: z.object({
1319
- resolution: z.string().describe('How the ticket was resolved'),
1320
- }),
1321
- terminal: true,
1322
- },
1323
1407
  },
1324
- })
1325
- .step('Process Result', ({ state, response }) => ({
1408
+ }))
1409
+ .step('Process Result', ({ state }) => ({
1326
1410
  ...state,
1327
- // response contains the webhook data (e.g., { approved: true, reviewerNote: '...' })
1328
- approved: response?.approved,
1329
- reviewerNote: response?.reviewerNote,
1411
+ handled: true,
1330
1412
  }));
1331
1413
  ```
1332
1414
 
1333
1415
  Key points about tool `waitFor`:
1334
- - Return `{ waitFor: webhook(...) }` to pause the agent and wait for an external event
1335
- - The webhook response is available in the next step via the `response` parameter
1416
+ - Return `{ waitFor: webhook(...) }` to pause and wait for an external event
1417
+ - The webhook response is fed back as a tool result the loop continues with this data
1336
1418
  - You can wait for multiple webhooks (first response wins): `{ waitFor: [webhook1(...), webhook2(...)] }`
1337
1419
  - The `execute` function receives a `context` parameter with access to `state`, `options`, `env`, etc.
1338
- - Use this pattern for approvals, external API callbacks, or any human-in-the-loop workflow
1339
- - The built-in `waitForWebhook` tool defaults to a 1-hour timeout. Agents can customize via the `timeout` parameter (e.g., "30m", "24h", "7d"). If the timeout elapses, the brain is cancelled.
1340
1420
 
1341
- ### Agent Output Schema
1421
+ ### Output Schema
1342
1422
 
1343
- Use `outputSchema` to get structured, typed output from agent steps. This generates a terminal tool that the agent must call to complete, ensuring the output matches your schema:
1423
+ The `outputSchema` generates a terminal `done` tool that the LLM must call to complete. The result is spread directly onto state:
1344
1424
 
1345
1425
  ```typescript
1346
1426
  brain('Entity Extractor')
1347
- .brain('Extract Entities', {
1427
+ .prompt('Extract Entities', () => ({
1348
1428
  system: 'You are an entity extraction assistant.',
1349
- prompt: 'Extract all people and organizations from the provided text.',
1350
- outputSchema: {
1351
- schema: z.object({
1352
- people: z.array(z.string()).describe('Names of people mentioned'),
1353
- organizations: z.array(z.string()).describe('Organization names'),
1354
- confidence: z.number().min(0).max(1).describe('Confidence score'),
1355
- }),
1356
- name: 'entities' as const, // Use 'as const' for type inference
1357
- },
1358
- })
1429
+ message: 'Extract all people and organizations from the provided text.',
1430
+ outputSchema: z.object({
1431
+ people: z.array(z.string()).describe('Names of people mentioned'),
1432
+ organizations: z.array(z.string()).describe('Organization names'),
1433
+ confidence: z.number().min(0).max(1).describe('Confidence score'),
1434
+ }),
1435
+ loop: { tools: {} },
1436
+ }))
1359
1437
  .step('Use Extracted Data', ({ state }) => {
1360
- // TypeScript knows state.entities has people, organizations, and confidence
1361
- console.log('Found ' + state.entities.people.length + ' people');
1362
- console.log('Found ' + state.entities.organizations.length + ' organizations');
1438
+ // TypeScript knows state has people, organizations, and confidence
1363
1439
  return {
1364
1440
  ...state,
1365
- summary: 'Extracted ' + state.entities.people.length + ' people and ' +
1366
- state.entities.organizations.length + ' organizations',
1441
+ summary: 'Extracted ' + state.people.length + ' people and ' +
1442
+ state.organizations.length + ' organizations',
1367
1443
  };
1368
1444
  });
1369
1445
  ```
1370
1446
 
1371
- Key points about `outputSchema`:
1372
- - The agent automatically gets a `done` tool that uses your schema
1373
- - The result is stored under `state[name]` (e.g., `state.entities`)
1447
+ Key points:
1448
+ - The `done` tool is auto-generated from your `outputSchema`
1449
+ - If the LLM provides invalid output, the error is fed back so it can retry
1450
+ - The result is spread directly onto state (e.g., `state.people`, `state.organizations`)
1374
1451
  - Full TypeScript type inference flows to subsequent steps
1375
- - Use `as const` on the name for proper type narrowing
1376
1452
 
1377
1453
  ## Environment and Pages Service
1378
1454
 
@@ -1437,92 +1513,113 @@ The created page object contains:
1437
1513
  - `url: string` - Public URL to access the page
1438
1514
  - `webhook: WebhookConfig` - Webhook configuration for handling form submissions
1439
1515
 
1440
- ### Custom Pages with Forms (CSRF Token)
1441
-
1442
- When building custom HTML pages with forms, you must include a CSRF token to prevent unauthorized submissions. The `.ui()` step handles this automatically, but custom pages require manual setup. This applies whether you submit to the built-in `ui-form` endpoint or to a custom webhook.
1516
+ ### Custom HTML Pages
1443
1517
 
1444
- #### Using a Custom Webhook
1518
+ When you need full control over the page HTML (instead of having the LLM generate it), use `.page()` with the `html` property. The framework handles CSRF tokens, webhook registration, suspension, and form data merging automatically — same as LLM-generated pages.
1445
1519
 
1446
- If your page submits to a custom webhook (e.g., `/webhooks/archive`), pass the token as the second argument when creating the webhook registration:
1520
+ Rename your brain file to `.tsx` and use JSX to build the page:
1447
1521
 
1448
- ```typescript
1449
- import { generateFormToken } from '@positronic/core';
1450
- import archiveWebhook from '../webhooks/archive.js';
1522
+ ```tsx
1523
+ import { z } from 'zod';
1524
+ import { Form } from '@positronic/core';
1451
1525
 
1452
1526
  brain('Archive Workflow')
1453
- .step('Create Page', async ({ state, pages, env }) => {
1454
- const formToken = generateFormToken();
1455
-
1456
- const html = `<html>
1457
- <body>
1458
- <form method="POST" action="<%= '${env.origin}' %>/webhooks/archive">
1459
- <input type="hidden" name="__positronic_token" value="<%= '${formToken}' %>">
1460
- <input type="text" name="name" placeholder="Your name">
1461
- <button type="submit">Submit</button>
1462
- </form>
1463
- </body>
1464
- </html>`;
1465
-
1466
- await pages.create('my-page', html);
1467
- return { ...state, formToken };
1527
+ .step('Fetch data', async ({ state }) => {
1528
+ return { ...state, items: await fetchItems() };
1468
1529
  })
1469
- .wait('Wait for submission', ({ state }) => archiveWebhook(state.sessionId, state.formToken), { timeout: '24h' })
1470
- .step('Process', ({ state, response }) => ({
1471
- ...state,
1472
- name: response.name,
1473
- }));
1530
+ .page('Review', ({ state }) => ({
1531
+ html: (
1532
+ <Form>
1533
+ {state.items.map(item => (
1534
+ <label>
1535
+ <input type="checkbox" name="selectedIds" value={item.id} />
1536
+ {item.name}
1537
+ </label>
1538
+ ))}
1539
+ <button type="submit">Confirm</button>
1540
+ </Form>
1541
+ ),
1542
+ formSchema: z.object({ selectedIds: z.array(z.string()) }),
1543
+ onCreated: async (page) => {
1544
+ // Notify user — page.url is the public URL
1545
+ },
1546
+ }))
1547
+ .step('Process', ({ state }) => {
1548
+ // state.selectedIds comes from the form submission
1549
+ return { ...state, processed: true };
1550
+ });
1474
1551
  ```
1475
1552
 
1476
- #### Using the System `ui-form` Endpoint
1553
+ **Key details:**
1554
+
1555
+ - **`Form`** is imported from `@positronic/core`. It's a Symbol-based built-in component (like `Fragment`, `File`, `Resource`). The framework injects the form action URL (including CSRF token) during rendering.
1556
+ - **`html`** accepts JSX directly, a string, or a function component.
1557
+ - The page is wrapped in a full HTML document with the step title as `<title>`.
1558
+ - You can use any HTML elements (`<div>`, `<input>`, `<table>`, etc.) and include `<style>` tags for custom CSS.
1559
+ - Read-only pages (no `formSchema`) work too — they complete immediately without suspending.
1560
+
1561
+ #### Extracting Page JSX into Separate Files
1562
+
1563
+ For complex pages, extract the JSX into a separate `.tsx` file:
1564
+
1565
+ ```tsx
1566
+ // brains/my-brain/pages/review-page.tsx
1567
+ import { Form } from '@positronic/core';
1568
+ export function ReviewPage({ items }: { items: { id: string; name: string }[] }) {
1569
+ return (
1570
+ <Form>
1571
+ {items.map(item => (
1572
+ <label>
1573
+ <input type="checkbox" name="selectedIds" value={item.id} />
1574
+ {item.name}
1575
+ </label>
1576
+ ))}
1577
+ <button type="submit">Confirm</button>
1578
+ </Form>
1579
+ );
1580
+ }
1581
+ ```
1477
1582
 
1478
- If your page submits to the built-in `ui-form` endpoint, include the token in the webhook registration object:
1583
+ Then use it in the brain:
1479
1584
 
1480
- ```typescript
1481
- import { generateFormToken } from '@positronic/core';
1482
-
1483
- brain('Custom Form')
1484
- .step('Create Form Page', async ({ state, pages, env }) => {
1485
- const formToken = generateFormToken();
1486
- const webhookIdentifier = `custom-form-<%= '${Date.now()}' %>`;
1487
- const formAction = `<%= '${env.origin}' %>/webhooks/system/ui-form?identifier=<%= '${encodeURIComponent(webhookIdentifier)}' %>`;
1488
-
1489
- const page = await pages.create('my-form', `<html>
1490
- <body>
1491
- <form method="POST" action="<%= '${formAction}' %>">
1492
- <input type="hidden" name="__positronic_token" value="<%= '${formToken}' %>">
1493
- <input type="text" name="name" placeholder="Your name">
1494
- <button type="submit">Submit</button>
1495
- </form>
1496
- </body>
1497
- </html>`);
1585
+ ```tsx
1586
+ import { ReviewPage } from './pages/review-page.js';
1498
1587
 
1499
- return {
1500
- ...state,
1501
- pageUrl: page.url,
1502
- webhook: { slug: 'ui-form', identifier: webhookIdentifier, token: formToken },
1503
- };
1504
- })
1505
- .wait('Wait for form', ({ state }) => state.webhook)
1506
- .step('Process', ({ state, response }) => ({
1507
- ...state,
1508
- name: response.name,
1509
- }));
1588
+ brain('Archive Workflow')
1589
+ .page('Review', ({ state }) => ({
1590
+ html: <ReviewPage items={state.items} />,
1591
+ formSchema: z.object({ selectedIds: z.array(z.string()) }),
1592
+ }))
1510
1593
  ```
1511
1594
 
1512
- #### Summary
1595
+ This works because the positronic JSX runtime handles function components — it calls them with their props and renders the result. The `.tsx` file inherits `jsxImportSource: "@positronic/core"` from the project tsconfig.
1596
+
1597
+ **Important:** Do NOT annotate the return type on page components. JSX produces `TemplateNode` (which is `JSX.Element`). Writing `: TemplateChild` or `: ReactNode` will cause type errors. Let TypeScript infer.
1598
+
1599
+ **HTML elements work in page JSX.** `<div>`, `<input>`, `<label>`, `<table>`, `<style>`, etc. are all valid. This is specific to the `html` property on `.page()`. Prompt JSX (`.prompt()`, `.map()`) only supports `<>` (Fragment), `<File>`, `<Resource>`, and function components.
1600
+
1601
+ #### No JavaScript in Custom Pages
1602
+
1603
+ Custom HTML pages are static — `<script>` tags are blocked by Content Security Policy. This prevents XSS when user data is interpolated into page content.
1604
+
1605
+ Form submission doesn't need JS — native HTML forms work. Unchecked checkboxes don't submit, and the framework's `parseFormData` handles duplicate field names as arrays.
1513
1606
 
1514
- The three required pieces for any custom page with a form:
1515
- 1. Call `generateFormToken()` to get a token
1516
- 2. Add `<input type="hidden" name="__positronic_token" value="...">` to your form
1517
- 3. Include the `token` in your webhook registration — either as the second argument to a custom webhook function (e.g., `myWebhook(identifier, token)`) or in the registration object for `ui-form`
1607
+ For interactive UI (tabs, toggles), use `<details>`/`<summary>`:
1518
1608
 
1519
- Without a token, the server will reject the form submission.
1609
+ ```tsx
1610
+ {categories.map(cat => (
1611
+ <details open={cat === firstCategory}>
1612
+ <summary>{cat.label} ({cat.count})</summary>
1613
+ <ItemList items={cat.items} />
1614
+ </details>
1615
+ ))}
1616
+ ```
1520
1617
 
1521
- ## UI Steps
1618
+ ## Page Steps
1522
1619
 
1523
- UI steps allow brains to generate dynamic user interfaces using AI. The `.ui()` step generates a page and provides a `page` object to the next step. You then notify users and use `.wait()` to pause until the form is submitted.
1620
+ Page steps allow brains to generate dynamic user interfaces using AI. When `formSchema` is provided, `.page()` generates a page, auto-suspends the brain, and spreads the form response directly onto state. Use the optional `onCreated` callback for side effects (Slack messages, emails) that need access to the generated page URL.
1524
1621
 
1525
- ### Basic UI Step
1622
+ ### Basic Page Step
1526
1623
 
1527
1624
  ```typescript
1528
1625
  import { z } from 'zod';
@@ -1532,55 +1629,49 @@ brain('Feedback Collector')
1532
1629
  ...state,
1533
1630
  userName: 'John Doe',
1534
1631
  }))
1535
- // Generate the form
1536
- .ui('Collect Feedback', {
1537
- template: ({ state }) => `
1632
+ // Generate the form, onCreated users, auto-suspend, auto-merge response
1633
+ .page('Collect Feedback', ({ state, slack }) => ({
1634
+ prompt: `
1538
1635
  Create a feedback form for <%= '${state.userName}' %>.
1539
1636
  Include fields for rating (1-5) and comments.
1540
1637
  `,
1541
- responseSchema: z.object({
1638
+ formSchema: z.object({
1542
1639
  rating: z.number().min(1).max(5),
1543
1640
  comments: z.string(),
1544
1641
  }),
1545
- })
1546
- // Notify user
1547
- .step('Notify', async ({ state, page, slack }) => {
1548
- await slack.post('#feedback', `Please fill out: <%= '${page.url}' %>`);
1549
- return state;
1550
- })
1551
- // Wait for form submission (timeout after 24 hours, brain is cancelled if no response)
1552
- .wait('Wait for submission', ({ page }) => page.webhook, { timeout: '24h' })
1553
- // Process the form data (comes through response, not page)
1554
- .step('Process Feedback', ({ state, response }) => ({
1642
+ onCreated: async (page) => {
1643
+ await slack.post('#feedback', `Please fill out: <%= '${page.url}' %>`);
1644
+ },
1645
+ }))
1646
+ // No .handle() needed — form data spreads onto state
1647
+ .step('Process Feedback', ({ state }) => ({
1555
1648
  ...state,
1556
1649
  feedbackReceived: true,
1557
- rating: response.rating, // typed from responseSchema
1558
- comments: response.comments,
1650
+ // state.rating and state.comments are typed
1559
1651
  }));
1560
1652
  ```
1561
1653
 
1562
- ### How UI Steps Work
1654
+ ### How Page Steps Work
1563
1655
 
1564
- 1. **Template**: The `template` function generates a prompt describing the desired UI
1656
+ 1. **Prompt**: The `prompt` value describes the desired UI
1565
1657
  2. **AI Generation**: The AI creates a component tree based on the prompt
1566
- 3. **Page Object**: Next step receives `page` with `url` and `webhook`
1567
- 4. **Notification**: You notify users however you want (Slack, email, etc.)
1568
- 5. **Wait**: Use `.wait('title', ({ page }) => page.webhook)` to pause until form submission
1569
- 6. **Form Data**: Step after `.wait()` receives form data via `response`
1658
+ 3. **onCreated**: The optional `onCreated` callback runs with a `page` object containing `url` and `webhook`. Use it to notify users (Slack, email, etc.)
1659
+ 4. **Auto-Suspend**: The brain automatically suspends and waits for the form submission
1660
+ 5. **Auto-Spread**: The form data is automatically spread onto state (`{ ...state, ...formData }`)
1570
1661
 
1571
1662
  ### The `page` Object
1572
1663
 
1573
- After a `.ui()` step, the next step receives:
1664
+ The `page` object is available inside the `onCreated` callback:
1574
1665
  - `page.url` - URL where users can access the form
1575
1666
  - `page.webhook` - Pre-configured webhook for form submissions
1576
1667
 
1577
- ### Template Best Practices
1668
+ ### Prompt Best Practices
1578
1669
 
1579
1670
  Be specific about layout and content:
1580
1671
 
1581
1672
  ```typescript
1582
- .ui('Contact Form', {
1583
- template: ({ state }) => `
1673
+ .page('Contact Form', ({ state }) => ({
1674
+ prompt: `
1584
1675
  Create a contact form with:
1585
1676
  - Header: "Get in Touch"
1586
1677
  - Name field (required)
@@ -1590,12 +1681,12 @@ Be specific about layout and content:
1590
1681
 
1591
1682
  Use a clean, centered single-column layout.
1592
1683
  `,
1593
- responseSchema: z.object({
1684
+ formSchema: z.object({
1594
1685
  name: z.string(),
1595
1686
  email: z.string().email(),
1596
1687
  message: z.string(),
1597
1688
  }),
1598
- })
1689
+ }))
1599
1690
  ```
1600
1691
 
1601
1692
  ### Data Bindings
@@ -1603,78 +1694,71 @@ Be specific about layout and content:
1603
1694
  Use `{{path}}` syntax to bind props to runtime data:
1604
1695
 
1605
1696
  ```typescript
1606
- .ui('Order Summary', {
1607
- template: ({ state }) => `
1697
+ .page('Order Summary', ({ state }) => ({
1698
+ prompt: `
1608
1699
  Create an order summary showing:
1609
1700
  - List of items from {{cart.items}}
1610
1701
  - Total: {{cart.total}}
1611
1702
  - Shipping address input
1612
1703
  - Confirm button
1613
1704
  `,
1614
- responseSchema: z.object({
1705
+ formSchema: z.object({
1615
1706
  shippingAddress: z.string(),
1616
1707
  }),
1617
- })
1708
+ }))
1618
1709
  ```
1619
1710
 
1620
1711
  ### Multi-Step Forms
1621
1712
 
1622
- Chain UI steps for multi-page workflows:
1713
+ Chain page steps for multi-page workflows:
1623
1714
 
1624
1715
  ```typescript
1625
1716
  brain('User Onboarding')
1626
1717
  .step('Start', () => ({ userData: {} }))
1627
1718
 
1628
1719
  // Step 1: Personal info
1629
- .ui('Personal Info', {
1630
- template: () => `
1720
+ .page('Personal Info', ({ notify }) => ({
1721
+ prompt: `
1631
1722
  Create a form for personal information:
1632
1723
  - First name, Last name
1633
1724
  - Date of birth
1634
1725
  - Next button
1635
1726
  `,
1636
- responseSchema: z.object({
1727
+ formSchema: z.object({
1637
1728
  firstName: z.string(),
1638
1729
  lastName: z.string(),
1639
1730
  dob: z.string(),
1640
1731
  }),
1641
- })
1642
- .step('Notify Personal', async ({ state, page, notify }) => {
1643
- await notify(`Step 1: <%= '${page.url}' %>`);
1644
- return state;
1645
- })
1646
- .wait('Wait for Personal', ({ page }) => page.webhook)
1647
- .step('Save Personal', ({ state, response }) => ({
1648
- ...state,
1649
- userData: { ...state.userData, ...response },
1732
+ onCreated: async (page) => {
1733
+ await notify(`Step 1: <%= '${page.url}' %>`);
1734
+ },
1650
1735
  }))
1736
+ // No .handle() needed — form data spreads onto state
1651
1737
 
1652
1738
  // Step 2: Preferences
1653
- .ui('Preferences', {
1654
- template: ({ state }) => `
1655
- Create preferences form for <%= '${state.userData.firstName}' %>:
1739
+ .page('Preferences', ({ state, notify }) => ({
1740
+ prompt: `
1741
+ Create preferences form for <%= '${state.firstName}' %>:
1656
1742
  - Newsletter subscription checkbox
1657
1743
  - Contact preference (email/phone/sms)
1658
1744
  - Complete button
1659
1745
  `,
1660
- responseSchema: z.object({
1746
+ formSchema: z.object({
1661
1747
  newsletter: z.boolean(),
1662
1748
  contactMethod: z.enum(['email', 'phone', 'sms']),
1663
1749
  }),
1664
- })
1665
- .step('Notify Preferences', async ({ state, page, notify }) => {
1666
- await notify(`Step 2: <%= '${page.url}' %>`);
1667
- return state;
1668
- })
1669
- .wait('Wait for Preferences', ({ page }) => page.webhook)
1670
- .step('Complete', ({ state, response }) => ({
1750
+ onCreated: async (page) => {
1751
+ await notify(`Step 2: <%= '${page.url}' %>`);
1752
+ },
1753
+ }))
1754
+ // No .handle() needed — form data spreads onto state
1755
+ .step('Complete', ({ state }) => ({
1671
1756
  ...state,
1672
- userData: { ...state.userData, preferences: response },
1673
1757
  onboardingComplete: true,
1674
1758
  }));
1675
1759
  ```
1676
1760
 
1677
- For more details on UI steps, see the full UI Step Guide in the main Positronic documentation.
1761
+ For more details on page steps, see the full Page Step Guide in the main Positronic documentation.
1678
1762
 
1679
1763
  ## Complete Example
1680
1764
 
@@ -1683,53 +1767,38 @@ import { brain } from '../brain.js';
1683
1767
  import { BrainRunner } from '@positronic/core';
1684
1768
  import { z } from 'zod';
1685
1769
 
1686
- // Define services
1687
- interface Services {
1688
- logger: Logger;
1689
- analytics: {
1690
- track: (event: string, properties?: any) => void;
1691
- };
1692
- }
1693
-
1694
1770
  // Create brain with all features
1695
1771
  const completeBrain = brain({
1696
1772
  title: 'Complete Example',
1697
1773
  description: 'Demonstrates all Brain DSL features',
1698
1774
  })
1699
- .withServices<Services>({
1700
- logger: console,
1701
- analytics: {
1702
- track: (event, props) => console.log('Track:', event, props)
1703
- }
1704
- })
1775
+ .withPlugin(logger)
1776
+ .withPlugin(analytics)
1705
1777
  .step('Initialize', ({ logger, analytics }) => {
1706
1778
  logger.log('Starting workflow');
1707
1779
  analytics.track('brain_started');
1708
1780
  return { startTime: Date.now() };
1709
1781
  })
1710
1782
  .prompt('Generate Plan', {
1711
- template: async ({ state, resources }) => {
1783
+ message: async ({ state, resources }) => {
1712
1784
  // Load a template from resources
1713
1785
  const template = await resources.templates.projectPlan.load();
1714
1786
  return template.replace('{{context}}', 'software project');
1715
1787
  },
1716
- outputSchema: {
1717
- schema: z.object({
1718
- tasks: z.array(z.string()),
1719
- duration: z.number(),
1720
- }),
1721
- name: 'plan' as const,
1722
- },
1788
+ outputSchema: z.object({
1789
+ tasks: z.array(z.string()),
1790
+ duration: z.number(),
1791
+ }),
1723
1792
  })
1724
1793
  .step('Process Plan', ({ state, logger, analytics }) => {
1725
- logger.log(`Plan generated with <%= '${state.plan.tasks.length}' %> tasks`);
1794
+ logger.log(`Plan generated with <%= '${state.tasks.length}' %> tasks`);
1726
1795
  analytics.track('plan_processed', {
1727
- task_count: state.plan.tasks.length,
1728
- duration: state.plan.duration
1796
+ task_count: state.tasks.length,
1797
+ duration: state.duration
1729
1798
  });
1730
1799
  return {
1731
1800
  ...state,
1732
- taskCount: state.plan.tasks.length,
1801
+ taskCount: state.tasks.length,
1733
1802
  endTime: Date.now(),
1734
1803
  };
1735
1804
  });
@@ -1743,3 +1812,85 @@ const runner = new BrainRunner({
1743
1812
  const finalState = await runner.run(completeBrain);
1744
1813
  console.log('Completed:', finalState);
1745
1814
  ```
1815
+
1816
+ ## Files Service
1817
+
1818
+ The `files` service is available on the step context for creating, reading, and managing files.
1819
+
1820
+ ### Basic Usage
1821
+
1822
+ ```typescript
1823
+ .step("Save report", async ({ files }) => {
1824
+ const file = files.open('report.txt');
1825
+ await file.write('Report content');
1826
+ const url = file.url; // public download URL
1827
+ return { reportFile: file.name }; // store name in state, not URL
1828
+ })
1829
+ ```
1830
+
1831
+ ### Streaming Writes
1832
+
1833
+ ```typescript
1834
+ // Stream from a URL — never buffered
1835
+ await file.write(await fetch('https://example.com/large.mp3'));
1836
+
1837
+ // Copy between files
1838
+ await file.write(files.open('source.txt'));
1839
+ ```
1840
+
1841
+ ### Zip Builder
1842
+
1843
+ ```typescript
1844
+ .step("Bundle", async ({ state, files }) => {
1845
+ const zip = files.zip('results.zip');
1846
+ await zip.write('data.txt', 'content');
1847
+ await zip.write('audio.mp3', await fetch(state.mp3Url));
1848
+ const ref = await zip.finalize();
1849
+ return { downloadUrl: files.open(ref.name).url };
1850
+ })
1851
+ ```
1852
+
1853
+ ### Scoping
1854
+
1855
+ ```typescript
1856
+ files.open('data.txt'); // 'brain' (default) — persists across runs
1857
+ files.open('temp.txt', { scope: 'run' }); // cleaned up after run
1858
+ files.open('profile.json', { scope: 'global' }); // persists across brains
1859
+ ```
1860
+
1861
+ ### JSX Components
1862
+
1863
+ ```tsx
1864
+ import { File, Resource } from '@positronic/core';
1865
+
1866
+ .prompt("Analyze", ({ state }) => ({
1867
+ prompt: (
1868
+ <>
1869
+ <Resource name="guidelines" />
1870
+ <File name={state.transcriptFile} />
1871
+ </>
1872
+ ),
1873
+ outputSchema: z.object({ summary: z.string() }),
1874
+ }))
1875
+ ```
1876
+
1877
+ ### Attachments
1878
+
1879
+ ```typescript
1880
+ .prompt("Analyze PDF", async ({ files }) => ({
1881
+ prompt: "Analyze the attached document.",
1882
+ attachments: [files.open('report.pdf')],
1883
+ outputSchema: z.object({ summary: z.string() }),
1884
+ }))
1885
+ ```
1886
+
1887
+ ### Agent Tools
1888
+
1889
+ ```typescript
1890
+ import { readFile, writeFile } from '@positronic/core';
1891
+
1892
+ .brain("Analyze", () => ({
1893
+ prompt: "Review the files and write a summary.",
1894
+ tools: { readFile, writeFile },
1895
+ }))
1896
+ ```