@positronic/template-new-project 0.0.76 → 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.
- package/index.js +5 -4
- package/package.json +1 -1
- package/template/.positronic/build-brains.mjs +93 -0
- package/template/.positronic/bundle.ts +1 -1
- package/template/.positronic/src/index.ts +6 -1
- package/template/.positronic/wrangler.jsonc +4 -0
- package/template/CLAUDE.md +149 -50
- package/template/docs/brain-dsl-guide.md +661 -510
- package/template/docs/brain-testing-guide.md +63 -3
- package/template/docs/memory-guide.md +116 -100
- package/template/docs/plugin-guide.md +218 -0
- package/template/docs/positronic-guide.md +99 -78
- package/template/docs/tips-for-agents.md +179 -79
- package/template/src/brain.ts +73 -0
- package/template/src/brains/hello.ts +46 -0
- package/template/{runner.ts → src/runner.ts} +9 -12
- package/template/tests/example.test.ts +1 -1
- package/template/tests/test-utils.ts +1 -4
- package/template/tsconfig.json +4 -2
- package/template/brain.ts +0 -96
- package/template/brains/hello.ts +0 -44
- /package/template/{brains → src/brains}/example.ts +0 -0
- /package/template/{components → src/components}/index.ts +0 -0
- /package/template/{utils → src/utils}/bottleneck.ts +0 -0
- /package/template/{webhooks → src/webhooks}/.gitkeep +0 -0
|
@@ -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 `
|
|
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
|
-
.
|
|
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
|
-
|
|
84
|
+
message: ({ state: { topic, context } }) =>
|
|
85
85
|
`<%= '${context}' %>. Please provide a brief, beginner-friendly explanation of <%= '${topic}' %>.`,
|
|
86
|
-
outputSchema: {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
100
|
-
summary: `This explanation covers <%= '${state.
|
|
101
|
-
points: state.
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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 `
|
|
132
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
`Analyze the implications of this summary: <%= '${
|
|
160
|
-
outputSchema: {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
.
|
|
208
|
-
|
|
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
|
|
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 `
|
|
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
|
|
250
|
+
// Use withOptions to add runtime validation
|
|
267
251
|
const notificationBrain = brain('Notification Brain')
|
|
268
|
-
.
|
|
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
|
|
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
|
-
- **
|
|
317
|
-
- Configure once with `.
|
|
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
|
-
.
|
|
340
|
-
.
|
|
341
|
-
|
|
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
|
-
###
|
|
368
|
+
### Plugin Injection
|
|
387
369
|
|
|
388
|
-
The `
|
|
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
|
-
|
|
394
|
-
logger
|
|
395
|
-
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
|
|
385
|
+
#### Where Plugin Values Are Available
|
|
408
386
|
|
|
409
|
-
|
|
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
|
-
|
|
423
|
-
outputSchema:
|
|
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,
|
|
404
|
+
await database.save({ ...state, ...response });
|
|
427
405
|
return state;
|
|
428
406
|
})
|
|
429
407
|
```
|
|
430
408
|
|
|
431
|
-
3. **Nested Brain
|
|
409
|
+
3. **Nested Brain Config**:
|
|
432
410
|
```typescript
|
|
433
|
-
.brain('Run Sub-Brain', subBrain
|
|
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
|
-
//
|
|
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
|
-
.
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
481
|
-
outputSchema: {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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',
|
|
447
|
+
await cache.set('analysis_result', { insights, confidence });
|
|
492
448
|
|
|
493
449
|
// Submit to API
|
|
494
|
-
await api.submitResult(
|
|
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:
|
|
500
|
-
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
|
|
464
|
+
#### Testing with Plugins
|
|
509
465
|
|
|
510
|
-
|
|
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
|
-
.
|
|
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
|
|
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 `
|
|
548
|
-
-
|
|
549
|
-
-
|
|
550
|
-
- Each brain instance maintains its own
|
|
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
|
|
509
|
+
### Tool-Calling Prompt Loops
|
|
553
510
|
|
|
554
|
-
|
|
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
|
-
|
|
560
|
-
.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
573
|
-
return response.json();
|
|
574
|
-
}
|
|
549
|
+
},
|
|
575
550
|
},
|
|
576
|
-
|
|
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 `.
|
|
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
|
-
.
|
|
633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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 '
|
|
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.
|
|
1014
|
+
selectedArticles: state.selectedArticles.map(
|
|
1063
1015
|
i => state.articles[i]
|
|
1064
1016
|
),
|
|
1065
|
-
reasoning: state.
|
|
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
|
|
1024
|
+
- The message is more than 2-3 lines
|
|
1073
1025
|
- The prompt uses complex logic or formatting
|
|
1074
|
-
- You need to load resources
|
|
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
|
-
##
|
|
1030
|
+
## JSX Templates
|
|
1079
1031
|
|
|
1080
|
-
|
|
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
|
-
###
|
|
1034
|
+
### Basic Usage
|
|
1083
1035
|
|
|
1084
|
-
|
|
1036
|
+
Rename your brain file from `.ts` to `.tsx` and return JSX from the message function:
|
|
1085
1037
|
|
|
1086
|
-
```
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
1135
|
+
### Async Components
|
|
1116
1136
|
|
|
1117
|
-
|
|
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
|
-
.
|
|
1176
|
+
.map('Process Each', 'results', {
|
|
1177
|
+
run: processBrain,
|
|
1133
1178
|
over: ({ state }) => state.items,
|
|
1134
|
-
initialState: (item) => ({ value: item.value }),
|
|
1135
|
-
|
|
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
|
-
###
|
|
1189
|
+
### Iterating a Prompt
|
|
1146
1190
|
|
|
1147
|
-
|
|
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
|
-
.
|
|
1155
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
1249
|
+
### `.map()` Options
|
|
1176
1250
|
|
|
1177
|
-
|
|
1251
|
+
`.map()` has two modes: **brain mode** (run an inner brain per item) and **prompt mode** (run a prompt per item).
|
|
1178
1252
|
|
|
1179
|
-
|
|
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
|
-
|
|
1255
|
+
**Common options** (both modes):
|
|
1183
1256
|
|
|
1184
|
-
- `
|
|
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
|
|
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
|
-
|
|
1265
|
+
**Prompt mode** (use `prompt: { message, outputSchema }`):
|
|
1191
1266
|
|
|
1192
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
1205
|
-
|
|
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
|
-
|
|
1310
|
+
The key always comes from `stateKey`.
|
|
1228
1311
|
|
|
1229
|
-
##
|
|
1312
|
+
## Prompt Steps with Tool-Calling Loops
|
|
1230
1313
|
|
|
1231
|
-
For complex AI workflows that require tool use, use
|
|
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
|
-
.
|
|
1321
|
+
.prompt('Research', ({ state }) => ({
|
|
1239
1322
|
system: 'You are a helpful research assistant with access to search tools.',
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1264
|
-
})
|
|
1265
|
-
.step('Format Results', ({ state, brainState }) => ({
|
|
1352
|
+
}))
|
|
1353
|
+
.step('Format Results', ({ state }) => ({
|
|
1266
1354
|
...state,
|
|
1267
|
-
researchResults:
|
|
1355
|
+
researchResults: state.summary,
|
|
1268
1356
|
}));
|
|
1269
1357
|
```
|
|
1270
1358
|
|
|
1271
|
-
###
|
|
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
|
-
|
|
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
|
|
1286
|
-
- `terminal?: boolean` - If true, calling this tool ends the
|
|
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
|
|
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
|
-
.
|
|
1387
|
+
.prompt('Handle Request', ({ state }) => ({
|
|
1297
1388
|
system: 'You are a support agent. Escalate complex issues for human review.',
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
|
1408
|
+
}))
|
|
1409
|
+
.step('Process Result', ({ state }) => ({
|
|
1326
1410
|
...state,
|
|
1327
|
-
|
|
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
|
|
1335
|
-
- The webhook response is
|
|
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
|
-
###
|
|
1421
|
+
### Output Schema
|
|
1342
1422
|
|
|
1343
|
-
|
|
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
|
-
.
|
|
1427
|
+
.prompt('Extract Entities', () => ({
|
|
1348
1428
|
system: 'You are an entity extraction assistant.',
|
|
1349
|
-
|
|
1350
|
-
outputSchema: {
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
|
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.
|
|
1366
|
-
state.
|
|
1441
|
+
summary: 'Extracted ' + state.people.length + ' people and ' +
|
|
1442
|
+
state.organizations.length + ' organizations',
|
|
1367
1443
|
};
|
|
1368
1444
|
});
|
|
1369
1445
|
```
|
|
1370
1446
|
|
|
1371
|
-
Key points
|
|
1372
|
-
- The
|
|
1373
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1520
|
+
Rename your brain file to `.tsx` and use JSX to build the page:
|
|
1447
1521
|
|
|
1448
|
-
```
|
|
1449
|
-
import {
|
|
1450
|
-
import
|
|
1522
|
+
```tsx
|
|
1523
|
+
import { z } from 'zod';
|
|
1524
|
+
import { Form } from '@positronic/core';
|
|
1451
1525
|
|
|
1452
1526
|
brain('Archive Workflow')
|
|
1453
|
-
.step('
|
|
1454
|
-
|
|
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
|
-
.
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1583
|
+
Then use it in the brain:
|
|
1479
1584
|
|
|
1480
|
-
```
|
|
1481
|
-
import {
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
1618
|
+
## Page Steps
|
|
1522
1619
|
|
|
1523
|
-
|
|
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
|
|
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
|
-
.
|
|
1537
|
-
|
|
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
|
-
|
|
1638
|
+
formSchema: z.object({
|
|
1542
1639
|
rating: z.number().min(1).max(5),
|
|
1543
1640
|
comments: z.string(),
|
|
1544
1641
|
}),
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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
|
-
|
|
1558
|
-
comments: response.comments,
|
|
1650
|
+
// state.rating and state.comments are typed
|
|
1559
1651
|
}));
|
|
1560
1652
|
```
|
|
1561
1653
|
|
|
1562
|
-
### How
|
|
1654
|
+
### How Page Steps Work
|
|
1563
1655
|
|
|
1564
|
-
1. **
|
|
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. **
|
|
1567
|
-
4. **
|
|
1568
|
-
5. **
|
|
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
|
-
|
|
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
|
-
###
|
|
1668
|
+
### Prompt Best Practices
|
|
1578
1669
|
|
|
1579
1670
|
Be specific about layout and content:
|
|
1580
1671
|
|
|
1581
1672
|
```typescript
|
|
1582
|
-
.
|
|
1583
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
1607
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
.
|
|
1630
|
-
|
|
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
|
-
|
|
1727
|
+
formSchema: z.object({
|
|
1637
1728
|
firstName: z.string(),
|
|
1638
1729
|
lastName: z.string(),
|
|
1639
1730
|
dob: z.string(),
|
|
1640
1731
|
}),
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
.
|
|
1654
|
-
|
|
1655
|
-
Create preferences form for <%= '${state.
|
|
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
|
-
|
|
1746
|
+
formSchema: z.object({
|
|
1661
1747
|
newsletter: z.boolean(),
|
|
1662
1748
|
contactMethod: z.enum(['email', 'phone', 'sms']),
|
|
1663
1749
|
}),
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
.
|
|
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
|
|
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
|
-
.
|
|
1700
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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.
|
|
1794
|
+
logger.log(`Plan generated with <%= '${state.tasks.length}' %> tasks`);
|
|
1726
1795
|
analytics.track('plan_processed', {
|
|
1727
|
-
task_count: state.
|
|
1728
|
-
duration: state.
|
|
1796
|
+
task_count: state.tasks.length,
|
|
1797
|
+
duration: state.duration
|
|
1729
1798
|
});
|
|
1730
1799
|
return {
|
|
1731
1800
|
...state,
|
|
1732
|
-
taskCount: state.
|
|
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
|
+
```
|