@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.
- 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 +157 -95
- 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
package/index.js
CHANGED
|
@@ -53,10 +53,10 @@ module.exports = {
|
|
|
53
53
|
],
|
|
54
54
|
setup: async ctx => {
|
|
55
55
|
const devRootPath = process.env.POSITRONIC_LOCAL_PATH;
|
|
56
|
-
let coreVersion = '^0.0.
|
|
57
|
-
let cloudflareVersion = '^0.0.
|
|
58
|
-
let clientVercelVersion = '^0.0.
|
|
59
|
-
let genUIComponentsVersion = '^0.0.
|
|
56
|
+
let coreVersion = '^0.0.78';
|
|
57
|
+
let cloudflareVersion = '^0.0.78';
|
|
58
|
+
let clientVercelVersion = '^0.0.78';
|
|
59
|
+
let genUIComponentsVersion = '^0.0.78';
|
|
60
60
|
|
|
61
61
|
// Map backend selection to package names
|
|
62
62
|
const backendPackageMap = {
|
|
@@ -153,6 +153,7 @@ module.exports = {
|
|
|
153
153
|
if (!ctx.answers.claudemd) {
|
|
154
154
|
ctx.files = ctx.files.filter(file => file.path !== 'CLAUDE.md');
|
|
155
155
|
}
|
|
156
|
+
|
|
156
157
|
},
|
|
157
158
|
complete: async ctx => {
|
|
158
159
|
// Display getting started message
|
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-compiles brain files from src/brains/ to .positronic/brains/.
|
|
3
|
+
*
|
|
4
|
+
* For .tsx files, an esbuild plugin preserves JSX text whitespace by wrapping
|
|
5
|
+
* JSXText nodes in expression containers before the JSX transform runs.
|
|
6
|
+
* For .ts files, esbuild simply transpiles TypeScript to JavaScript.
|
|
7
|
+
*
|
|
8
|
+
* The compiled output lives in .positronic/brains/ and resolves its relative
|
|
9
|
+
* imports (../brain.js, ../services/...) through symlinks that mirror src/.
|
|
10
|
+
*/
|
|
11
|
+
import * as esbuild from 'esbuild';
|
|
12
|
+
import ts from 'typescript';
|
|
13
|
+
import { readdirSync, promises as fs } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
|
|
16
|
+
// Esbuild plugin that preserves whitespace in JSX text nodes.
|
|
17
|
+
// Uses TypeScript's parser to find JSXText nodes and wrap them in
|
|
18
|
+
// expression containers ({`text`}) before esbuild's JSX transform runs.
|
|
19
|
+
function jsxTextPlugin() {
|
|
20
|
+
return {
|
|
21
|
+
name: 'jsx-text-preserver',
|
|
22
|
+
setup(build) {
|
|
23
|
+
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
|
|
24
|
+
const source = await fs.readFile(args.path, 'utf8');
|
|
25
|
+
const sourceFile = ts.createSourceFile(
|
|
26
|
+
args.path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const replacements = [];
|
|
30
|
+
function visit(node) {
|
|
31
|
+
if (node.kind === ts.SyntaxKind.JsxText) {
|
|
32
|
+
const rawText = node.getText(sourceFile);
|
|
33
|
+
const escaped = rawText
|
|
34
|
+
.replace(/\\/g, '\\\\')
|
|
35
|
+
.replace(/`/g, '\\`')
|
|
36
|
+
.replace(/\$/g, '\\$');
|
|
37
|
+
replacements.push({
|
|
38
|
+
start: node.getStart(sourceFile),
|
|
39
|
+
end: node.getEnd(),
|
|
40
|
+
text: `{\`<%= '${escaped}' %>\`}`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
ts.forEachChild(node, visit);
|
|
44
|
+
}
|
|
45
|
+
visit(sourceFile);
|
|
46
|
+
|
|
47
|
+
if (replacements.length === 0) return { contents: source, loader: 'tsx' };
|
|
48
|
+
|
|
49
|
+
let result = source;
|
|
50
|
+
for (const r of replacements.reverse()) {
|
|
51
|
+
result = result.slice(0, r.start) + r.text + result.slice(r.end);
|
|
52
|
+
}
|
|
53
|
+
return { contents: result, loader: 'tsx' };
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Find all .ts and .tsx files recursively
|
|
60
|
+
function findBrainFiles(dir) {
|
|
61
|
+
const results = [];
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
65
|
+
} catch {
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (entry.name.startsWith('_')) continue;
|
|
70
|
+
const fullPath = join(dir, entry.name);
|
|
71
|
+
if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
|
|
72
|
+
results.push(fullPath);
|
|
73
|
+
} else if (entry.isDirectory()) {
|
|
74
|
+
results.push(...findBrainFiles(fullPath));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return results;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const brainFiles = findBrainFiles('../src/brains');
|
|
81
|
+
|
|
82
|
+
if (brainFiles.length > 0) {
|
|
83
|
+
await esbuild.build({
|
|
84
|
+
entryPoints: brainFiles,
|
|
85
|
+
outdir: 'brains',
|
|
86
|
+
format: 'esm',
|
|
87
|
+
jsx: 'automatic',
|
|
88
|
+
jsxImportSource: '@positronic/core',
|
|
89
|
+
plugins: [jsxTextPlugin()],
|
|
90
|
+
bundle: false,
|
|
91
|
+
allowOverwrite: true,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* When you add custom components to components/index.ts, they will automatically
|
|
8
8
|
* be included in the bundle.
|
|
9
9
|
*/
|
|
10
|
-
import { components } from '
|
|
10
|
+
import { components } from './components/index.js';
|
|
11
11
|
|
|
12
12
|
// Extract the React component from each UIComponent and expose to window
|
|
13
13
|
const PositronicComponents: Record<string, React.ComponentType<any>> = {};
|
|
@@ -10,21 +10,26 @@ import {
|
|
|
10
10
|
AuthDO,
|
|
11
11
|
PositronicManifest,
|
|
12
12
|
} from "@positronic/cloudflare";
|
|
13
|
+
import { collectPluginWebhooks } from "@positronic/core";
|
|
13
14
|
// Import the generated manifests - NOTE the .js extension for runtime compatibility
|
|
14
15
|
// @ts-expect-error - _manifest.js is generated during template processing
|
|
15
16
|
import { manifest as brainManifest } from "./_manifest.js";
|
|
16
17
|
// @ts-expect-error - _webhookManifest.js is generated during template processing
|
|
17
18
|
import { webhookManifest } from "./_webhookManifest.js";
|
|
18
19
|
import { runner } from "./runner.js";
|
|
20
|
+
import { brain as brainFactory } from "../brain.js";
|
|
19
21
|
|
|
20
22
|
// Configure the manifest to use the statically generated list
|
|
21
23
|
const manifest = new PositronicManifest({
|
|
22
24
|
manifest: brainManifest,
|
|
23
25
|
});
|
|
24
26
|
|
|
27
|
+
// Merge file-based webhooks with plugin-declared webhooks
|
|
28
|
+
const pluginWebhooks = collectPluginWebhooks(brainFactory.plugins);
|
|
29
|
+
|
|
25
30
|
setManifest(manifest);
|
|
26
31
|
setBrainRunner(runner);
|
|
27
|
-
setWebhookManifest(webhookManifest);
|
|
32
|
+
setWebhookManifest({ ...webhookManifest, ...pluginWebhooks });
|
|
28
33
|
|
|
29
34
|
// Define Env type based on wrangler.jsonc bindings
|
|
30
35
|
interface Env {
|
package/template/CLAUDE.md
CHANGED
|
@@ -8,13 +8,16 @@ This is a Positronic project - an AI-powered framework for building and running
|
|
|
8
8
|
|
|
9
9
|
## Project Structure
|
|
10
10
|
|
|
11
|
-
- **`/
|
|
12
|
-
- **`/
|
|
11
|
+
- **`/src`** - Application source code
|
|
12
|
+
- **`/src/brain.ts`** - Project brain wrapper (custom `brain` function)
|
|
13
|
+
- **`/src/brains`** - AI workflow definitions using the Brain DSL
|
|
14
|
+
- **`/src/runner.ts`** - The main entry point for running brains locally
|
|
15
|
+
- **`/src/utils`** - Shared utilities (e.g., `bottleneck` for rate limiting)
|
|
16
|
+
- **`/src/plugins`** - Plugin definitions for external integrations (see `/docs/plugin-guide.md`)
|
|
17
|
+
- **`/src/components`** - Reusable UI/prompt components
|
|
13
18
|
- **`/resources`** - Files and documents that brains can access via the resource system
|
|
14
19
|
- **`/tests`** - Test files for brains (kept separate to avoid deployment issues)
|
|
15
|
-
- **`/utils`** - Shared utilities (e.g., `bottleneck` for rate limiting)
|
|
16
20
|
- **`/docs`** - Documentation including brain testing guide
|
|
17
|
-
- **`/runner.ts`** - The main entry point for running brains locally
|
|
18
21
|
- **`/positronic.config.json`** - Project configuration
|
|
19
22
|
|
|
20
23
|
## Key Commands
|
|
@@ -40,6 +43,7 @@ The Brain DSL provides a fluent API for defining AI workflows:
|
|
|
40
43
|
|
|
41
44
|
```typescript
|
|
42
45
|
// Import from the project brain wrapper (see positronic-guide.md)
|
|
46
|
+
// From a file in src/brains/, brain.ts is at src/brain.ts
|
|
43
47
|
import { brain } from '../brain.js';
|
|
44
48
|
|
|
45
49
|
const myBrain = brain('my-brain')
|
|
@@ -60,88 +64,183 @@ const myBrain = brain('my-brain')
|
|
|
60
64
|
export default myBrain;
|
|
61
65
|
```
|
|
62
66
|
|
|
67
|
+
### JSX Templates
|
|
68
|
+
|
|
69
|
+
Templates in `.prompt()`, `.page()`, and `.map()` steps can be written as JSX for better readability. Rename the brain file to `.tsx` and return JSX from the template function. See `/docs/brain-dsl-guide.md` for details.
|
|
70
|
+
|
|
63
71
|
## Resource System
|
|
64
72
|
|
|
65
73
|
Resources are files that brains can access during execution. They're stored in the `/resources` directory and are automatically typed based on the manifest.
|
|
66
74
|
|
|
67
75
|
## Webhooks
|
|
68
76
|
|
|
69
|
-
Webhooks allow brains to pause execution and wait for external events (like
|
|
77
|
+
Webhooks allow brains to pause execution and wait for external events (like Slack replies or GitHub events). Webhooks live inside plugins, not in a separate directory.
|
|
78
|
+
|
|
79
|
+
### Page Forms vs Service Webhooks
|
|
80
|
+
|
|
81
|
+
**Page forms** (user submits a form on a page generated by your brain) are handled automatically by `.page()` with `formSchema`. No webhook code needed — see "Custom HTML Pages" below and `/docs/brain-dsl-guide.md`.
|
|
70
82
|
|
|
71
|
-
|
|
83
|
+
**Service webhooks** (external services like Slack or GitHub calling back into your app) are declared on plugins. The plugin defines the webhook handler and exposes it for brains to use in `.wait()` steps or as tools in prompt loops.
|
|
72
84
|
|
|
73
|
-
|
|
85
|
+
### Using Plugin Webhooks in Brains
|
|
86
|
+
|
|
87
|
+
Plugins that declare webhooks make them available on the step context:
|
|
74
88
|
|
|
75
89
|
```typescript
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
export default brain('feedback-loop')
|
|
91
|
+
.step('Post draft', async ({ state, slack }) => {
|
|
92
|
+
const result = await slack.sendMessage(channel, state.draft);
|
|
93
|
+
return { ...state, threadTs: result.ts };
|
|
94
|
+
})
|
|
95
|
+
.wait('Wait for feedback', ({ state, slack }) => slack.webhooks.slack(state.threadTs))
|
|
96
|
+
.handle('Apply feedback', ({ state, response }) => ({
|
|
97
|
+
...state,
|
|
98
|
+
feedback: response.message.text,
|
|
99
|
+
}));
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The optional `timeout` parameter accepts durations like `'30m'`, `'1h'`, `'24h'`, `'7d'`, or a number in milliseconds. If the timeout elapses without a webhook response, the brain is cancelled. Without a timeout, the brain waits indefinitely.
|
|
103
|
+
|
|
104
|
+
### Declaring Webhooks in Plugins
|
|
105
|
+
|
|
106
|
+
Webhooks are declared on the plugin definition using `createWebhook()` and returned from `create()` so brains can access them:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { definePlugin, createWebhook } from '@positronic/core';
|
|
78
110
|
import { z } from 'zod';
|
|
79
111
|
|
|
80
|
-
const
|
|
81
|
-
'
|
|
82
|
-
z.object({
|
|
83
|
-
approved: z.boolean(),
|
|
84
|
-
reviewerNote: z.string().optional(),
|
|
85
|
-
}),
|
|
112
|
+
const myWebhook = createWebhook(
|
|
113
|
+
'my-service',
|
|
114
|
+
z.object({ message: z.string() }),
|
|
86
115
|
async (request: Request) => {
|
|
87
|
-
// Parse the incoming request and return identifier + response
|
|
88
116
|
const body = await request.json();
|
|
89
117
|
return {
|
|
90
118
|
type: 'webhook',
|
|
91
|
-
identifier: body.
|
|
92
|
-
response: {
|
|
93
|
-
approved: body.approved,
|
|
94
|
-
reviewerNote: body.note,
|
|
95
|
-
},
|
|
119
|
+
identifier: body.threadId,
|
|
120
|
+
response: { message: body.text },
|
|
96
121
|
};
|
|
97
122
|
}
|
|
98
123
|
);
|
|
99
124
|
|
|
100
|
-
export
|
|
125
|
+
export const myService = definePlugin({
|
|
126
|
+
name: 'myService',
|
|
127
|
+
webhooks: { myWebhook },
|
|
128
|
+
create: () => ({
|
|
129
|
+
webhooks: { myWebhook },
|
|
130
|
+
// ... other methods and tools
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
101
133
|
```
|
|
102
134
|
|
|
103
|
-
|
|
135
|
+
Plugin webhooks are automatically registered in the webhook manifest at startup.
|
|
136
|
+
|
|
137
|
+
### Webhook Handler Return Types
|
|
138
|
+
|
|
139
|
+
The handler return type determines behavior:
|
|
140
|
+
- `{ type: 'webhook', identifier, response }` — resumes a waiting brain
|
|
141
|
+
- `{ type: 'trigger', response }` — starts a new brain run (requires `triggers` config)
|
|
142
|
+
- `{ type: 'ignore' }` — acknowledges receipt, takes no action
|
|
143
|
+
- `{ type: 'verification', challenge }` — handles webhook verification challenges
|
|
144
|
+
|
|
145
|
+
### Custom HTML Pages
|
|
146
|
+
|
|
147
|
+
For pages with forms, use `.page()` with the `html` property instead of `prompt`. The framework handles CSRF tokens, webhook registration, suspension, and form data merging automatically:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
import { Form } from '@positronic/core';
|
|
151
|
+
|
|
152
|
+
.page('Review items', ({ state }) => ({
|
|
153
|
+
html: (
|
|
154
|
+
<Form>
|
|
155
|
+
{state.items.map(item => (
|
|
156
|
+
<label>
|
|
157
|
+
<input type="checkbox" name="selectedIds" value={item.id} />
|
|
158
|
+
{item.name}
|
|
159
|
+
</label>
|
|
160
|
+
))}
|
|
161
|
+
<button type="submit">Confirm</button>
|
|
162
|
+
</Form>
|
|
163
|
+
),
|
|
164
|
+
formSchema: z.object({ selectedIds: z.array(z.string()) }),
|
|
165
|
+
onCreated: async (page) => {
|
|
166
|
+
await ntfy.send('Review ready', page.url);
|
|
167
|
+
},
|
|
168
|
+
}))
|
|
169
|
+
.step('Process', ({ state }) => {
|
|
170
|
+
// state.selectedIds comes from the form submission
|
|
171
|
+
return { ...state, confirmed: true };
|
|
172
|
+
})
|
|
173
|
+
```
|
|
104
174
|
|
|
105
|
-
|
|
175
|
+
**Key details:**
|
|
176
|
+
|
|
177
|
+
- `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 into it during rendering.
|
|
178
|
+
- The `html` property accepts JSX directly, a string, or a function component.
|
|
179
|
+
- The page title comes from the step title. The framework wraps the html body in a full HTML document automatically.
|
|
180
|
+
|
|
181
|
+
### Extracting Page JSX into Separate Files
|
|
182
|
+
|
|
183
|
+
For complex pages, extract the JSX into a separate `.tsx` file. Don't annotate the return type — let TypeScript infer it from the JSX:
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
// brains/my-brain/pages/review-page.tsx
|
|
187
|
+
import { Form } from '@positronic/core';
|
|
188
|
+
export function ReviewPage({ items }: { items: { id: string; name: string }[] }) {
|
|
189
|
+
return (
|
|
190
|
+
<Form>
|
|
191
|
+
{items.map(item => (
|
|
192
|
+
<label>
|
|
193
|
+
<input type="checkbox" name="selectedIds" value={item.id} />
|
|
194
|
+
{item.name}
|
|
195
|
+
</label>
|
|
196
|
+
))}
|
|
197
|
+
<button type="submit">Confirm</button>
|
|
198
|
+
</Form>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
```
|
|
106
202
|
|
|
107
|
-
|
|
108
|
-
import { brain } from '../brain.js';
|
|
109
|
-
import approvalWebhook from '../webhooks/approval.js';
|
|
203
|
+
Then use it in the brain:
|
|
110
204
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
.
|
|
117
|
-
|
|
118
|
-
status: response.approved ? 'approved' : 'rejected',
|
|
119
|
-
reviewerNote: response.reviewerNote,
|
|
120
|
-
}));
|
|
205
|
+
```tsx
|
|
206
|
+
import { ReviewPage } from './pages/review-page.js';
|
|
207
|
+
|
|
208
|
+
.page('Review items', ({ state }) => ({
|
|
209
|
+
html: <ReviewPage items={state.items} />,
|
|
210
|
+
formSchema: z.object({ selectedIds: z.array(z.string()) }),
|
|
211
|
+
}))
|
|
121
212
|
```
|
|
122
213
|
|
|
123
|
-
|
|
214
|
+
This works because the positronic JSX runtime handles function components — it calls them with their props and renders the result. The `.tsx` file must use `jsxImportSource: "@positronic/core"` (inherited from the project tsconfig).
|
|
215
|
+
|
|
216
|
+
**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 — these are different types. Let TypeScript infer the return type.
|
|
124
217
|
|
|
125
|
-
|
|
218
|
+
**HTML elements work in page JSX.** You can use `<div>`, `<input>`, `<label>`, `<table>`, `<style>`, etc. — any standard HTML element. This is specific to the `html` property on `.page()`. Prompt JSX (in `.prompt()`, `.map()`) only supports `<>` (Fragment), `<File>`, `<Resource>`, and function components — HTML elements will throw an error there.
|
|
126
219
|
|
|
127
|
-
|
|
220
|
+
See `/docs/brain-dsl-guide.md` for full examples.
|
|
128
221
|
|
|
129
|
-
|
|
130
|
-
2. Add `<input type="hidden" name="__positronic_token" value="<%= '${token}' %>">` to the form
|
|
131
|
-
3. Pass the token when creating the webhook registration: `myWebhook(identifier, token)`
|
|
222
|
+
### No JavaScript in Custom Pages
|
|
132
223
|
|
|
133
|
-
|
|
224
|
+
Custom HTML pages are static — inline `<script>` tags are blocked by a Content Security Policy (`script-src 'none'`). This prevents XSS when user data is interpolated into page content.
|
|
134
225
|
|
|
135
|
-
|
|
226
|
+
**Form submission doesn't need JS.** Native HTML forms work. Unchecked checkboxes don't submit — only checked ones do. The framework's `parseFormData` handles duplicate field names as arrays automatically.
|
|
227
|
+
|
|
228
|
+
**For interactive UI (tabs, toggles, show/hide), use `<details>`/`<summary>` instead of JavaScript:**
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
{categories.map(cat => (
|
|
232
|
+
<details open={cat === firstCategory}>
|
|
233
|
+
<summary>{cat.label} ({cat.count})</summary>
|
|
234
|
+
<ThreadList emails={cat.emails} />
|
|
235
|
+
</details>
|
|
236
|
+
))}
|
|
237
|
+
```
|
|
136
238
|
|
|
137
|
-
|
|
138
|
-
- Each file must have a default export using `createWebhook()`
|
|
139
|
-
- The dev server generates `_webhookManifest.ts` automatically
|
|
140
|
-
- Webhook name comes from the filename (e.g., `approval.ts` → `'approval'`)
|
|
239
|
+
This gives collapsible sections that work without JS. Style with CSS for a polished look.
|
|
141
240
|
|
|
142
241
|
## Development Workflow
|
|
143
242
|
|
|
144
|
-
1. Define your brain in `/brains`
|
|
243
|
+
1. Define your brain in `/src/brains`
|
|
145
244
|
2. Add any required resources to `/resources`
|
|
146
245
|
3. Run `px brain run <brain-name>` to test locally
|
|
147
246
|
4. Deploy using backend-specific commands
|