create-theokit 1.25.0 → 1.25.2
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/package.json +1 -1
- package/templates/_bot-preset/agents/publisher.ts +1 -1
- package/templates/_bot-preset/agents/researcher.ts +1 -1
- package/templates/default/.env.example +1 -1
- package/templates/default/CLAUDE.md +2 -2
- package/templates/default/README.md.tmpl +1 -1
- package/templates/default/agents/chat.ts +12 -4
- package/templates/default/docs/ARCHITECTURE.md +1 -1
- package/templates/default/docs/CUSTOMIZATION.md +1 -1
- package/templates/default/docs/ENVIRONMENT.md +11 -3
- package/templates/default/dot-claude/skills/theokit-agents/SKILL.md +46 -23
- package/templates/default/dot-claude/skills/theokit-config/SKILL.md +67 -57
- package/templates/default/dot-claude/skills/theokit-gateways/SKILL.md +30 -2
- package/templates/default/dot-claude/skills/theokit-routes/SKILL.md +51 -45
- package/templates/default/package.json.tmpl +1 -1
package/package.json
CHANGED
|
@@ -20,7 +20,7 @@ export const policy = 'public'
|
|
|
20
20
|
|
|
21
21
|
export default AgentBuilder.create()
|
|
22
22
|
.input(z.object({ instruction: z.string() }))
|
|
23
|
-
.model(process.env.LLM_MODEL ?? 'openai/gpt-4o-mini')
|
|
23
|
+
.model(process.env.LLM_MODEL ?? 'openrouter/openai/gpt-4o-mini')
|
|
24
24
|
.system(
|
|
25
25
|
'You turn research notes into published output. Read notes with read_notes, then publish. ' +
|
|
26
26
|
'Publishing is gated: a human approves it before it happens.',
|
|
@@ -19,7 +19,7 @@ export const policy = 'public'
|
|
|
19
19
|
|
|
20
20
|
export default AgentBuilder.create()
|
|
21
21
|
.input(z.object({ topic: z.string() }))
|
|
22
|
-
.model(process.env.LLM_MODEL ?? 'openai/gpt-4o-mini')
|
|
22
|
+
.model(process.env.LLM_MODEL ?? 'openrouter/openai/gpt-4o-mini')
|
|
23
23
|
.system(
|
|
24
24
|
'You research a topic and leave notes for the publisher bot. ' +
|
|
25
25
|
'Write what you find with write_note; read what you already know with read_notes. ' +
|
|
@@ -8,13 +8,13 @@ This project includes TheoKit-aware skills that activate automatically when you
|
|
|
8
8
|
|
|
9
9
|
| Skill | Triggers when editing | What it provides |
|
|
10
10
|
| ---------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
11
|
-
| theokit-routes | `server/routes/**` |
|
|
11
|
+
| theokit-routes | `server/routes/**` | the route() builder, Zod validation, HTTP methods, dynamic params |
|
|
12
12
|
| theokit-gateways | `server/routes/**`, `**/*webhook*` | handleChannelWebhook, the `@theokit/gateway-*` adapters, signature validation, the onMessage seam |
|
|
13
13
|
| theokit-agents | `**/*agent*`, `**/*tool*`, `**/*Agent*`, `**/*Tool*` | @Agent, @Tool, @Toolbox decorators, LLM integration |
|
|
14
14
|
| theokit-database | `**/*schema*`, `**/*db*`, `**/drizzle*`, `**/*migration*`, `**/*seed*` | Drizzle ORM, SQLite, schema patterns, migrations |
|
|
15
15
|
| theokit-frontend | `app/**` | File-based routing, layouts, theoFetch, useAgent |
|
|
16
16
|
| theokit-ui | `app/**`, `**/*Chat*`, `**/*Sidebar*`, `**/*Theme*` | @theokit/ui AI components: ChatThread, ChatMessage, ToolCallCard, theming (generic primitives like CodeBlock/Sidebar come from @usetheo/ui) |
|
|
17
|
-
| theokit-config | `theo.config*`, `**/*config*` |
|
|
17
|
+
| theokit-config | `theo.config*`, `**/*config*` | the config() builder, plugins, security, storage |
|
|
18
18
|
|
|
19
19
|
### Settings
|
|
20
20
|
|
|
@@ -56,7 +56,7 @@ your environment.
|
|
|
56
56
|
```ts
|
|
57
57
|
export default AgentBuilder.create()
|
|
58
58
|
.input(z.object({ message: z.string() }))
|
|
59
|
-
.model('openai/gpt-4o-mini')
|
|
59
|
+
.model('openrouter/openai/gpt-4o-mini')
|
|
60
60
|
.system(BASE_INSTRUCTIONS)
|
|
61
61
|
.tool(weatherTool)
|
|
62
62
|
.approval('send_notification', { question: 'Send this notification?' })
|
|
@@ -14,9 +14,17 @@ import { weatherTool } from './tools/weather.js'
|
|
|
14
14
|
* treats `prompts/ tools/ skills/ lib/ …` as semantic folders, so `agents/tools/weather.ts` never becomes
|
|
15
15
|
* a `/api/agents/tools/weather` endpoint. Add a second agent as another `agents/<name>.ts`.
|
|
16
16
|
*
|
|
17
|
-
* `@theokit/sdk` runs the agent; conversation turns auto-persist per session.
|
|
18
|
-
*
|
|
19
|
-
* provider
|
|
17
|
+
* `@theokit/sdk` runs the agent; conversation turns auto-persist per session.
|
|
18
|
+
*
|
|
19
|
+
* The FIRST segment of the model id picks the provider, and the key it needs follows from that:
|
|
20
|
+
* `openrouter/…` needs `OPENROUTER_API_KEY`, `anthropic/…` needs `ANTHROPIC_API_KEY`, `openai/…`
|
|
21
|
+
* needs `OPENAI_API_KEY`. There is no magic routing — a bare `openai/gpt-4o-mini` goes to OpenAI,
|
|
22
|
+
* not through a gateway, even with an OpenRouter key present. Reaching OpenAI's catalog THROUGH
|
|
23
|
+
* OpenRouter means naming the gateway: `openrouter/openai/gpt-4o-mini`, which is what this file
|
|
24
|
+
* declares, because `.env.example` asks for `OPENROUTER_API_KEY` (one key, many models —
|
|
25
|
+
* https://openrouter.ai/models).
|
|
26
|
+
*
|
|
27
|
+
* Change the id and the key changes with it. Both live in `.env` and here; nothing else to wire.
|
|
20
28
|
*/
|
|
21
29
|
/**
|
|
22
30
|
* Who may run this agent, and against which conversation (ADR 0001).
|
|
@@ -48,7 +56,7 @@ export default AgentBuilder.create()
|
|
|
48
56
|
// so setting it changed the model to exactly what it already was (#398, #408). One expression is
|
|
49
57
|
// cheaper than an override path through the framework, and it keeps the value visible in the file
|
|
50
58
|
// that decides it. The literal stays as the fallback: a scaffold has to run with no environment.
|
|
51
|
-
.model(process.env.LLM_MODEL ?? 'openai/gpt-4o-mini')
|
|
59
|
+
.model(process.env.LLM_MODEL ?? 'openrouter/openai/gpt-4o-mini')
|
|
52
60
|
.system(BASE_INSTRUCTIONS)
|
|
53
61
|
.tool(weatherTool)
|
|
54
62
|
.tool(currentTimeTool)
|
|
@@ -59,7 +59,7 @@ An agent that outgrows one file becomes a folder that co-locates its own composi
|
|
|
59
59
|
```ts
|
|
60
60
|
export default AgentBuilder.create()
|
|
61
61
|
.input(z.object({ message: z.string() }))
|
|
62
|
-
.model('openai/gpt-4o-mini')
|
|
62
|
+
.model('openrouter/openai/gpt-4o-mini')
|
|
63
63
|
.system(BASE_INSTRUCTIONS) // agents/prompts/instructions.ts
|
|
64
64
|
.tool(weatherTool) // agents/tools/weather.ts
|
|
65
65
|
.tool(currentTimeTool) // agents/tools/current-time.ts
|
|
@@ -17,7 +17,7 @@ Common changes, and where they go. See [ARCHITECTURE](./ARCHITECTURE.md) for the
|
|
|
17
17
|
|
|
18
18
|
```ts
|
|
19
19
|
// agents/tools/echo.ts
|
|
20
|
-
import { tool } from 'theokit/server'
|
|
20
|
+
import { tool } from 'theokit/server/define'
|
|
21
21
|
import { z } from 'zod'
|
|
22
22
|
|
|
23
23
|
export const echoTool = tool('echo')
|
|
@@ -8,9 +8,17 @@ Configuration lives in `.env` (copy `.env.example`). Nothing here is committed
|
|
|
8
8
|
| `ANTHROPIC_API_KEY` | one of these | Use Anthropic directly instead of OpenRouter. |
|
|
9
9
|
| `OPENAI_API_KEY` | one of these | Use OpenAI directly. |
|
|
10
10
|
|
|
11
|
-
The
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
The FIRST segment of the model id in `agents/chat.ts` picks the provider, and that decides which key
|
|
12
|
+
is needed. It is not a hint: `openai/gpt-4o-mini` goes to OpenAI and needs `OPENAI_API_KEY`, even
|
|
13
|
+
with an OpenRouter key present. Reaching another vendor's catalog THROUGH OpenRouter means naming
|
|
14
|
+
the gateway first — `openrouter/openai/gpt-4o-mini`, which is what the scaffold declares, matching
|
|
15
|
+
the key above. See <https://openrouter.ai/models> for the ids OpenRouter serves.
|
|
16
|
+
|
|
17
|
+
| Model id in `agents/chat.ts` | Key it needs |
|
|
18
|
+
| ------------------------------- | -------------------- |
|
|
19
|
+
| `openrouter/openai/gpt-4o-mini` | `OPENROUTER_API_KEY` |
|
|
20
|
+
| `anthropic/claude-sonnet-4-6` | `ANTHROPIC_API_KEY` |
|
|
21
|
+
| `openai/gpt-4o-mini` | `OPENAI_API_KEY` |
|
|
14
22
|
|
|
15
23
|
```bash
|
|
16
24
|
cp .env.example .env
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: theokit-agents
|
|
3
|
-
description: TheoKit agent/LLM integration — agents/*.ts convention (
|
|
3
|
+
description: TheoKit agent/LLM integration — agents/*.ts convention (AgentBuilder), the tool() builder, capabilities (advanced/DI), useAgent client hook
|
|
4
4
|
user-invocable: false
|
|
5
5
|
paths:
|
|
6
6
|
- '**/*agent*'
|
|
@@ -25,7 +25,7 @@ import { z } from 'zod'
|
|
|
25
25
|
|
|
26
26
|
export default defineAgent({
|
|
27
27
|
input: z.object({ message: z.string() }),
|
|
28
|
-
model: 'openai/gpt-4o-mini',
|
|
28
|
+
model: 'openrouter/openai/gpt-4o-mini',
|
|
29
29
|
system: 'You are a helpful assistant.',
|
|
30
30
|
})
|
|
31
31
|
```
|
|
@@ -33,8 +33,11 @@ export default defineAgent({
|
|
|
33
33
|
The endpoint streams the ai-sdk `UIMessageStream` that `useAgent` (client hook) consumes.
|
|
34
34
|
`@theokit/sdk` runs the agent; conversation turns auto-persist per session — the SDK owns storage.
|
|
35
35
|
|
|
36
|
-
**Provider resolution:**
|
|
37
|
-
|
|
36
|
+
**Provider resolution:** the FIRST segment of the model id picks the provider, and that decides
|
|
37
|
+
which key is needed — `openrouter/…` needs `OPENROUTER_API_KEY`, `anthropic/…` needs
|
|
38
|
+
`ANTHROPIC_API_KEY`, `openai/…` needs `OPENAI_API_KEY`. A bare vendor prefix is a selection of that
|
|
39
|
+
vendor, not a hint: reaching OpenAI's catalog through OpenRouter means naming the gateway first
|
|
40
|
+
(`openrouter/openai/gpt-4o-mini`). Set the matching key in `.env`.
|
|
38
41
|
|
|
39
42
|
## Advanced Surface — @Agent Decorator (DI / class-based)
|
|
40
43
|
|
|
@@ -46,7 +49,7 @@ When you need dependency injection or composition, build the agent from **capabi
|
|
|
46
49
|
import { applyCapabilities, AgentConfigCapability, ModelCapability } from '@theokit/agents'
|
|
47
50
|
|
|
48
51
|
export const assistantAgent = applyCapabilities([
|
|
49
|
-
new ModelCapability('openai/gpt-4o-mini'),
|
|
52
|
+
new ModelCapability('openrouter/openai/gpt-4o-mini'),
|
|
50
53
|
new AgentConfigCapability({
|
|
51
54
|
systemPrompt: 'You are a helpful assistant.',
|
|
52
55
|
maxIterations: 5,
|
|
@@ -55,32 +58,52 @@ export const assistantAgent = applyCapabilities([
|
|
|
55
58
|
// The framework runs the LLM loop via @theokit/sdk.
|
|
56
59
|
```
|
|
57
60
|
|
|
58
|
-
## Tools —
|
|
61
|
+
## Tools — `tool()`
|
|
59
62
|
|
|
60
|
-
Declare
|
|
61
|
-
|
|
63
|
+
Declare a tool with the `tool()` builder from `theokit/server/define`, then chain it onto the agent
|
|
64
|
+
with `.tool(…)`. It is the same API `agents/tools/weather.ts` in this project uses — read that file
|
|
65
|
+
for a working one.
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// agents/tools/current-time.ts
|
|
69
|
+
import { tool } from 'theokit/server/define'
|
|
70
|
+
import { z } from 'zod'
|
|
71
|
+
|
|
72
|
+
export const currentTimeTool = tool('current_time')
|
|
73
|
+
.describe('Return the current ISO timestamp')
|
|
74
|
+
.input(z.object({}))
|
|
75
|
+
.execute(async () => new Date().toISOString())
|
|
76
|
+
.build()
|
|
77
|
+
```
|
|
62
78
|
|
|
63
79
|
```typescript
|
|
64
80
|
// agents/chat.ts
|
|
65
|
-
import {
|
|
66
|
-
import { defineAgentTool } from 'theokit/server'
|
|
81
|
+
import { AgentBuilder } from '@theokit/agents'
|
|
67
82
|
import { z } from 'zod'
|
|
68
83
|
|
|
69
|
-
|
|
70
|
-
name: 'current_time',
|
|
71
|
-
description: 'Return the current ISO timestamp',
|
|
72
|
-
inputSchema: z.object({}),
|
|
73
|
-
handler: async () => new Date().toISOString(),
|
|
74
|
-
})
|
|
84
|
+
import { currentTimeTool } from './tools/current-time.js'
|
|
75
85
|
|
|
76
|
-
export default
|
|
77
|
-
input
|
|
78
|
-
model
|
|
79
|
-
system
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
export default AgentBuilder.create()
|
|
87
|
+
.input(z.object({ message: z.string() }))
|
|
88
|
+
.model('openrouter/openai/gpt-4o-mini')
|
|
89
|
+
.system('You are a helpful assistant.')
|
|
90
|
+
.tool(currentTimeTool)
|
|
91
|
+
.build()
|
|
82
92
|
```
|
|
83
93
|
|
|
94
|
+
A tool is pure metadata plus a handler: it describes a capability and does local or HTTP work, and
|
|
95
|
+
it NEVER calls an LLM — the agent decides when to invoke it.
|
|
96
|
+
|
|
97
|
+
**Import from `theokit/server/define`, not `theokit/server`.** The umbrella subpath still resolves
|
|
98
|
+
and prints a deprecation warning naming a removal release; every symbol lives under a domain
|
|
99
|
+
subpath (`define`, `auth`, `http`, `security`, …).
|
|
100
|
+
|
|
101
|
+
> **`defineAgentTool` does not exist.** Earlier versions of this skill taught it. The name is still
|
|
102
|
+
> declared in the published `.d.ts`, so an editor will autocomplete it and `tsc` will accept it —
|
|
103
|
+
> and there is no runtime export behind it on any subpath, so the call throws on the first request
|
|
104
|
+
> (usetheokit/theokit#542). If you find it in older code or in a generated snippet, replace it with
|
|
105
|
+
> the `tool()` builder above.
|
|
106
|
+
|
|
84
107
|
### Toolbox class (advanced — state + injected dependencies)
|
|
85
108
|
|
|
86
109
|
A toolbox declares its tools as DATA and keeps handlers as ordinary methods, so the class can hold
|
|
@@ -174,7 +197,7 @@ Before writing custom tools, check if they already exist:
|
|
|
174
197
|
## Rules
|
|
175
198
|
|
|
176
199
|
- Tool `name` and `description` are ALWAYS explicit — never inferred from method names (G4)
|
|
177
|
-
- Tool
|
|
200
|
+
- Tool `.input()` takes a Zod schema — same pattern as `route().body(…)`
|
|
178
201
|
- `@UseGuards()` works on agents (shared with HTTP pipeline)
|
|
179
202
|
- `@UseInterceptors()` and `@UseFilters()` on agents are metadata-only (emit warnings)
|
|
180
203
|
- Agent runtime is `@theokit/sdk` — NEVER call LLM APIs directly via fetch
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: theokit-config
|
|
3
|
-
description: TheoKit configuration —
|
|
3
|
+
description: TheoKit configuration — the config() builder, plugins, security, storage, agents, build targets
|
|
4
4
|
user-invocable: false
|
|
5
5
|
paths:
|
|
6
6
|
- 'theo.config*'
|
|
@@ -12,37 +12,39 @@ paths:
|
|
|
12
12
|
## theo.config.ts
|
|
13
13
|
|
|
14
14
|
```typescript
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
export default
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
15
|
+
import { config } from 'theokit'
|
|
16
|
+
|
|
17
|
+
export default config()
|
|
18
|
+
.set({
|
|
19
|
+
// Basic
|
|
20
|
+
name: 'my-app', // DNS-1123 format (lowercase + hyphens)
|
|
21
|
+
port: 3000, // Dev + production port
|
|
22
|
+
|
|
23
|
+
// SSR (default: false)
|
|
24
|
+
ssr: false,
|
|
25
|
+
|
|
26
|
+
// Security (defaults are secure)
|
|
27
|
+
security: {
|
|
28
|
+
csrf: true, // CSRF protection (default: true)
|
|
29
|
+
csp: 'report-only', // Content Security Policy
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
// Agent runtime
|
|
33
|
+
agents: {
|
|
34
|
+
maxRegistries: 100,
|
|
35
|
+
registry: {
|
|
36
|
+
maxAgents: 100,
|
|
37
|
+
idleTimeoutMs: 30 * 60_000,
|
|
38
|
+
},
|
|
37
39
|
},
|
|
38
|
-
},
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
// DevTools overlay (dev only)
|
|
42
|
+
devtools: true,
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
})
|
|
44
|
+
// Plugins
|
|
45
|
+
plugins: [],
|
|
46
|
+
})
|
|
47
|
+
.build()
|
|
46
48
|
```
|
|
47
49
|
|
|
48
50
|
## Common Configuration Patterns
|
|
@@ -50,50 +52,58 @@ export default defineConfig({
|
|
|
50
52
|
### Adding CORS
|
|
51
53
|
|
|
52
54
|
```typescript
|
|
53
|
-
import {
|
|
54
|
-
|
|
55
|
-
export default
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
import { config } from 'theokit'
|
|
56
|
+
|
|
57
|
+
export default config()
|
|
58
|
+
.set({
|
|
59
|
+
// CORS is handled by the framework — configure in route-level or globally
|
|
60
|
+
security: {
|
|
61
|
+
cors: {
|
|
62
|
+
origin: ['http://localhost:3000', 'https://myapp.com'],
|
|
63
|
+
credentials: true,
|
|
64
|
+
},
|
|
61
65
|
},
|
|
62
|
-
}
|
|
63
|
-
|
|
66
|
+
})
|
|
67
|
+
.build()
|
|
64
68
|
```
|
|
65
69
|
|
|
66
70
|
### Storage (Postgres + Redis)
|
|
67
71
|
|
|
68
72
|
```typescript
|
|
69
|
-
export default
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
73
|
+
export default config()
|
|
74
|
+
.set({
|
|
75
|
+
storage: {
|
|
76
|
+
postgres: [{ url: process.env.DATABASE_URL }],
|
|
77
|
+
redis: [{ url: process.env.REDIS_URL }],
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
.build()
|
|
75
81
|
```
|
|
76
82
|
|
|
77
83
|
### Rate Limiting
|
|
78
84
|
|
|
79
85
|
```typescript
|
|
80
|
-
export default
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
86
|
+
export default config()
|
|
87
|
+
.set({
|
|
88
|
+
rateLimit: {
|
|
89
|
+
global: { max: 100, windowMs: 60_000 },
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
.build()
|
|
85
93
|
```
|
|
86
94
|
|
|
87
95
|
### OpenAPI Generation
|
|
88
96
|
|
|
89
97
|
```typescript
|
|
90
|
-
export default
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
98
|
+
export default config()
|
|
99
|
+
.set({
|
|
100
|
+
openapi: {
|
|
101
|
+
title: 'My App API',
|
|
102
|
+
version: '1.0.0',
|
|
103
|
+
outDir: '.theokit',
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
.build()
|
|
97
107
|
```
|
|
98
108
|
|
|
99
109
|
## CLI Commands
|
|
@@ -26,7 +26,7 @@ returns the `Response` your route must return.
|
|
|
26
26
|
|
|
27
27
|
```typescript
|
|
28
28
|
import { handleChannelWebhook } from 'theokit/server/agent'
|
|
29
|
-
import { telegram } from 'theokit/server/webhook' // also: discord, slack, github, stripe
|
|
29
|
+
import { telegram } from 'theokit/server/webhook' // also: discord, slack, github, stripe, whatsapp
|
|
30
30
|
import { parseInbound } from '@theokit/gateway-telegram'
|
|
31
31
|
|
|
32
32
|
const response = await handleChannelWebhook(request, new URL(request.url).pathname, {
|
|
@@ -42,8 +42,36 @@ const response = await handleChannelWebhook(request, new URL(request.url).pathna
|
|
|
42
42
|
The path it expects is `POST /api/agents/<name>/channels/<platform>/webhook`; `<name>` and
|
|
43
43
|
`<platform>` arrive in `onMessage` as `agent` and `platform`.
|
|
44
44
|
|
|
45
|
+
## Platforms that verify the endpoint first (WhatsApp, Instagram, Messenger)
|
|
46
|
+
|
|
47
|
+
Meta will not deliver anything until it has verified the URL with a `GET` carrying
|
|
48
|
+
`hub.mode=subscribe`, `hub.verify_token` and `hub.challenge`, and it requires the challenge echoed
|
|
49
|
+
back as `text/plain`. Declare a responder per platform and mount the route for `GET` as well as
|
|
50
|
+
`POST`:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { whatsapp, whatsappSubscribe } from 'theokit/server/webhook'
|
|
54
|
+
|
|
55
|
+
const response = await handleChannelWebhook(request, new URL(request.url).pathname, {
|
|
56
|
+
validators: { whatsapp: whatsapp({ appSecret: process.env.META_APP_SECRET! }) },
|
|
57
|
+
subscribe: { whatsapp: whatsappSubscribe({ verifyToken: process.env.META_VERIFY_TOKEN! }) },
|
|
58
|
+
onMessage: async ({ payload }) => {
|
|
59
|
+
/* … */
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`appSecret` is the Meta **app secret**, not the access token — the signature is HMAC-SHA256 of it
|
|
65
|
+
over the raw body. `verifyToken` is the string you typed into the Meta app when registering the URL;
|
|
66
|
+
comparing it is the only thing standing between an arbitrary caller and a subscription. A `GET` for
|
|
67
|
+
a platform with no `subscribe` entry answers `405`, not `404`: the platform is configured, it just
|
|
68
|
+
does not do handshakes.
|
|
69
|
+
|
|
70
|
+
Developing against any of this needs a public URL. `theo.config.ts` has `allowedHosts` for exactly
|
|
71
|
+
that — see the framework README.
|
|
72
|
+
|
|
45
73
|
**Give it a `Request` whose body has not been read.** It calls `request.json()` itself, so a wrapper
|
|
46
|
-
that has already parsed the body — `
|
|
74
|
+
that has already parsed the body — `route().body(…)` offers a parsed `body` in its handler context —
|
|
47
75
|
leaves nothing for it to read. Mount it where you still hold the original request, or pass a clone.
|
|
48
76
|
|
|
49
77
|
`ChannelMessage` is `{ agent, platform, payload }`. There is no `request` inside `onMessage`, because
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: theokit-routes
|
|
3
|
-
description: TheoKit server routes —
|
|
3
|
+
description: TheoKit server routes — the route() builder, Zod validation, HTTP methods, dynamic params, error handling
|
|
4
4
|
user-invocable: false
|
|
5
5
|
paths:
|
|
6
6
|
- 'server/routes/**'
|
|
@@ -9,41 +9,47 @@ paths:
|
|
|
9
9
|
|
|
10
10
|
# TheoKit Routes
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## The `route()` builder
|
|
13
13
|
|
|
14
14
|
```typescript
|
|
15
|
-
import {
|
|
15
|
+
import { route } from 'theokit/server/define'
|
|
16
16
|
import { z } from 'zod'
|
|
17
17
|
|
|
18
|
-
// GET
|
|
19
|
-
export const GET =
|
|
20
|
-
policy
|
|
21
|
-
params
|
|
22
|
-
query
|
|
23
|
-
handler
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
status
|
|
36
|
-
handler
|
|
37
|
-
// body is fully typed from Zod schema
|
|
18
|
+
// GET — no body, optional params/query
|
|
19
|
+
export const GET = route()
|
|
20
|
+
.policy('public') // who may call it — required
|
|
21
|
+
.params(z.object({ id: z.coerce.number() })) // URL params
|
|
22
|
+
.query(z.object({ page: z.coerce.number().optional() })) // query string
|
|
23
|
+
.handler(({ params, query }) => ({ id: params.id, page: query?.page }))
|
|
24
|
+
.build()
|
|
25
|
+
|
|
26
|
+
// POST — body validation + custom status
|
|
27
|
+
export const POST = route()
|
|
28
|
+
.policy(({ subject }) => subject !== null) // any authenticated caller
|
|
29
|
+
.body(
|
|
30
|
+
z.object({
|
|
31
|
+
title: z.string().min(3),
|
|
32
|
+
done: z.boolean().default(false),
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
.status(201)
|
|
36
|
+
.handler(({ body }) => {
|
|
37
|
+
// body is fully typed from the Zod schema
|
|
38
38
|
return db.insert(tasks).values(body).returning().get()
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// PUT, DELETE follow the same pattern
|
|
43
|
-
export const PUT = defineRoute({ policy: 'public', body: z.object({...}), handler: ({body, params}) => {...} })
|
|
44
|
-
export const DELETE = defineRoute({ policy: 'public', params: z.object({id: z.coerce.number()}), handler: ({params}) => {...} })
|
|
39
|
+
})
|
|
40
|
+
.build()
|
|
45
41
|
```
|
|
46
42
|
|
|
43
|
+
The chain is `.policy()`, `.params()`, `.query()`, `.body()`, `.status()`, `.response()`,
|
|
44
|
+
`.csrf()`, `.handler()`, and `.build()` closes it. `server/routes/health.ts` in this project is a
|
|
45
|
+
working one — read it rather than this block if the two ever disagree.
|
|
46
|
+
|
|
47
|
+
`.csrf(false)` opts a single route out of CSRF enforcement. It is for endpoints that legitimately
|
|
48
|
+
receive third-party POSTs — a Stripe or WhatsApp webhook, an OAuth callback — which authenticate by
|
|
49
|
+
signature rather than by session. `policy('public')` answers a different question (may an
|
|
50
|
+
unauthenticated caller reach this) and does NOT lift the CSRF gate: without `.csrf(false)` a webhook
|
|
51
|
+
is refused `CSRF_INVALID` before its signature is ever checked.
|
|
52
|
+
|
|
47
53
|
## policy — who may call this route
|
|
48
54
|
|
|
49
55
|
Required on every exported method. The scanner refuses a route file that omits it and names the
|
|
@@ -68,40 +74,40 @@ headers and no cookies: identity arrives as `subject`, established by the transp
|
|
|
68
74
|
| `server/routes/tasks/[id].ts` | `/api/tasks/:id` | Dynamic param |
|
|
69
75
|
| `server/routes/users/[...slug].ts` | `/api/users/*` | Catch-all |
|
|
70
76
|
|
|
71
|
-
##
|
|
77
|
+
## `action()` (Server Actions)
|
|
72
78
|
|
|
73
79
|
```typescript
|
|
74
|
-
import {
|
|
80
|
+
import { action } from 'theokit/server/define'
|
|
75
81
|
import { z } from 'zod'
|
|
76
82
|
|
|
77
|
-
export const createTask =
|
|
78
|
-
input
|
|
79
|
-
handler
|
|
80
|
-
|
|
81
|
-
},
|
|
82
|
-
})
|
|
83
|
+
export const createTask = action()
|
|
84
|
+
.input(z.object({ title: z.string() }))
|
|
85
|
+
.handler(({ input }) => db.insert(tasks).values(input).returning().get())
|
|
86
|
+
.build()
|
|
83
87
|
```
|
|
84
88
|
|
|
89
|
+
The chain is `.input()`, `.accept()`, `.csrf()`, `.handler()`, and `.build()`.
|
|
90
|
+
|
|
85
91
|
## Error Handling
|
|
86
92
|
|
|
87
93
|
```typescript
|
|
88
|
-
import { TheoError } from 'theokit'
|
|
94
|
+
import { TheoError } from 'theokit/server/http'
|
|
89
95
|
|
|
90
|
-
export const GET =
|
|
91
|
-
policy
|
|
92
|
-
handler
|
|
96
|
+
export const GET = route()
|
|
97
|
+
.policy('public')
|
|
98
|
+
.handler(({ params }) => {
|
|
93
99
|
const task = db.select().from(tasks).where(eq(tasks.id, params.id)).get()
|
|
94
100
|
if (!task) throw new TheoError({ code: 'NOT_FOUND', message: 'Task not found' })
|
|
95
101
|
return task
|
|
96
|
-
}
|
|
97
|
-
|
|
102
|
+
})
|
|
103
|
+
.build()
|
|
98
104
|
```
|
|
99
105
|
|
|
100
106
|
Valid error codes: `BAD_REQUEST` (400), `UNAUTHORIZED` (401), `FORBIDDEN` (403), `NOT_FOUND` (404), `CONFLICT` (409), `UNPROCESSABLE_ENTITY` (422), `TOO_MANY_REQUESTS` (429), `INTERNAL_SERVER_ERROR` (500).
|
|
101
107
|
|
|
102
108
|
## Anti-patterns
|
|
103
109
|
|
|
104
|
-
- NEVER use `res.status().json()` — use
|
|
105
|
-
- NEVER parse `req.body` manually — use
|
|
110
|
+
- NEVER use `res.status().json()` — use `.status(201)` on the `route()` chain
|
|
111
|
+
- NEVER parse `req.body` manually — use `.body(z.object({ … }))` on the chain
|
|
106
112
|
- NEVER create routes outside `server/routes/` — they won't be discovered
|
|
107
113
|
- NEVER export non-HTTP-method names — only `GET`, `POST`, `PUT`, `DELETE`, `PATCH`
|