@escalay/kit 0.0.0-stage → 1.7.0

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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/cli.mjs +432 -0
  4. package/docs/STANDARD.md +342 -0
  5. package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
  6. package/docs/decisions/0002-node-and-workers.md +45 -0
  7. package/package.json +58 -3
  8. package/registry.json +512 -0
  9. package/src/a2a/README.md +25 -0
  10. package/src/a2a/index.ts +262 -0
  11. package/src/boards/README.md +35 -0
  12. package/src/boards/handlers.ts +87 -0
  13. package/src/boards/index.ts +64 -0
  14. package/src/boards/refs.ts +23 -0
  15. package/src/boards/repo.ts +50 -0
  16. package/src/boards/routes.ts +83 -0
  17. package/src/boards/schema.ts +28 -0
  18. package/src/boards/tools.ts +76 -0
  19. package/src/browser/README.md +31 -0
  20. package/src/browser/boards-client.ts +74 -0
  21. package/src/browser/boards-react.ts +73 -0
  22. package/src/browser/search-client.ts +258 -0
  23. package/src/browser/search-react.ts +116 -0
  24. package/src/browser/vector-search-block.tsx +94 -0
  25. package/src/core/README.md +37 -0
  26. package/src/core/api.ts +239 -0
  27. package/src/core/auth.ts +147 -0
  28. package/src/core/cache.ts +53 -0
  29. package/src/core/discovery.ts +315 -0
  30. package/src/core/guards.ts +109 -0
  31. package/src/core/hal.ts +63 -0
  32. package/src/core/kv.ts +29 -0
  33. package/src/core/problem.ts +124 -0
  34. package/src/db/README.md +39 -0
  35. package/src/db/guards.ts +61 -0
  36. package/src/db/index.ts +122 -0
  37. package/src/guide/README.md +38 -0
  38. package/src/guide/guide.ts +152 -0
  39. package/src/guide/reference.ts +204 -0
  40. package/src/index.ts +15 -0
  41. package/src/mcp/README.md +34 -0
  42. package/src/mcp/index.ts +251 -0
  43. package/src/oauth/README.md +29 -0
  44. package/src/oauth/index.ts +439 -0
  45. package/src/oauth/schema.ts +7 -0
  46. package/src/oauth/store.ts +34 -0
  47. package/src/pagination/README.md +31 -0
  48. package/src/pagination/index.ts +65 -0
  49. package/src/testing/README.md +31 -0
  50. package/src/testing/index.ts +162 -0
  51. package/src/vector-search/README.md +27 -0
  52. package/src/vector-search/index.ts +367 -0
  53. package/src/vectors/README.md +30 -0
  54. package/src/vectors/index.ts +326 -0
  55. package/src/web/README.md +21 -0
  56. package/src/web/index.ts +100 -0
  57. package/templates/app/AGENTS.md +41 -0
  58. package/templates/app/CLAUDE.md +3 -0
  59. package/templates/app/Dockerfile +12 -0
  60. package/templates/app/container/entrypoint.sh +7 -0
  61. package/templates/app/gitignore +4 -0
  62. package/templates/app/guide/llms.txt +11 -0
  63. package/templates/app/pnpm-workspace.yaml +5 -0
  64. package/templates/app/scripts/guide.ts +8 -0
  65. package/templates/app/src/app.ts +39 -0
  66. package/templates/app/src/features/index.ts +30 -0
  67. package/templates/app/src/server.ts +14 -0
  68. package/templates/app/test/api.test.ts +34 -0
  69. package/templates/app/tsconfig.json +16 -0
  70. package/templates/feature/feature.test.ts +19 -0
  71. package/templates/feature/handlers.ts +43 -0
  72. package/templates/feature/index.ts +34 -0
  73. package/templates/feature/repo.ts +24 -0
  74. package/templates/feature/routes.ts +43 -0
  75. package/templates/feature/schema.ts +14 -0
  76. package/templates/feature/tools.ts +46 -0
  77. package/templates/runtime-cloudflare/dev.vars.example +3 -0
  78. package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
  79. package/templates/runtime-cloudflare/src/worker.ts +19 -0
  80. package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
  81. package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
  82. package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
  83. package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
  84. package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
  85. package/templates/runtime-cloudflare/vitest.config.ts +23 -0
  86. package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * A2A (Agent2Agent, spec 1.0) over the app's MCP tools: the Agent Card at `/.well-known/agent-card.json` and a JSON-RPC
3
+ * endpoint (`SendMessage`, `GetTask`, `ListTasks`, `CancelTask`). Each skill is one tool: a message's text fills the
4
+ * tool's main argument (a `data` part may give all of them), the tool runs its route in-process with the caller's own
5
+ * credentials, and the answer comes back as a Task whose artifact holds the tool's summary (text) and the route's JSON
6
+ * (data). A slow route (202 + run) leaves the task working; `GetTask` asks again. Tasks live in the store for a day and
7
+ * only their caller sees them. No streaming, push notifications or extended card: the card says so.
8
+ *
9
+ * @example
10
+ * mountA2A(app, {
11
+ * info: { name: 'Cookbook', description: 'Recipes and menus…', version: '1.0.0' },
12
+ * skills: [{ tool: exploreIdea, text: 'idea', tags: ['research'], examples: ['split a bill with friends'] }],
13
+ * callApi: inProcess(app), store: state.oauth, oauth: true,
14
+ * })
15
+ */
16
+ import { randomUUID } from 'node:crypto'
17
+ import type { AuthInfo } from '@modelcontextprotocol/server'
18
+ import type { Context, Hono } from 'hono'
19
+ import { sendPublic, WELL_KNOWN } from '../core/discovery.js'
20
+ import { originOf } from '../core/hal.js'
21
+ import { callWaiting, FORWARDED, problemText, type CallApi, type McpTool } from '../mcp/index.js'
22
+ import { memoryStore, type KeyValueStore } from '../core/kv.js'
23
+
24
+ export const A2A_VERSION = '1.0'
25
+
26
+ /** One A2A skill: an MCP tool, the argument a plain-text message fills, and how registries should file it. */
27
+ export type A2ASkill = {
28
+ tool: McpTool
29
+ /** The tool's argument that a message's text fills, e.g. `idea` or `purpose`. */
30
+ text: string
31
+ tags: string[]
32
+ examples?: string[]
33
+ }
34
+
35
+ /** Any key-value store with expiry (`kv.ts`); the OAuth store fits (tasks are kept under the kind `a2a-task`). */
36
+ export type A2ATaskStore = KeyValueStore
37
+
38
+ export type A2AConfig = {
39
+ path?: string
40
+ info: { name: string; description: string; version: string; organization?: string }
41
+ /** The first is used when a message names none (`metadata.skill`). */
42
+ skills: A2ASkill[]
43
+ callApi: CallApi
44
+ store?: A2ATaskStore
45
+ /** Advertise the app's OAuth server (`oauth.ts`) beside API keys. */
46
+ oauth?: boolean
47
+ /** How long `SendMessage` waits for a slow route before answering with a working task. */
48
+ waitMs?: number
49
+ }
50
+
51
+ type Part = { text?: string; data?: unknown; url?: string; raw?: string; mediaType?: string; filename?: string; metadata?: Record<string, unknown> }
52
+ type Message = { messageId: string; role: string; parts: Part[]; contextId?: string; taskId?: string; metadata?: Record<string, unknown> }
53
+ type TaskState = 'TASK_STATE_WORKING' | 'TASK_STATE_COMPLETED' | 'TASK_STATE_FAILED' | 'TASK_STATE_CANCELED' | 'TASK_STATE_AUTH_REQUIRED'
54
+ type Task = { id: string; contextId: string; status: { state: TaskState; message?: Message; timestamp: string }; artifacts?: unknown[]; history?: Message[]; metadata: Record<string, unknown> }
55
+ type Stored = { owner: string; task: Task; skill: string; args: Record<string, unknown>; created: number }
56
+
57
+ const TERMINAL = new Set<TaskState>(['TASK_STATE_COMPLETED', 'TASK_STATE_FAILED', 'TASK_STATE_CANCELED'])
58
+ const DAY = 86_400_000
59
+ const KIND = 'a2a-task'
60
+ const INDEX = 'a2a-tasks'
61
+
62
+ /** JSON-RPC and A2A error codes (spec §5.4, §9.5). */
63
+ const ERRORS = {
64
+ parse: [-32700, 'Invalid JSON payload'],
65
+ request: [-32600, 'Request payload validation error'],
66
+ method: [-32601, 'Method not found'],
67
+ params: [-32602, 'Invalid parameters'],
68
+ taskNotFound: [-32001, 'Task not found', 'TASK_NOT_FOUND'],
69
+ notCancelable: [-32002, 'Task cannot be canceled', 'TASK_NOT_CANCELABLE'],
70
+ noPush: [-32003, 'Push notifications are not supported', 'PUSH_NOTIFICATION_NOT_SUPPORTED'],
71
+ unsupported: [-32004, 'This operation is not supported', 'UNSUPPORTED_OPERATION'],
72
+ version: [-32009, 'This A2A version is not supported', 'VERSION_NOT_SUPPORTED'],
73
+ } as const
74
+
75
+ class RpcError extends Error {
76
+ constructor(readonly error: (typeof ERRORS)[keyof typeof ERRORS], readonly detail: string) {
77
+ super(detail)
78
+ }
79
+ }
80
+
81
+ const rpcError = (id: unknown, [code, message, reason]: readonly [number, string, string?], detail: string) => ({
82
+ jsonrpc: '2.0',
83
+ id: id ?? null,
84
+ error: { code, message, data: [{ '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason: reason ?? message.toUpperCase().replace(/\W+/g, '_'), domain: 'a2a-protocol.org', metadata: { detail } }] },
85
+ })
86
+
87
+ const agentMessage = (text: string, task: Pick<Task, 'id' | 'contextId'>): Message => ({ messageId: randomUUID(), role: 'ROLE_AGENT', parts: [{ text }], taskId: task.id, contextId: task.contextId })
88
+
89
+ /** The Agent Card (spec §4.4.1) for this origin. */
90
+ export const agentCard = (origin: string, { path = '/a2a', info, skills, oauth }: A2AConfig) => ({
91
+ name: info.name,
92
+ description: info.description,
93
+ supportedInterfaces: [{ url: origin + path, protocolBinding: 'JSONRPC', protocolVersion: A2A_VERSION }],
94
+ provider: { organization: info.organization ?? info.name, url: origin },
95
+ version: info.version,
96
+ documentationUrl: origin + WELL_KNOWN.guide,
97
+ capabilities: { streaming: false, pushNotifications: false, extendedAgentCard: false },
98
+ securitySchemes: {
99
+ ...(oauth
100
+ ? {
101
+ oauth: {
102
+ oauth2SecurityScheme: {
103
+ description: 'The owner approves each agent: register at /oauth/register (or follow /auth.md), then the authorization code flow with PKCE.',
104
+ flows: { authorizationCode: { authorizationUrl: `${origin}/oauth/authorize`, tokenUrl: `${origin}/oauth/token`, refreshUrl: `${origin}/oauth/token`, scopes: { read: 'Look things up.', write: 'Change saved state.' }, pkceRequired: true } },
105
+ oauth2MetadataUrl: `${origin}/.well-known/oauth-authorization-server`,
106
+ },
107
+ },
108
+ }
109
+ : {}),
110
+ apiKey: { httpAuthSecurityScheme: { description: 'An API key from the owner, as a bearer token.', scheme: 'Bearer' } },
111
+ },
112
+ securityRequirements: [...(oauth ? [{ schemes: { oauth: { list: ['read'] } } }] : []), { schemes: { apiKey: { list: [] } } }],
113
+ defaultInputModes: ['text/plain', 'application/json'],
114
+ defaultOutputModes: ['text/plain', 'application/json'],
115
+ skills: skills.map(({ tool, tags, examples }) => ({ id: tool.name, name: tool.title, description: tool.description, tags, ...(examples ? { examples } : {}) })),
116
+ })
117
+
118
+ /** Serves the Agent Card and the JSON-RPC endpoint. Mount after `createAgentApi`, whose `authenticate` guards `path`. */
119
+ export const mountA2A = (app: Hono<any, any, any>, config: A2AConfig) => {
120
+ const { path = '/a2a', skills, callApi, store = memoryStore(), waitMs = 60_000 } = config
121
+ // Each Worker isolate has its own memory: a task made in one is missing in the next
122
+ if (!config.store && globalThis.navigator?.userAgent === 'Cloudflare-Workers') console.warn('mountA2A: tasks are kept in memory; on Workers pass a store in the database (dbOAuthStore(db) fits)')
123
+ const byId = new Map(skills.map((skill) => [skill.tool.name, skill]))
124
+
125
+ app.get(WELL_KNOWN.agentCard, (c: Context) => sendPublic(c, JSON.stringify(agentCard(originOf(c), config), null, 2), 'application/json'))
126
+
127
+ const ownerOf = (c: Context) => {
128
+ const caller = c.get('caller' as never) as { kind: string; name?: string; email?: string } | undefined
129
+ return `${caller?.kind ?? 'anonymous'}:${caller?.name ?? caller?.email ?? ''}`
130
+ }
131
+ const authOf = (c: Context): AuthInfo => ({
132
+ token: '',
133
+ clientId: ownerOf(c),
134
+ scopes: [],
135
+ extra: {
136
+ headers: {
137
+ ...Object.fromEntries(FORWARDED.flatMap((name) => (c.req.header(name) ? [[name, c.req.header(name)!]] : []))),
138
+ 'x-forwarded-proto': new URL(originOf(c)).protocol.replace(':', ''),
139
+ 'x-forwarded-host': new URL(originOf(c)).host,
140
+ },
141
+ },
142
+ })
143
+
144
+ const save = async (owner: string, stored: Stored) => {
145
+ // The store takes epoch seconds
146
+ await store.put(KIND, stored.task.id, stored as never, Math.floor((stored.created + DAY) / 1000))
147
+ const index = ((await store.get(INDEX, owner))?.ids as string[] | undefined) ?? []
148
+ if (!index.includes(stored.task.id)) await store.put(INDEX, owner, { ids: [stored.task.id, ...index].slice(0, 500) }, Math.floor((Date.now() + DAY) / 1000))
149
+ }
150
+ const load = async (owner: string, id: unknown) => {
151
+ const stored = typeof id === 'string' ? ((await store.get(KIND, id)) as Stored | null) : null
152
+ if (!stored || stored.owner !== owner) throw new RpcError(ERRORS.taskNotFound, `No task ${String(id)} for this caller (tasks are kept for a day).`)
153
+ return stored
154
+ }
155
+ const shown = (stored: Stored, historyLength?: number) => {
156
+ const { history = [], ...task } = stored.task
157
+ return historyLength === 0 ? task : { ...task, history: historyLength ? history.slice(-historyLength) : history }
158
+ }
159
+
160
+ /** Runs (or asks again for) the task's tool call and records the outcome on the task. */
161
+ const run = async (c: Context, stored: Stored, wait: number) => {
162
+ const skill = byId.get(stored.skill)!
163
+ const { response, pending } = await callWaiting(callApi, skill.tool.call(stored.args as never), authOf(c), wait)
164
+ const json = await response.json().catch(() => null)
165
+ const at = new Date().toISOString()
166
+ const task = stored.task
167
+ if (pending) task.status = { state: 'TASK_STATE_WORKING', message: agentMessage(`Still working (run ${pending}). Ask again with GetTask.`, task), timestamp: at }
168
+ else if (response.ok) {
169
+ task.status = { state: 'TASK_STATE_COMPLETED', timestamp: at }
170
+ task.artifacts = [{ artifactId: randomUUID(), name: skill.tool.title, parts: [{ text: skill.tool.summarize(json, stored.args as never), mediaType: 'text/plain' }, { data: json, mediaType: 'application/json' }] }]
171
+ } else {
172
+ const state = response.status === 401 || response.status === 403 ? 'TASK_STATE_AUTH_REQUIRED' : 'TASK_STATE_FAILED'
173
+ task.status = { state, message: agentMessage(problemText(json, response.status), task), timestamp: at }
174
+ }
175
+ if (task.status.message) task.history = [...(task.history ?? []), task.status.message]
176
+ await save(stored.owner, stored)
177
+ return stored
178
+ }
179
+
180
+ const argsOf = (skill: A2ASkill, message: Message) => {
181
+ const data = message.parts.find((part) => part.data && typeof part.data === 'object' && !Array.isArray(part.data))?.data as Record<string, unknown> | undefined
182
+ const text = message.parts.flatMap((part) => (typeof part.text === 'string' ? [part.text] : [])).join('\n').trim()
183
+ const args = { ...(text ? { [skill.text]: text } : {}), ...data }
184
+ const parsed = skill.tool.input.safeParse(args)
185
+ if (!parsed.success) throw new RpcError(ERRORS.params, `${skill.tool.name}: ${parsed.error.issues.map((issue) => `${issue.path.join('.') || skill.text}: ${issue.message}`).join('; ')}`)
186
+ return parsed.data as Record<string, unknown>
187
+ }
188
+
189
+ const methods: Record<string, (c: Context, params: any) => Promise<unknown>> = {
190
+ SendMessage: async (c, params) => {
191
+ const message = params?.message as Message | undefined
192
+ if (!message || typeof message.messageId !== 'string' || message.role !== 'ROLE_USER' || !Array.isArray(message.parts) || !message.parts.length)
193
+ throw new RpcError(ERRORS.params, 'params.message needs messageId, role ROLE_USER and at least one part.')
194
+ const owner = ownerOf(c)
195
+ if (message.taskId) {
196
+ const stored = await load(owner, message.taskId)
197
+ if (TERMINAL.has(stored.task.status.state)) throw new RpcError(ERRORS.unsupported, `Task ${stored.task.id} is finished; send a new message without taskId.`)
198
+ return { task: shown(await run(c, stored, 0), params?.configuration?.historyLength) }
199
+ }
200
+ const name = (params?.metadata?.skill ?? message.metadata?.skill ?? skills[0]?.tool.name) as string
201
+ const skill = byId.get(name)
202
+ if (!skill) throw new RpcError(ERRORS.params, `Unknown skill ${name}. Skills: ${[...byId.keys()].join(', ')}.`)
203
+ const args = argsOf(skill, message)
204
+ const task: Task = { id: randomUUID(), contextId: message.contextId ?? randomUUID(), status: { state: 'TASK_STATE_WORKING', timestamp: new Date().toISOString() }, metadata: { skill: name } }
205
+ task.history = [{ ...message, taskId: task.id, contextId: task.contextId }]
206
+ const stored = await run(c, { owner, task, skill: name, args, created: Date.now() }, params?.configuration?.returnImmediately ? 0 : waitMs)
207
+ return { task: shown(stored, params?.configuration?.historyLength) }
208
+ },
209
+ GetTask: async (c, params) => {
210
+ const stored = await load(ownerOf(c), params?.id)
211
+ return shown(stored.task.status.state === 'TASK_STATE_WORKING' ? await run(c, stored, 0) : stored, params?.historyLength)
212
+ },
213
+ ListTasks: async (c, params) => {
214
+ const owner = ownerOf(c)
215
+ const ids = ((await store.get(INDEX, owner))?.ids as string[] | undefined) ?? []
216
+ const all = (await Promise.all(ids.map((id) => store.get(KIND, id) as Promise<Stored | null>))).filter((stored): stored is Stored => !!stored && stored.owner === owner)
217
+ const matching = all.filter((stored) => (!params?.contextId || stored.task.contextId === params.contextId) && (!params?.status || stored.task.status.state === params.status))
218
+ const pageSize = Math.min(Math.max(Number(params?.pageSize) || 50, 1), 100)
219
+ const start = Number(params?.pageToken) || 0
220
+ const page = matching.slice(start, start + pageSize)
221
+ return {
222
+ tasks: page.map((stored) => {
223
+ const task = shown(stored, params?.historyLength ?? 0) as Task
224
+ return params?.includeArtifacts ? task : { ...task, artifacts: undefined }
225
+ }),
226
+ nextPageToken: start + pageSize < matching.length ? String(start + pageSize) : '',
227
+ pageSize,
228
+ totalSize: matching.length,
229
+ }
230
+ },
231
+ CancelTask: async (c, params) => {
232
+ const stored = await load(ownerOf(c), params?.id)
233
+ if (TERMINAL.has(stored.task.status.state)) throw new RpcError(ERRORS.notCancelable, `Task ${stored.task.id} already ended (${stored.task.status.state}).`)
234
+ stored.task.status = { state: 'TASK_STATE_CANCELED', timestamp: new Date().toISOString() }
235
+ await save(stored.owner, stored)
236
+ return shown(stored)
237
+ },
238
+ }
239
+ const unsupported = ['SendStreamingMessage', 'SubscribeToTask', 'GetExtendedAgentCard']
240
+ const push = ['CreateTaskPushNotificationConfig', 'GetTaskPushNotificationConfig', 'ListTaskPushNotificationConfigs', 'DeleteTaskPushNotificationConfig']
241
+
242
+ app.post(path, async (c: Context) => {
243
+ const reply = (body: unknown) => c.json(body as never, 200)
244
+ const body = await c.req.json().catch(() => undefined)
245
+ if (body === undefined) return reply(rpcError(null, ERRORS.parse, 'The body is not JSON.'))
246
+ if (!body || typeof body !== 'object' || Array.isArray(body) || body.jsonrpc !== '2.0' || typeof body.method !== 'string')
247
+ return reply(rpcError(body?.id, ERRORS.request, 'Send one JSON-RPC 2.0 request: {"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{…}}.'))
248
+ // An absent version means 0.3 (spec §3.2.6); this endpoint speaks 1.0
249
+ const version = c.req.header('a2a-version') ?? c.req.query('A2A-Version') ?? '0.3'
250
+ if (version.split('.').slice(0, 2).join('.') !== A2A_VERSION) return reply(rpcError(body.id, ERRORS.version, `Send the header A2A-Version: ${A2A_VERSION}.`))
251
+ if (unsupported.includes(body.method)) return reply(rpcError(body.id, ERRORS.unsupported, `${body.method}: this agent answers SendMessage and GetTask only (no streaming, no extended card).`))
252
+ if (push.includes(body.method)) return reply(rpcError(body.id, ERRORS.noPush, 'Poll with GetTask instead.'))
253
+ const method = methods[body.method]
254
+ if (!method) return reply(rpcError(body.id, ERRORS.method, `Methods: ${Object.keys(methods).join(', ')}.`))
255
+ try {
256
+ return reply({ jsonrpc: '2.0', id: body.id ?? null, result: await method(c, body.params) })
257
+ } catch (error) {
258
+ if (error instanceof RpcError) return reply(rpcError(body.id, error.error, error.detail))
259
+ throw error
260
+ }
261
+ })
262
+ }
@@ -0,0 +1,35 @@
1
+ # boards
2
+
3
+ The owner's own collections of the app's items for a project, with a note per item: the part of a read-only catalog
4
+ you can write. Routes, MCP tools, and the reference shape of a kit feature.
5
+
6
+ Needs: `db`, `mcp`.
7
+
8
+ ```ts
9
+ import { boardTools, registerBoards } from './kit/boards/index.js'
10
+
11
+ await registerBoards(app, { db, item: RecipeCardSchema, refExample: 'recipe:soup', itemOf: (ref, origin) => cardOfRef(ref, origin), suggestions })
12
+ const tools = boardTools({ noun: 'recipes', label: (recipe) => recipe.title, suggestions: true })
13
+ ```
14
+
15
+ Routes: `GET|POST /api/boards` (`?item=<ref>`: the boards holding it), `GET|PATCH|DELETE /api/boards/{id}`,
16
+ `PUT|DELETE /api/boards/{id}/items/{ref}` (idempotent), `GET /api/boards/{id}/suggestions`. Writes need `write`.
17
+
18
+ ## The feature shape (copy it for your own features)
19
+
20
+ | File | What |
21
+ |---|---|
22
+ | `schema.ts` | the Drizzle tables and `BOARDS_MIGRATIONS` (step 1 = its tables) |
23
+ | `repo.ts` | `boardsRepo(db)`: the only queries, nothing about HTTP |
24
+ | `routes.ts` | `boardRoutes(config)`: the `createRoute` contracts and their schemas |
25
+ | `handlers.ts` | `boardHandlers(config, repo)`: request → repo → HAL; a missing board or item is a `ProblemError` |
26
+ | `tools.ts` | `boardTools`: the MCP tools over the routes |
27
+ | `refs.ts` | `boardRef(type, id)`, `parseBoardRef(ref)`, `BOARD_REF`: also copied with `boards-client` |
28
+ | `index.ts` | `registerBoards`: migrate, then wire routes to handlers |
29
+
30
+ ## Rules
31
+
32
+ - A ref is `<type>:<id>`; the id may hold colons (`segment:episode:intro:2`). Build and read refs with
33
+ `boardRef` / `parseBoardRef`, never by hand.
34
+ - An app whose boards also list other things (curated collections beside the owner's) keeps its own feature, in this shape.
35
+ - Standard: `docs/STANDARD.md` §17.
@@ -0,0 +1,87 @@
1
+ /** The board routes' handlers: request → repo → HAL answer; a missing board or item is a problem. No queries here. */
2
+ import type { RouteHandler } from '@hono/zod-openapi'
3
+ import { linksOf, originOf } from '../core/hal.js'
4
+ import { ProblemError } from '../core/problem.js'
5
+ import type { BoardsConfig } from './index.js'
6
+ import type { BoardRow, BoardsRepo } from './repo.js'
7
+ import type { BoardRoutes } from './routes.js'
8
+
9
+ export const boardHandlers = (config: BoardsConfig, repo: BoardsRepo) => {
10
+ const path = config.path ?? '/api/boards'
11
+ const rowOf = async (boardId: string) => {
12
+ const row = await repo.get(boardId)
13
+ if (!row) throw new ProblemError('not_found', { detail: `No board ${boardId}. GET ${path} lists them.` })
14
+ return row
15
+ }
16
+ const boardOf = async (origin: string, row: BoardRow, withItems = false) => ({
17
+ id: row.id,
18
+ name: row.name,
19
+ description: row.description,
20
+ items: row.items,
21
+ createdAt: row.createdAt,
22
+ updatedAt: row.updatedAt,
23
+ _links: linksOf(origin, {
24
+ self: `${path}/${row.id}`,
25
+ item: { path: `${path}/${row.id}/items/{ref}`, templated: true, title: 'PUT to add, DELETE to remove' },
26
+ suggestions: config.suggestions ? { path: `${path}/${row.id}/suggestions`, title: 'More like what is on it' } : null,
27
+ }),
28
+ ...(withItems ? { _embedded: { items: (await repo.items(row.id)).map((item) => ({ ...item, item: config.itemOf(item.ref, origin) ?? null })) } } : {}),
29
+ })
30
+
31
+ const list: RouteHandler<BoardRoutes['list']> = async (c) => {
32
+ const { item } = c.req.valid('query')
33
+ const rows = await repo.list(item)
34
+ const origin = originOf(c)
35
+ return c.json({ count: rows.length, _links: linksOf(origin, { self: `${path}${item ? `?item=${encodeURIComponent(item)}` : ''}`, create: { path, title: 'POST { name } to make one' } }), _embedded: { boards: await Promise.all(rows.map((row) => boardOf(origin, row))) } }, 200)
36
+ }
37
+
38
+ const create: RouteHandler<BoardRoutes['create']> = async (c) => {
39
+ const { name, description } = c.req.valid('json')
40
+ const boardId = await repo.create({ name: name.trim(), description: description?.trim() || null })
41
+ return c.json(await boardOf(originOf(c), await rowOf(boardId)), 201, { location: `${path}/${boardId}` })
42
+ }
43
+
44
+ const get: RouteHandler<BoardRoutes['get']> = async (c) => c.json(await boardOf(originOf(c), await rowOf(c.req.valid('param').id), true), 200)
45
+
46
+ const update: RouteHandler<BoardRoutes['update']> = async (c) => {
47
+ const { id: boardId } = c.req.valid('param')
48
+ const { name, description } = c.req.valid('json')
49
+ const row = await rowOf(boardId)
50
+ await repo.update(boardId, { name: name?.trim() || row.name, description: description === undefined ? row.description : description.trim() || null })
51
+ return c.json(await boardOf(originOf(c), await rowOf(boardId)), 200)
52
+ }
53
+
54
+ const remove: RouteHandler<BoardRoutes['remove']> = async (c) => {
55
+ const { id: boardId } = c.req.valid('param')
56
+ await rowOf(boardId)
57
+ await repo.remove(boardId)
58
+ return c.body(null, 204)
59
+ }
60
+
61
+ const save: RouteHandler<BoardRoutes['save']> = async (c) => {
62
+ const { id: boardId, ref: itemRef } = c.req.valid('param')
63
+ const { note } = ((await c.req.json().catch(() => ({}))) ?? {}) as { note?: string }
64
+ await rowOf(boardId)
65
+ if (config.itemOf(itemRef, originOf(c)) == null) throw new ProblemError('not_found', { detail: `No item ${itemRef} in the catalog. Add items by the ref their search results and links give.` })
66
+ const saved = await repo.save(boardId, itemRef, note?.trim() || null)
67
+ return c.json(await boardOf(originOf(c), await rowOf(boardId)), saved === 'added' ? 201 : 200)
68
+ }
69
+
70
+ const unsave: RouteHandler<BoardRoutes['unsave']> = async (c) => {
71
+ const { id: boardId, ref: itemRef } = c.req.valid('param')
72
+ await rowOf(boardId)
73
+ await repo.unsave(boardId, itemRef)
74
+ return c.body(null, 204)
75
+ }
76
+
77
+ const suggestions: RouteHandler<BoardRoutes['suggestions']> = async (c) => {
78
+ const { id: boardId } = c.req.valid('param')
79
+ await rowOf(boardId)
80
+ const refs = await repo.refs(boardId)
81
+ const origin = originOf(c)
82
+ const items = refs.length && config.suggestions ? config.suggestions(refs, c.req.valid('query').limit, origin) : []
83
+ return c.json({ count: items.length, _links: linksOf(origin, { self: `${path}/${boardId}/suggestions`, board: `${path}/${boardId}` }), _embedded: { items } }, 200)
84
+ }
85
+
86
+ return { list, create, get, update, remove, save, unsave, suggestions }
87
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Boards: the owner's own collections of the app's items for a project, the part of a catalog you can write. An item
3
+ * is addressed by a ref the app chooses (`recipe:<slug>`, `cook:<id>`); adding is idempotent (`PUT`) and every item
4
+ * can carry a note. Kept in the app's database (`db`, the same one as its OAuth state), read and written through:
5
+ * GET {path} every board, newest change first; `?item=<ref>` only the boards holding it
6
+ * POST {path} a new board → 201 with `Location`
7
+ * GET {path}/{id} a board with its items (each resolved to the app's card, null if it left)
8
+ * PATCH {path}/{id} rename or describe it
9
+ * DELETE {path}/{id} delete it (its items are only references)
10
+ * PUT {path}/{id}/items/{ref} add an item, or change its note
11
+ * DELETE {path}/{id}/items/{ref} take it off
12
+ * GET {path}/{id}/suggestions more like what is on it (when the app gives `suggestions`)
13
+ * Writes need the `write` scope (an OAuth token with it, a key, or the owner's browser on the same site).
14
+ *
15
+ * @example
16
+ * await registerBoards(app, { db: await openDb(`file:${stateFile}`), item: RecipeCardSchema, refExample: 'recipe:soup', itemOf: (ref, origin) => cardOfRef(ref, origin) })
17
+ * mcpServerFactory({ tools: [...boardTools({ noun: 'recipes', label: (recipe) => recipe.title }), …], … })
18
+ */
19
+ import type { OpenAPIHono, z } from '@hono/zod-openapi'
20
+ import { migrate, type Db } from '../db/index.js'
21
+ import { boardHandlers } from './handlers.js'
22
+ import { boardsRepo } from './repo.js'
23
+ import { boardRoutes } from './routes.js'
24
+ import { BOARDS_MIGRATIONS } from './schema.js'
25
+
26
+ export { BOARD_REF, boardRef, parseBoardRef } from './refs.js'
27
+ export { boardsRepo, type BoardRow, type BoardsRepo } from './repo.js'
28
+ export { boardRoutes, type BoardRoutes } from './routes.js'
29
+ export { boardItems, boards, BOARDS_MIGRATIONS } from './schema.js'
30
+ export { boardTools } from './tools.js'
31
+
32
+ export type BoardsConfig = {
33
+ /** Default `/api/boards`. */
34
+ path?: string
35
+ /** The app's database (`openDb`); the boards tables are migrated into it. */
36
+ db: Db
37
+ /** The OpenAPI schema of an item's card. */
38
+ item: z.ZodTypeAny
39
+ /** A ref → the app's card for it, or null when it left the catalog. */
40
+ itemOf: (ref: string, origin: string) => unknown
41
+ /** What a ref looks like (default `BOARD_REF`, `<type>:<id>`). */
42
+ refPattern?: RegExp
43
+ refExample: string
44
+ tag?: string
45
+ /** Cards for refs like those on a board, best first, leaving out what is on it. */
46
+ suggestions?: (refs: string[], limit: number, origin: string) => unknown[]
47
+ }
48
+
49
+ /**
50
+ * Registers the board routes on the app, after migrating the boards tables. Call it after `createAgentApi`.
51
+ */
52
+ export const registerBoards = async (app: OpenAPIHono, config: BoardsConfig) => {
53
+ await migrate(config.db, 'boards', BOARDS_MIGRATIONS)
54
+ const routes = boardRoutes(config)
55
+ const handle = boardHandlers(config, boardsRepo(config.db))
56
+ app.openapi(routes.list, handle.list)
57
+ app.openapi(routes.create, handle.create)
58
+ app.openapi(routes.get, handle.get)
59
+ app.openapi(routes.update, handle.update)
60
+ app.openapi(routes.remove, handle.remove)
61
+ app.openapi(routes.save, handle.save)
62
+ app.openapi(routes.unsave, handle.unsave)
63
+ if (config.suggestions) app.openapi(routes.suggestions, handle.suggestions)
64
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Board refs: how a board addresses an item, `<type>:<id>`. The type is a short lowercase word the app chooses
3
+ * (`recipe`, `segment`); the id is everything after the first colon and may hold colons itself
4
+ * (`segment:episode:intro:2`). Shared by `boards` (server) and `boards-client` (browser), so both read refs
5
+ * the same way.
6
+ *
7
+ * @example
8
+ * boardRef('recipe', 'soup') // 'recipe:soup'
9
+ * parseBoardRef('segment:episode:intro:2') // { type: 'segment', id: 'episode:intro:2' }
10
+ * parseBoardRef('fox') // null: no type
11
+ */
12
+
13
+ /** A whole ref: a type, a colon, an id of word characters, dots, colons and hyphens. */
14
+ export const BOARD_REF = /^([a-z][\w-]*):([\w.:-]+)$/
15
+
16
+ /** The ref for an item of this type. */
17
+ export const boardRef = (type: string, id: string) => `${type}:${id}`
18
+
19
+ /** A ref's type and id, or null when it isn't a ref. */
20
+ export const parseBoardRef = (ref: string): { type: string; id: string } | null => {
21
+ const match = BOARD_REF.exec(ref)
22
+ return match ? { type: match[1]!, id: match[2]! } : null
23
+ }
@@ -0,0 +1,50 @@
1
+ /** Boards in the database: the only place they are queried, and nothing about HTTP. */
2
+ import { randomUUID } from 'node:crypto'
3
+ import { and, desc, eq, exists, sql } from 'drizzle-orm'
4
+ import type { Db } from '../db/index.js'
5
+ import { boardItems, boards } from './schema.js'
6
+
7
+ export type BoardRow = typeof boards.$inferSelect & { items: number }
8
+
9
+ export const boardsRepo = (db: Db) => {
10
+ const columns = { id: boards.id, name: boards.name, description: boards.description, createdAt: boards.createdAt, updatedAt: boards.updatedAt, items: sql<number>`(select count(*) from ${boardItems} where ${boardItems.boardId} = ${boards.id})`.mapWith(Number) }
11
+ const now = () => new Date().toISOString()
12
+ const touch = (id: string) => db.update(boards).set({ updatedAt: now() }).where(eq(boards.id, id))
13
+ return {
14
+ /** Every board, newest change first; with a ref, only those holding it. */
15
+ list: (holding?: string): Promise<BoardRow[]> =>
16
+ db
17
+ .select(columns)
18
+ .from(boards)
19
+ .where(holding ? exists(db.select({ one: sql`1` }).from(boardItems).where(and(eq(boardItems.boardId, boards.id), eq(boardItems.ref, holding)))) : undefined)
20
+ .orderBy(desc(boards.updatedAt)),
21
+ get: async (id: string): Promise<BoardRow | undefined> => (await db.select(columns).from(boards).where(eq(boards.id, id)))[0],
22
+ create: async ({ name, description }: { name: string; description: string | null }) => {
23
+ const id = randomUUID()
24
+ const at = now()
25
+ await db.insert(boards).values({ id, name, description, createdAt: at, updatedAt: at })
26
+ return id
27
+ },
28
+ update: (id: string, change: { name: string; description: string | null }) => db.update(boards).set({ ...change, updatedAt: now() }).where(eq(boards.id, id)),
29
+ remove: async (id: string) => {
30
+ await db.delete(boardItems).where(eq(boardItems.boardId, id))
31
+ await db.delete(boards).where(eq(boards.id, id))
32
+ },
33
+ items: (id: string) => db.select({ ref: boardItems.ref, note: boardItems.note, addedAt: boardItems.addedAt }).from(boardItems).where(eq(boardItems.boardId, id)).orderBy(desc(boardItems.addedAt)),
34
+ refs: async (id: string) => (await db.select({ ref: boardItems.ref }).from(boardItems).where(eq(boardItems.boardId, id))).map((row) => row.ref),
35
+ /** Puts an item on a board, or changes its note; 'added' or 'noted'. */
36
+ save: async (id: string, ref: string, note: string | null): Promise<'added' | 'noted'> => {
37
+ const existed = (await db.select({ ref: boardItems.ref }).from(boardItems).where(and(eq(boardItems.boardId, id), eq(boardItems.ref, ref)))).length > 0
38
+ if (existed) await db.update(boardItems).set({ note: sql`coalesce(${note}, ${boardItems.note})` }).where(and(eq(boardItems.boardId, id), eq(boardItems.ref, ref)))
39
+ else await db.insert(boardItems).values({ boardId: id, ref, note, addedAt: now() })
40
+ await touch(id)
41
+ return existed ? 'noted' : 'added'
42
+ },
43
+ unsave: async (id: string, ref: string) => {
44
+ await db.delete(boardItems).where(and(eq(boardItems.boardId, id), eq(boardItems.ref, ref)))
45
+ await touch(id)
46
+ },
47
+ }
48
+ }
49
+
50
+ export type BoardsRepo = ReturnType<typeof boardsRepo>
@@ -0,0 +1,83 @@
1
+ /** The board routes as OpenAPI contracts: paths, parameters, bodies and answers, built around the app's item card. */
2
+ import { createRoute, z } from '@hono/zod-openapi'
3
+ import { HAL_JSON, LinksSchema } from '../core/hal.js'
4
+ import { problemResponses } from '../core/problem.js'
5
+ import type { BoardsConfig } from './index.js'
6
+ import { BOARD_REF } from './refs.js'
7
+
8
+ export const boardRoutes = (config: BoardsConfig) => {
9
+ const { path = '/api/boards', tag = 'Boards' } = config
10
+ const BoardItemSchema = z
11
+ .object({
12
+ ref: z.string().openapi({ description: 'The item’s address, e.g. `' + config.refExample + '`.', example: config.refExample }),
13
+ note: z.string().nullable().openapi({ description: 'Why it is here.' }),
14
+ addedAt: z.string(),
15
+ item: config.item.nullable().openapi({ description: 'The item’s card (null if it left the catalog).' }),
16
+ })
17
+ .openapi('BoardItem', { description: 'Something on a board, with its note.' })
18
+ const BoardSchema = z
19
+ .object({
20
+ id: z.string(),
21
+ name: z.string(),
22
+ description: z.string().nullable(),
23
+ items: z.number().int().openapi({ description: 'How many items are on it.' }),
24
+ createdAt: z.string(),
25
+ updatedAt: z.string(),
26
+ _links: LinksSchema,
27
+ _embedded: z.object({ items: z.array(BoardItemSchema) }).optional(),
28
+ })
29
+ .openapi('Board', { description: 'A board: a collection of items for a project, with notes.' })
30
+ const BoardListSchema = z.object({ count: z.number().int(), _links: LinksSchema, _embedded: z.object({ boards: z.array(BoardSchema) }) }).openapi('BoardList', { description: 'Boards, most recently changed first.' })
31
+ const BoardInputSchema = z.object({ name: z.string().min(1).max(120).openapi({ example: 'Bank identity references' }), description: z.string().max(1000).optional().openapi({ description: 'What the board is for.' }) }).openapi('BoardInput', { description: 'A board’s name and what it is for.' })
32
+ const BoardPatchSchema = BoardInputSchema.partial().openapi('BoardPatch', { description: 'A new name or description.' })
33
+ const NoteSchema = z.object({ note: z.string().max(1000).optional().openapi({ description: 'Why it is here.' }) }).openapi('BoardItemInput', { description: 'An optional note for the item.' })
34
+ const SuggestionsSchema = z.object({ count: z.number().int(), _links: LinksSchema, _embedded: z.object({ items: z.array(config.item) }) }).openapi('BoardSuggestions', { description: 'Items like those on a board, best first.' })
35
+ const id = z.string().min(1).max(80).openapi({ description: 'The board’s id.', example: '0b2e4c1a-5d1f-4b3e-9a51-7c8f2e6d1a90' })
36
+ const ref = z.string().regex(config.refPattern ?? BOARD_REF).openapi({ description: 'The item’s ref, e.g. `' + config.refExample + '`.', example: config.refExample })
37
+ const ok = <Schema extends z.ZodTypeAny>(description: string, schema: Schema) => ({ content: { [HAL_JSON]: { schema } }, description })
38
+
39
+ return {
40
+ list: createRoute({
41
+ method: 'get', path, operationId: 'listBoards', tags: [tag], summary: 'Boards', description: 'Every board with how many items it holds, most recently changed first. `item` keeps only the boards that hold that item (to show where something is saved).',
42
+ request: { query: z.object({ item: ref.optional().openapi({ description: 'Only boards holding this ref.' }) }) },
43
+ responses: { 200: ok('Boards.', BoardListSchema), ...problemResponses(400, 401) },
44
+ }),
45
+ create: createRoute({
46
+ method: 'post', path, operationId: 'createBoard', tags: [tag], summary: 'Make a board', description: 'A new, empty board with a name and what it is for. Answers 201 with its address in `Location`.',
47
+ request: { body: { required: true, content: { 'application/json': { schema: BoardInputSchema } } } },
48
+ responses: { 201: ok('The new board.', BoardSchema), ...problemResponses(400, 401, 403) },
49
+ }),
50
+ get: createRoute({
51
+ method: 'get', path: `${path}/{id}`, operationId: 'getBoard', tags: [tag], summary: 'One board with its items', description: 'A board and everything on it, newest first, each with its note and the item’s card (null if it left the catalog).',
52
+ request: { params: z.object({ id }) },
53
+ responses: { 200: ok('The board.', BoardSchema), ...problemResponses(401, 404) },
54
+ }),
55
+ update: createRoute({
56
+ method: 'patch', path: `${path}/{id}`, operationId: 'updateBoard', tags: [tag], summary: 'Rename or describe a board', description: 'Changes a board’s name, its description, or both.',
57
+ request: { params: z.object({ id }), body: { required: true, content: { 'application/json': { schema: BoardPatchSchema } } } },
58
+ responses: { 200: ok('The board.', BoardSchema), ...problemResponses(400, 401, 403, 404) },
59
+ }),
60
+ remove: createRoute({
61
+ method: 'delete', path: `${path}/{id}`, operationId: 'deleteBoard', tags: [tag], summary: 'Delete a board', description: 'Deletes the board and its notes. The items themselves stay in the catalog.',
62
+ request: { params: z.object({ id }) },
63
+ responses: { 204: { description: 'Deleted.' }, ...problemResponses(401, 403, 404) },
64
+ }),
65
+ save: createRoute({
66
+ method: 'put', path: `${path}/{id}/items/{ref}`, operationId: 'putBoardItem', tags: [tag], summary: 'Add an item to a board', description: 'Adds the item, or changes its note when it is already there; doing it twice is harmless. 201 when added, 200 when only the note changed.',
67
+ request: { params: z.object({ id, ref }), body: { required: false, content: { 'application/json': { schema: NoteSchema } } } },
68
+ responses: { 200: ok('The board.', BoardSchema), 201: ok('The board.', BoardSchema), ...problemResponses(400, 401, 403, 404) },
69
+ }),
70
+ unsave: createRoute({
71
+ method: 'delete', path: `${path}/{id}/items/{ref}`, operationId: 'deleteBoardItem', tags: [tag], summary: 'Take an item off a board', description: 'Removes the item and its note from the board; harmless when it was not there.',
72
+ request: { params: z.object({ id, ref }) },
73
+ responses: { 204: { description: 'Removed.' }, ...problemResponses(400, 401, 403, 404) },
74
+ }),
75
+ suggestions: createRoute({
76
+ method: 'get', path: `${path}/{id}/suggestions`, operationId: 'listBoardSuggestions', tags: [tag], summary: 'More like what is on a board', description: 'Items like the ones on the board, best first, leaving out what is already on it. Free.',
77
+ request: { params: z.object({ id }), query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(24).openapi({ description: 'At most this many (1–100, default 24).' }) }) },
78
+ responses: { 200: ok('Suggestions.', SuggestionsSchema), ...problemResponses(400, 401, 404) },
79
+ }),
80
+ }
81
+ }
82
+
83
+ export type BoardRoutes = ReturnType<typeof boardRoutes>
@@ -0,0 +1,28 @@
1
+ /** The boards tables, and the SQL that makes them (one migration step per change, never edited once shipped). */
2
+ import { index, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
3
+
4
+ export const boards = sqliteTable('boards', {
5
+ id: text('id').primaryKey(),
6
+ name: text('name').notNull(),
7
+ description: text('description'),
8
+ createdAt: text('created_at').notNull(),
9
+ updatedAt: text('updated_at').notNull(),
10
+ })
11
+
12
+ export const boardItems = sqliteTable(
13
+ 'board_items',
14
+ {
15
+ boardId: text('board_id').notNull().references(() => boards.id, { onDelete: 'cascade' }),
16
+ ref: text('ref').notNull(),
17
+ note: text('note'),
18
+ addedAt: text('added_at').notNull(),
19
+ },
20
+ (table) => [primaryKey({ columns: [table.boardId, table.ref] }), index('board_items_ref').on(table.ref)],
21
+ )
22
+
23
+ /** The tables as SQL, one step per change, never edited once shipped. Step 1 is what every app's state already holds. */
24
+ export const BOARDS_MIGRATIONS = [
25
+ `create table if not exists boards (id text primary key, name text not null, description text, created_at text not null, updated_at text not null);
26
+ create table if not exists board_items (board_id text not null references boards(id) on delete cascade, ref text not null, note text, added_at text not null, primary key (board_id, ref));
27
+ create index if not exists board_items_ref on board_items(ref)`,
28
+ ] as const