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.
Files changed (34) hide show
  1. package/LICENSE +2 -2
  2. package/README.md +68 -0
  3. package/dist/cli.js +117 -96
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +11 -1
  6. package/templates/default/.env.example +1 -1
  7. package/templates/default/CLAUDE.md +1 -0
  8. package/templates/default/README.md.tmpl +87 -50
  9. package/templates/default/_gitignore +5 -2
  10. package/templates/default/agents/chat.ts +30 -3
  11. package/templates/default/app/components/ChatPanel.tsx +3 -2
  12. package/templates/default/app/components/Nav.tsx +1 -2
  13. package/templates/default/app/hooks/use-transcript.ts +9 -3
  14. package/templates/default/app/layout.tsx +8 -1
  15. package/templates/default/app/lib/constants.ts +2 -1
  16. package/templates/default/app/lib/renderable.ts +78 -0
  17. package/templates/default/app/page.test.tsx +9 -3
  18. package/templates/default/app/page.tsx +1 -1
  19. package/templates/default/docs/ARCHITECTURE.md +6 -2
  20. package/templates/default/dot-claude/rules/theokit-conventions.md +2 -0
  21. package/templates/default/dot-claude/skills/theokit-agents/SKILL.md +1 -1
  22. package/templates/default/dot-claude/skills/theokit-config/SKILL.md +1 -1
  23. package/templates/default/dot-claude/skills/theokit-gateways/SKILL.md +71 -0
  24. package/templates/default/dot-claude/skills/theokit-routes/SKILL.md +21 -2
  25. package/templates/default/dot-claude/skills/theokit-ui/SKILL.md +3 -3
  26. package/templates/default/eslint.config.mjs +7 -7
  27. package/templates/default/index.html +11 -11
  28. package/templates/default/package.json.tmpl +5 -11
  29. package/templates/default/pnpm-workspace.yaml +21 -0
  30. package/templates/default/public/index.html +430 -63
  31. package/templates/default/server/routes/health.ts +3 -0
  32. package/templates/default/types/jobs.d.ts +0 -1
  33. package/templates/surfaces/desktop/sidecar/sidecar.ts +6 -2
  34. 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.7",
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": {
@@ -1,5 +1,5 @@
1
1
  # LLM API key (OpenRouter, Anthropic, or OpenAI)
2
2
  OPENROUTER_API_KEY=sk-or-v1-your-key-here
3
3
 
4
- # Optional: override the model declared by ModelCapability
4
+ # Optional: override the model declared in agents/chat.ts
5
5
  # LLM_MODEL=openai/gpt-4o-mini
@@ -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 **agent chat surface** wired to a mock
6
- streaming agent, ready for you to plug in your LLM. Run it and you immediately
7
- see your agent talking that's the point.
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
- npm install
12
+ # 1. Install dependencies
13
+ pnpm install
14
14
 
15
- # Start development server (HMR)
16
- npm run dev
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 with the agent. Edit the agent at `agents/chat.ts`
20
- (pick your model / add tools) the `@theokit/sdk` runtime resolves OpenRouter /
21
- Anthropic / Ollama / OpenAI from env vars (see the file's header comment).
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
- ├── app/
28
- │ ├── page.tsx # Chat surface (@theokit/ui: ChatThread, ChatComposer, …)
29
- └── layout.tsx # Root layout
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
- ├── chat.ts # Streaming agent endpoint (mockreplace with your LLM)
33
- │ └── health.ts # GET /api/health
34
- ├── tailwind.config.ts # TheoUI design tokens
35
- ├── theo.config.ts # TheoKit configuration
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
- - **Agent chat surface** `app/page.tsx` renders `@theokit/ui` chat components
42
- driven by the streaming endpoint.
43
- - **`agents/chat.ts` (`defineAgent`)** one file auto-served at `POST /api/agents/chat`,
44
- streaming the ai-sdk `UIMessageStream` wire; `useAgent('/api/agents/chat')` on the client
45
- reconstructs `UIMessage[]` you render via `message.parts`. `@theokit/sdk` owns
46
- conversation persistence and provider resolution.
47
- - **`defineAgentTool`** — declare typed tools (the template ships a
48
- `current_time` example).
49
-
50
- ## About `@theokit/sdk`
51
-
52
- `@theokit/sdk` is the agent runtime this template depends on. Its publish to the
53
- public npm registry is **operator-deferred** — if `npm install` cannot resolve
54
- it yet, scaffold the registry-free variant instead (see Bare mode below) and add
55
- the SDK once it is published.
56
-
57
- ## Bare mode (`--bare`)
58
-
59
- Want the minimal "Hello Theo" scaffold with **no** agent UI and **no** unpublished
60
- registry dependencies? Scaffold with `--bare`:
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
- npm create theokit my-app -- --template=default --bare
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
- npm run dev # Start dev server with HMR
74
- npm run build # Build for production
75
- npm run start # Run production build
76
- npm run test # Run tests (vitest)
77
- npm run lint # ESLint check
78
- npm run typecheck # TypeScript type check
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
- # database
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`. See
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
- .model('openai/gpt-4o-mini')
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`. See
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 '@theokit/ui'
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
- /** The full transcript to render (greeting + committed history + the in-flight reply while streaming). */
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
- <main className="flex h-full flex-col overflow-hidden">
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>
@@ -1,4 +1,5 @@
1
- import { type UIMessage, type QuickAction } from '@theokit/ui'
1
+ import { type QuickAction } from '@theokit/ui'
2
+ import { type UIMessage } from 'theokit/client'
2
3
  import { Sparkles } from 'lucide-react'
3
4
 
4
5
  import { AGENT } from '../../shared/agent'
@@ -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, type UIMessage } from '@theokit/ui'
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('ChatMessage accepts a UIMessage and renders its message container (auto-dispatch)', () => {
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` + `docs/ARCHITECTURE.md` § Adding a screen).
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** — the conventional sub-folders under `agents/` (`prompts/`, `tools/`, `skills/`,
46
- `lib/`, `hooks/`, `channels/`, `connections/`, `subagents/`, `schedules/`) are **that concern, not routes**.
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
 
@@ -107,7 +107,7 @@ export class TaskTools {
107
107
  }
108
108
  }
109
109
 
110
- // compõe no agente:
110
+ // compose onto the agent:
111
111
  // new ToolboxCapability(new TaskTools(db), { namespace: 'tasks' }) → tasks.list_tasks
112
112
  ```
113
113
 
@@ -91,7 +91,7 @@ export default defineConfig({
91
91
  openapi: {
92
92
  title: 'My App API',
93
93
  version: '1.0.0',
94
- outDir: 'docs/api',
94
+ outDir: '.theokit',
95
95
  },
96
96
  })
97
97
  ```
@@ -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.