create-theokit 1.23.7 → 1.23.10
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/LICENSE +2 -2
- package/README.md +68 -0
- package/dist/cli.js +117 -96
- package/dist/cli.js.map +1 -1
- package/package.json +11 -1
- package/templates/default/.env.example +1 -1
- package/templates/default/CLAUDE.md +1 -0
- package/templates/default/README.md.tmpl +87 -50
- package/templates/default/_gitignore +5 -2
- package/templates/default/agents/chat.ts +30 -3
- package/templates/default/app/components/ChatPanel.tsx +3 -2
- package/templates/default/app/components/Nav.tsx +1 -2
- package/templates/default/app/hooks/use-transcript.ts +9 -3
- package/templates/default/app/layout.tsx +8 -1
- package/templates/default/app/lib/constants.ts +2 -1
- package/templates/default/app/lib/renderable.ts +78 -0
- package/templates/default/app/page.test.tsx +9 -3
- package/templates/default/app/page.tsx +1 -1
- package/templates/default/docs/ARCHITECTURE.md +6 -2
- package/templates/default/dot-claude/rules/theokit-conventions.md +2 -0
- package/templates/default/dot-claude/skills/theokit-agents/SKILL.md +1 -1
- package/templates/default/dot-claude/skills/theokit-config/SKILL.md +1 -1
- package/templates/default/dot-claude/skills/theokit-gateways/SKILL.md +71 -0
- package/templates/default/dot-claude/skills/theokit-routes/SKILL.md +21 -2
- package/templates/default/dot-claude/skills/theokit-ui/SKILL.md +3 -3
- package/templates/default/eslint.config.mjs +7 -7
- package/templates/default/index.html +11 -11
- package/templates/default/package.json.tmpl +5 -11
- package/templates/default/pnpm-workspace.yaml +21 -0
- package/templates/default/public/index.html +430 -63
- package/templates/default/server/routes/health.ts +3 -0
- package/templates/default/types/jobs.d.ts +0 -1
- package/templates/surfaces/desktop/sidecar/sidecar.ts +6 -2
- package/templates/surfaces/tui/tui/main.tsx.tmpl +12 -8
package/package.json
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-theokit",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold a new TheoKit project",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/usetheokit/theokit.git",
|
|
9
|
+
"directory": "packages/create-theokit"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/usetheokit/theokit/tree/main/packages/create-theokit#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/usetheokit/theokit/issues"
|
|
14
|
+
},
|
|
6
15
|
"license": "Apache-2.0",
|
|
7
16
|
"bin": {
|
|
8
17
|
"create-theokit": "./dist/cli.js"
|
|
@@ -15,6 +24,7 @@
|
|
|
15
24
|
"cross-spawn": "^7.0.6"
|
|
16
25
|
},
|
|
17
26
|
"publishConfig": {
|
|
27
|
+
"provenance": true,
|
|
18
28
|
"access": "public"
|
|
19
29
|
},
|
|
20
30
|
"engines": {
|
|
@@ -9,6 +9,7 @@ This project includes TheoKit-aware skills that activate automatically when you
|
|
|
9
9
|
| Skill | Triggers when editing | What it provides |
|
|
10
10
|
|-------|----------------------|------------------|
|
|
11
11
|
| theokit-routes | `server/routes/**` | defineRoute API, Zod validation, HTTP methods, dynamic params |
|
|
12
|
+
| theokit-gateways | `server/routes/**`, `**/*webhook*` | handleChannelWebhook, the `@theokit/gateway-*` adapters, signature validation, the onMessage seam |
|
|
12
13
|
| theokit-agents | `**/*agent*`, `**/*tool*`, `**/*Agent*`, `**/*Tool*` | @Agent, @Tool, @Toolbox decorators, LLM integration |
|
|
13
14
|
| theokit-database | `**/*schema*`, `**/*db*`, `**/drizzle*`, `**/*migration*`, `**/*seed*` | Drizzle ORM, SQLite, schema patterns, migrations |
|
|
14
15
|
| theokit-frontend | `app/**` | File-based routing, layouts, theoFetch, useAgent |
|
|
@@ -2,78 +2,115 @@
|
|
|
2
2
|
|
|
3
3
|
Built with [TheoKit](https://theokit.dev) — **Build the app your agent lives in.**
|
|
4
4
|
|
|
5
|
-
This is the default template: a working
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
This is the default template: a **working agent chat**, end to end. The thread
|
|
6
|
+
streams real replies, the tools really run, and the approval prompt really gates
|
|
7
|
+
the side-effecting one. Run it and you are talking to your agent.
|
|
8
8
|
|
|
9
9
|
## Getting Started
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
# Install dependencies
|
|
13
|
-
|
|
12
|
+
# 1. Install dependencies
|
|
13
|
+
pnpm install
|
|
14
14
|
|
|
15
|
-
#
|
|
16
|
-
|
|
15
|
+
# 2. Point the agent at a provider (OpenRouter, Anthropic, or OpenAI)
|
|
16
|
+
cp .env.example .env && $EDITOR .env
|
|
17
|
+
|
|
18
|
+
# 3. Start the dev server (HMR)
|
|
19
|
+
pnpm dev
|
|
17
20
|
```
|
|
18
21
|
|
|
19
|
-
Open the app and chat
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
Open the app and chat. Edit `agents/chat.ts` to change the model, add tools, or
|
|
23
|
+
swap the persona — `@theokit/sdk` is the runtime and resolves the provider from
|
|
24
|
+
your environment.
|
|
22
25
|
|
|
23
26
|
## Project Structure
|
|
24
27
|
|
|
25
28
|
```
|
|
26
29
|
{{name}}/
|
|
27
|
-
├──
|
|
28
|
-
│ ├──
|
|
29
|
-
│
|
|
30
|
+
├── agents/ # The agent, and what composes it
|
|
31
|
+
│ ├── chat.ts # the agent → POST /api/agents/chat
|
|
32
|
+
│ ├── prompts/ # system prompts / personas
|
|
33
|
+
│ ├── tools/ # tools the agent can call (weather, current-time, …)
|
|
34
|
+
│ └── skills/ # procedures the model loads on demand
|
|
35
|
+
├── app/ # Frontend — file-based routing
|
|
36
|
+
│ ├── page.tsx # the `/` route — composition root
|
|
37
|
+
│ ├── layout.tsx # root layout
|
|
38
|
+
│ ├── components/ # presentational UI
|
|
39
|
+
│ ├── hooks/ # custom hooks — where state lives
|
|
40
|
+
│ └── lib/ # app modules / config
|
|
30
41
|
├── server/
|
|
31
|
-
│ └── routes/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
├──
|
|
35
|
-
|
|
36
|
-
└── .env.example # Environment variables
|
|
42
|
+
│ └── routes/health.ts # GET /api/health
|
|
43
|
+
├── shared/agent.ts # Branding (name, model, greeting) — one source of truth
|
|
44
|
+
├── docs/ # ARCHITECTURE · CUSTOMIZATION · ENVIRONMENT
|
|
45
|
+
├── theo.config.ts # TheoKit configuration
|
|
46
|
+
└── .env.example # Environment variables
|
|
37
47
|
```
|
|
38
48
|
|
|
39
49
|
## Key Concepts
|
|
40
50
|
|
|
41
|
-
- **
|
|
42
|
-
|
|
43
|
-
- **`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
51
|
+
- **An agent is a file.** `agents/chat.ts` is served at `POST /api/agents/chat` —
|
|
52
|
+
nothing to register. Add a second agent as another `agents/<name>.ts`.
|
|
53
|
+
- **`AgentBuilder`** is the authoring surface. The chain accumulates type-state, so
|
|
54
|
+
`.build()` without `.model()` is a compile error, not a runtime surprise:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
export default AgentBuilder.create()
|
|
58
|
+
.input(z.object({ message: z.string() }))
|
|
59
|
+
.model('openai/gpt-4o-mini')
|
|
60
|
+
.system(BASE_INSTRUCTIONS)
|
|
61
|
+
.tool(weatherTool)
|
|
62
|
+
.approval('send_notification', { question: 'Send this notification?' })
|
|
63
|
+
.skills([dailyBriefingSkill])
|
|
64
|
+
.build()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **A tool is a file too** — `tool('name').describe(…).input(z.object({…})).execute(…).build()`.
|
|
68
|
+
Pure metadata plus a handler; it never calls an LLM.
|
|
69
|
+
- **The sub-folders under `agents/` are semantic, not routes.** `prompts/`, `tools/`,
|
|
70
|
+
`skills/` and friends are skipped by the scanner, so `agents/tools/weather.ts`
|
|
71
|
+
never becomes a phantom `/api/agents/tools/weather` endpoint.
|
|
72
|
+
- **`useAgent`** binds the client to the agent and hands you the transcript:
|
|
73
|
+
`const { thread, send, status, reset, error } = useAgent('/api/agents/chat')`.
|
|
74
|
+
- **Human-in-the-loop.** `.approval(tool, …)` pauses the run before that tool and
|
|
75
|
+
asks. Ask the agent to "notify me that …" to see it.
|
|
76
|
+
- **Styling is wired for you.** Tailwind v4 and `@theokit/ui` are detected and
|
|
77
|
+
configured by the framework — there is no `tailwind.config.ts` to maintain.
|
|
78
|
+
|
|
79
|
+
Deeper detail lives in [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md);
|
|
80
|
+
customization and environment variables have their own pages beside it.
|
|
81
|
+
|
|
82
|
+
## Adding a screen
|
|
83
|
+
|
|
84
|
+
Routing is file-based: a screen is a folder under `app/` with a `page.tsx`.
|
|
85
|
+
|
|
86
|
+
| You want | Create | Serves |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| a `/settings` screen | `app/settings/page.tsx` | `/settings` |
|
|
89
|
+
| a nested screen | `app/settings/billing/page.tsx` | `/settings/billing` |
|
|
90
|
+
| a dynamic screen | `app/users/[id]/page.tsx` | `/users/:id` |
|
|
91
|
+
|
|
92
|
+
Or run `theokit generate page settings`, then link it from `app/components/Nav.tsx`.
|
|
93
|
+
|
|
94
|
+
## Other scaffolds
|
|
95
|
+
|
|
96
|
+
The same agent runs behind a terminal or desktop frontend — only the transport
|
|
97
|
+
changes. Scaffold them with `--surface=tui` (Ink) or `--surface=desktop` (Tauri).
|
|
98
|
+
|
|
99
|
+
Want the opposite — a plain app with no agent and no `@theokit/*` runtime
|
|
100
|
+
dependencies? Scaffold with `--bare`: a Hello Theo page and the same clean
|
|
101
|
+
structure to grow into.
|
|
61
102
|
|
|
62
103
|
```bash
|
|
63
|
-
|
|
104
|
+
npx create-theokit my-app --bare
|
|
64
105
|
```
|
|
65
106
|
|
|
66
|
-
`--bare` strips `@theokit/ui`, `@theokit/sdk`, `lucide-react`, and the Tailwind
|
|
67
|
-
toolchain, replaces `app/page.tsx` with a plain "Hello Theo" page, and removes
|
|
68
|
-
the mock chat route — a scaffold that always installs without registry access.
|
|
69
|
-
|
|
70
107
|
## Commands
|
|
71
108
|
|
|
72
109
|
```bash
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
110
|
+
pnpm dev # Start dev server with HMR
|
|
111
|
+
pnpm build # Build for production
|
|
112
|
+
pnpm start # Run the production build
|
|
113
|
+
pnpm test # Run tests (vitest)
|
|
114
|
+
pnpm lint # ESLint check
|
|
115
|
+
pnpm typecheck # TypeScript type check
|
|
79
116
|
```
|
|
@@ -25,8 +25,11 @@ pnpm-debug.log*
|
|
|
25
25
|
*.pem
|
|
26
26
|
*.tsbuildinfo
|
|
27
27
|
|
|
28
|
-
#
|
|
29
|
-
data
|
|
28
|
+
# framework-written app data — conversation transcripts (.data/agent-sessions/**/*.jsonl)
|
|
29
|
+
# and the local SQLite DB both live here. This said `data/`, without the dot, and
|
|
30
|
+
# matched nothing the framework writes: every prompt, answer, tool input and tool
|
|
31
|
+
# result went into version control on the first commit after the first run (#395).
|
|
32
|
+
.data/
|
|
30
33
|
|
|
31
34
|
# editors
|
|
32
35
|
.vscode/
|
|
@@ -12,16 +12,43 @@ import { weatherTool } from './tools/weather.js'
|
|
|
12
12
|
* agent; it composes its neighbours under `agents/`: the persona in `prompts/`, capabilities in `tools/`,
|
|
13
13
|
* procedures in `skills/`. Those folders are that concern, NOT extra routes — the framework's scanner
|
|
14
14
|
* treats `prompts/ tools/ skills/ lib/ …` as semantic folders, so `agents/tools/weather.ts` never becomes
|
|
15
|
-
* a `/api/agents/tools/weather` endpoint. Add a second agent as another `agents/<name>.ts`.
|
|
16
|
-
* `docs/ARCHITECTURE.md`.
|
|
15
|
+
* a `/api/agents/tools/weather` endpoint. Add a second agent as another `agents/<name>.ts`.
|
|
17
16
|
*
|
|
18
17
|
* `@theokit/sdk` runs the agent; conversation turns auto-persist per session. Provider is resolved from
|
|
19
18
|
* the environment — OPENROUTER_API_KEY (preferred) OR ANTHROPIC_API_KEY / OPENAI_API_KEY; the model id is
|
|
20
19
|
* provider-prefixed so OpenRouter routes it upstream (https://openrouter.ai/models).
|
|
21
20
|
*/
|
|
21
|
+
/**
|
|
22
|
+
* Who may run this agent, and against which conversation (ADR 0001).
|
|
23
|
+
*
|
|
24
|
+
* Every agent declares this; `'public'` is the decision, not the absence of one — and it is the
|
|
25
|
+
* honest one for a scaffold with no login. It means: the endpoint resumes whatever conversation the
|
|
26
|
+
* caller names, so anyone holding a session id may read and continue it. That is a capability
|
|
27
|
+
* model, and it is fine while the ids are the random UUIDs the client mints.
|
|
28
|
+
*
|
|
29
|
+
* The moment this app has users, replace it with the owner check:
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { requireOwner } from 'theokit/server/define'
|
|
33
|
+
*
|
|
34
|
+
* export const policy = ({ subject, params }) =>
|
|
35
|
+
* requireOwner(subject, ownerOfConversation(params.sessionId))
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* `subject` is whatever `server/context.ts` put on `ctx.subject`; `params` carries
|
|
39
|
+
* `{ agent, endpoint, sessionId?, approvalId? }`. One declaration covers the run, the thread
|
|
40
|
+
* routes, the approval surface and MCP.
|
|
41
|
+
*/
|
|
42
|
+
export const policy = 'public'
|
|
43
|
+
|
|
22
44
|
export default AgentBuilder.create()
|
|
23
45
|
.input(z.object({ message: z.string() }))
|
|
24
|
-
|
|
46
|
+
// `LLM_MODEL` is honoured HERE, not by the framework, because this is where the model is
|
|
47
|
+
// declared and this file is yours. `.env.example` documented the variable and nothing read it,
|
|
48
|
+
// so setting it changed the model to exactly what it already was (#398, #408). One expression is
|
|
49
|
+
// cheaper than an override path through the framework, and it keeps the value visible in the file
|
|
50
|
+
// 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')
|
|
25
52
|
.system(BASE_INSTRUCTIONS)
|
|
26
53
|
.tool(weatherTool)
|
|
27
54
|
.tool(currentTimeTool)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { ChatThread, ChatMessage, AgentStreaming, QuickActionChips } from '@theokit/ui'
|
|
2
|
-
import { type UIMessage } from '@theokit/ui'
|
|
3
2
|
import { ScrollArea } from '@usetheo/ui'
|
|
3
|
+
import { type UIMessage } from 'theokit/client'
|
|
4
4
|
|
|
5
5
|
import { MODEL_NAME, STARTERS } from '../lib/constants'
|
|
6
|
+
import { toRenderable } from '../lib/renderable'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The scrolling transcript: the message thread + a live streaming indicator + the starter prompts (shown
|
|
@@ -25,7 +26,7 @@ export function ChatPanel({
|
|
|
25
26
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-6 py-6">
|
|
26
27
|
<ChatThread>
|
|
27
28
|
{thread.map((message) => (
|
|
28
|
-
<ChatMessage key={message.id} message={message} />
|
|
29
|
+
<ChatMessage key={message.id} message={toRenderable(message)} />
|
|
29
30
|
))}
|
|
30
31
|
{isStreaming && <AgentStreaming model={MODEL_NAME} />}
|
|
31
32
|
</ChatThread>
|
|
@@ -4,8 +4,7 @@ import { Link } from 'theokit/client'
|
|
|
4
4
|
/**
|
|
5
5
|
* The primary navigation menu. Add one entry per screen you add under `app/` (a screen is a folder with a
|
|
6
6
|
* `page.tsx` — `app/settings/page.tsx` → `/settings`). Uses TheoKit's `Link` (react-router's Link + route
|
|
7
|
-
* prefetch on hover/focus) and computes the active route from `useLocation`.
|
|
8
|
-
* `docs/ARCHITECTURE.md` § Adding a screen.
|
|
7
|
+
* prefetch on hover/focus) and computes the active route from `useLocation`.
|
|
9
8
|
*/
|
|
10
9
|
const LINKS = [
|
|
11
10
|
{ to: '/', label: 'Chat', exact: true },
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { type UIMessage } from '
|
|
4
|
-
import { useAgent } from 'theokit/client'
|
|
3
|
+
import { useAgent, type UIMessage } from 'theokit/client'
|
|
5
4
|
|
|
6
5
|
import { GREETING } from '../lib/constants'
|
|
7
6
|
|
|
@@ -12,7 +11,14 @@ import { GREETING } from '../lib/constants'
|
|
|
12
11
|
* hand-rolled transcript (local history + commit-once effect + inflight-merge) is gone — the store owns it.
|
|
13
12
|
*/
|
|
14
13
|
export interface ChatTranscript {
|
|
15
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The full transcript (greeting + committed history + the in-flight reply while streaming).
|
|
16
|
+
*
|
|
17
|
+
* Typed as the framework's `UIMessage` — the shape `useAgent` actually returns — and NOT as the
|
|
18
|
+
* component library's. Two earlier versions of this template typed it as the renderer's type and
|
|
19
|
+
* a fresh scaffold failed its own `typecheck` (usetheokit/theokit#80, #396). `app/lib/renderable.ts`
|
|
20
|
+
* converts at the one place that renders.
|
|
21
|
+
*/
|
|
16
22
|
thread: UIMessage[]
|
|
17
23
|
isStreaming: boolean
|
|
18
24
|
hasError: boolean
|
|
@@ -14,7 +14,14 @@ export default function RootLayout() {
|
|
|
14
14
|
return (
|
|
15
15
|
<div className="grid h-screen w-screen grid-rows-[auto_1fr] bg-background text-foreground">
|
|
16
16
|
<Header />
|
|
17
|
-
|
|
17
|
+
{/*
|
|
18
|
+
`data-theo-scroll` marks this as a scroll container, so its offset is restored on back
|
|
19
|
+
navigation (usetheokit/theokit#421). Browsers and react-router only ever restore the
|
|
20
|
+
DOCUMENT; in a shell like this one the document never scrolls, so without the marker there
|
|
21
|
+
is nothing to restore. The value is the id — add the attribute to any other element you
|
|
22
|
+
scroll, with a different value.
|
|
23
|
+
*/}
|
|
24
|
+
<main data-theo-scroll="main" className="flex h-full flex-col overflow-hidden">
|
|
18
25
|
<Outlet />
|
|
19
26
|
</main>
|
|
20
27
|
</div>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ToolInvocationState,
|
|
3
|
+
UIMessage as RenderableMessage,
|
|
4
|
+
UIMessagePart as RenderablePart,
|
|
5
|
+
} from '@theokit/ui'
|
|
6
|
+
import type { UIMessage } from 'theokit/client'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The seam between what the framework STREAMS and what the component library RENDERS.
|
|
10
|
+
*
|
|
11
|
+
* `useAgent().thread` hands you `UIMessage` from `theokit/client` — the wire shape, whose parts are
|
|
12
|
+
* deliberately open (`{ type: string; [key: string]: unknown }`) so a `data-*` part or a part kind
|
|
13
|
+
* invented after your app was written still arrives intact. `<ChatMessage>` wants `@theokit/ui`'s
|
|
14
|
+
* `UIMessage`, whose parts are a closed discriminated union.
|
|
15
|
+
*
|
|
16
|
+
* Both are right, and neither can be made assignable to the other without one of them giving up
|
|
17
|
+
* what it is for. So the APP owns the conversion — which is where it belongs: your app chose the
|
|
18
|
+
* renderer, and the framework does not know about it. Two prior versions of this template typed the
|
|
19
|
+
* transcript as the renderer's type and let TypeScript discover the mismatch on the first
|
|
20
|
+
* `tsc --noEmit` of a fresh scaffold (usetheokit/theokit#80, #396).
|
|
21
|
+
*
|
|
22
|
+
* Nothing here is a cast. Each branch checks the fields that branch needs and builds a value the
|
|
23
|
+
* renderer's type accepts; a part it cannot vouch for is DROPPED rather than forced through, which
|
|
24
|
+
* is the honest outcome for a part this version of the library has no renderer for.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The tool states `@theokit/ui` knows how to render. Anything else is not renderable here. */
|
|
28
|
+
const TOOL_STATES = [
|
|
29
|
+
'input-streaming',
|
|
30
|
+
'input-available',
|
|
31
|
+
'approval-requested',
|
|
32
|
+
'approval-responded',
|
|
33
|
+
'output-available',
|
|
34
|
+
'output-error',
|
|
35
|
+
'output-denied',
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
function isToolState(value: unknown): value is ToolInvocationState {
|
|
39
|
+
return typeof value === 'string' && (TOOL_STATES as readonly string[]).includes(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toRenderablePart(part: UIMessage['parts'][number]): RenderablePart | null {
|
|
43
|
+
const { type } = part
|
|
44
|
+
|
|
45
|
+
if (type === 'text' || type === 'reasoning') {
|
|
46
|
+
return typeof part.text === 'string' ? { type, text: part.text } : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (type === 'dynamic-tool' || type.startsWith('tool-')) {
|
|
50
|
+
// A tool part the renderer can draw needs both of these; a half-built one is skipped rather
|
|
51
|
+
// than drawn with holes.
|
|
52
|
+
if (typeof part.toolCallId !== 'string' || !isToolState(part.state)) return null
|
|
53
|
+
return {
|
|
54
|
+
...part,
|
|
55
|
+
type: type === 'dynamic-tool' ? 'dynamic-tool' : `tool-${type.slice('tool-'.length)}`,
|
|
56
|
+
toolCallId: part.toolCallId,
|
|
57
|
+
state: part.state,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (type.startsWith('data-')) {
|
|
62
|
+
return { ...part, type: `data-${type.slice('data-'.length)}`, data: part.data }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A kind this version of `@theokit/ui` has no renderer for. Dropping it keeps the rest of the
|
|
66
|
+
// message readable; forcing it through would render nothing and hide the reason.
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Project one streamed message into the shape `<ChatMessage>` renders. */
|
|
71
|
+
export function toRenderable(message: UIMessage): RenderableMessage {
|
|
72
|
+
return {
|
|
73
|
+
id: message.id,
|
|
74
|
+
role: message.role,
|
|
75
|
+
parts: message.parts.map(toRenderablePart).filter((part) => part !== null),
|
|
76
|
+
...(message.metadata === undefined ? {} : { metadata: message.metadata }),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { render, screen } from '@testing-library/react'
|
|
3
3
|
import { describe, it, expect } from 'vitest'
|
|
4
|
-
import { ChatThread, ChatMessage
|
|
4
|
+
import { ChatThread, ChatMessage } from '@theokit/ui'
|
|
5
|
+
import { type UIMessage } from 'theokit/client'
|
|
6
|
+
|
|
7
|
+
import { toRenderable } from './lib/renderable'
|
|
5
8
|
import Page from './page'
|
|
6
9
|
|
|
7
10
|
/**
|
|
@@ -22,7 +25,10 @@ describe('default chat page', () => {
|
|
|
22
25
|
expect(screen.getByLabelText('New chat')).toBeDefined()
|
|
23
26
|
})
|
|
24
27
|
|
|
25
|
-
it('
|
|
28
|
+
it('a streamed message renders through the conversion the app actually uses', () => {
|
|
29
|
+
// Typed as what `useAgent()` RETURNS, not as what `<ChatMessage>` takes. Those are different
|
|
30
|
+
// types on purpose, and a scaffold that conflated them failed its own typecheck twice
|
|
31
|
+
// (usetheokit/theokit#80, #396). `toRenderable` is the seam, and this is what exercises it.
|
|
26
32
|
const assistant: UIMessage = {
|
|
27
33
|
id: 'a-0',
|
|
28
34
|
role: 'assistant',
|
|
@@ -30,7 +36,7 @@ describe('default chat page', () => {
|
|
|
30
36
|
}
|
|
31
37
|
const { container } = render(
|
|
32
38
|
<ChatThread>
|
|
33
|
-
<ChatMessage message={assistant} />
|
|
39
|
+
<ChatMessage message={toRenderable(assistant)} />
|
|
34
40
|
</ChatThread>,
|
|
35
41
|
)
|
|
36
42
|
expect(container.querySelector('[data-slot="chat-message"]')).not.toBeNull()
|
|
@@ -17,7 +17,7 @@ import { useChatTranscript } from './hooks/use-transcript'
|
|
|
17
17
|
* Add a SCREEN: routing is file-based — a screen is a folder under `app/` with a `page.tsx`
|
|
18
18
|
* (`app/settings/page.tsx` → `/settings`; `app/users/[id]/page.tsx` → `/users/:id`). Run
|
|
19
19
|
* `theokit generate page <name>`, then add a link in `app/components/Nav.tsx`. This route is `/` (see the
|
|
20
|
-
* example `app/about/page.tsx`
|
|
20
|
+
* example `app/about/page.tsx`).
|
|
21
21
|
*/
|
|
22
22
|
export default function Page() {
|
|
23
23
|
const [composerValue, setComposerValue] = useState('')
|
|
@@ -42,12 +42,16 @@ folders it composes (prompts, tools, skills) live together under `agents/`, with
|
|
|
42
42
|
## Clean names, no phantom routes
|
|
43
43
|
|
|
44
44
|
An agent is a file: `agents/<name>.ts` → `POST /api/agents/<name>`. But the framework's scanner is
|
|
45
|
-
**folder-semantic** —
|
|
46
|
-
`lib/`, `hooks/`, `channels/`, `connections/`, `subagents/`, `schedules
|
|
45
|
+
**folder-semantic** — thirteen sub-folder names under `agents/` (`tools/`, `skills/`, `prompts/`,
|
|
46
|
+
`lib/`, `hooks/`, `channels/`, `connections/`, `subagents/`, `schedules/`, `sandbox/`, `workflows/`,
|
|
47
|
+
`evals/`, `memory/`) are **that concern, not routes**.
|
|
47
48
|
So the names stay clean (`tools/`, not `_tools/`) and `agents/tools/weather.ts` never becomes a phantom
|
|
48
49
|
`/api/agents/tools/weather` endpoint. Markdown (`skills/*.md`) is never scanned either way. The
|
|
49
50
|
prompts/tools/skills are **shared** across every agent in `agents/`.
|
|
50
51
|
|
|
52
|
+
An agent that outgrows one file becomes a folder that co-locates its own composition —
|
|
53
|
+
`agents/<name>/index.ts` with `tools/` and `prompts/` beside it. Same route, same identity.
|
|
54
|
+
|
|
51
55
|
## Composition
|
|
52
56
|
|
|
53
57
|
`agents/chat.ts` is thin on purpose — it wires the pieces together:
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
- File at `server/routes/tasks/[id].ts` maps to `/api/tasks/:id`
|
|
21
21
|
- Export HTTP method handlers: `export const GET = defineRoute({...})`
|
|
22
|
+
- Every method declares `policy` — who may call it. `'public'` is a valid answer and an explicit one;
|
|
23
|
+
omitting it fails the build with the file named
|
|
22
24
|
- Use `params: z.object({...})` for URL params, `body:` for request body
|
|
23
25
|
- Use `status: 201` for creation responses, not manual `res.status()`
|
|
24
26
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: theokit-gateways
|
|
3
|
+
description: Receiving messages from Telegram, WhatsApp, Slack and other platforms — handleChannelWebhook, the @theokit/gateway-* adapters, signature validation, the onMessage seam
|
|
4
|
+
user-invocable: false
|
|
5
|
+
paths:
|
|
6
|
+
- "server/routes/**"
|
|
7
|
+
- "server/channels/**"
|
|
8
|
+
- "**/*gateway*"
|
|
9
|
+
- "**/*webhook*"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# TheoKit Gateways
|
|
13
|
+
|
|
14
|
+
Receiving a message from a messaging platform spans three packages, and each owns one part of it.
|
|
15
|
+
|
|
16
|
+
| Package | Its half |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `theokit` (here) | The HTTP route and the signature check. `handleChannelWebhook` verifies the signature, parses the body, and hands your app the parsed JSON as `payload: unknown` |
|
|
19
|
+
| `@theokit/gateway-*` | Translating that payload into a canonical event, so your app never re-declares a platform's wire format |
|
|
20
|
+
| `@theokit/sdk` | Not involved in this path today |
|
|
21
|
+
|
|
22
|
+
## Wiring one
|
|
23
|
+
|
|
24
|
+
`handleChannelWebhook` is not mounted for you. It takes a `Request`, the URL path, and a config, and
|
|
25
|
+
returns the `Response` your route must return.
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { handleChannelWebhook } from 'theokit/server/agent'
|
|
29
|
+
import { telegram } from 'theokit/server/webhook' // also: discord, slack, github, stripe
|
|
30
|
+
import { parseInbound } from '@theokit/gateway-telegram'
|
|
31
|
+
|
|
32
|
+
const response = await handleChannelWebhook(request, new URL(request.url).pathname, {
|
|
33
|
+
validators: { telegram: telegram({ secretToken: process.env.TELEGRAM_SECRET_TOKEN! }) },
|
|
34
|
+
onMessage: async ({ agent, platform, payload }) => {
|
|
35
|
+
const event = parseInbound(payload)
|
|
36
|
+
if (event === null) return // not a message this adapter handles — see below
|
|
37
|
+
// hand `event` to your agent
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The path it expects is `POST /api/agents/<name>/channels/<platform>/webhook`; `<name>` and
|
|
43
|
+
`<platform>` arrive in `onMessage` as `agent` and `platform`.
|
|
44
|
+
|
|
45
|
+
**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 — `defineRoute` offers a parsed `body` in its handler context —
|
|
47
|
+
leaves nothing for it to read. Mount it where you still hold the original request, or pass a clone.
|
|
48
|
+
|
|
49
|
+
`ChannelMessage` is `{ agent, platform, payload }`. There is no `request` inside `onMessage`, because
|
|
50
|
+
the body was read and the signature checked before it ran.
|
|
51
|
+
|
|
52
|
+
## Never throw out of `onMessage`
|
|
53
|
+
|
|
54
|
+
`onMessage` is awaited **before** the 200 is built, and `handleChannelWebhook` does not catch around
|
|
55
|
+
it. A throw there means the 200 is never built: mounted in a TheoKit route, the rejection reaches
|
|
56
|
+
that route's error boundary and is answered 500 — the platform sees a failed delivery where it
|
|
57
|
+
expected an acknowledgement.
|
|
58
|
+
|
|
59
|
+
This is why every adapter's translator returns `null` (or `undefined`) for a payload it does not
|
|
60
|
+
recognise rather than throwing. Handle the `null` and answer normally.
|
|
61
|
+
|
|
62
|
+
## Which adapters go through this seam
|
|
63
|
+
|
|
64
|
+
Only platforms that deliver by **webhook**. Telegram and the SMS providers export `parseInbound`
|
|
65
|
+
under that name; LINE, WhatsApp Cloud and Teams export their translation under their own names, each
|
|
66
|
+
with its own signature — read the one you are using.
|
|
67
|
+
|
|
68
|
+
Adapters whose transport is a long-lived connection — Discord, Slack, Mattermost, Matrix, e-mail,
|
|
69
|
+
and WhatsApp's `web` and `baileys` backends — do **not** go through this seam. They own their
|
|
70
|
+
transport, and running one alongside a TheoKit server needs a process lifecycle this scaffold does
|
|
71
|
+
not set up.
|