The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| 4 | ColineAppV2, |
| 5 | ColineFileRenderContextV2, |
| 6 | ColineHomeRenderContextV2, |
| 7 | ColineScheduleContextV2, |
| 8 | ColineToolContext, |
| 9 | } from "../define/types"; |
| 10 | import { validateChatCardTree } from "../tree/chat-card"; |
| 11 | import { |
| 12 | ColineProtocolError, |
| 13 | parseSignedProtocolRequest, |
| 14 | type ColineProtocolEnvelope, |
| 15 | } from "./protocol"; |
| 16 | import { |
| 17 | RENDER_SURFACE_KIND, |
| 18 | renderSurfacePayloadSchema, |
| 19 | } from "./render-surface"; |
| 20 | import { |
| 21 | TOOL_INVOKE_KIND, |
| 22 | toolInvokePayloadSchema, |
| 23 | } from "./tool-invoke"; |
| 24 | import { |
| 25 | TRIGGER_SCHEDULE_KIND, |
| 26 | triggerSchedulePayloadSchema, |
| 27 | } from "./trigger-schedule"; |
| 28 | import { |
| 29 | ENDPOINT_REQUEST_KIND, |
| 30 | JOB_RUN_KIND, |
| 31 | RENDER_PUBLIC_KIND, |
| 32 | TRIGGER_EVENT_KIND, |
| 33 | endpointRequestPayloadSchema, |
| 34 | jobRunPayloadSchema, |
| 35 | renderPublicPayloadSchema, |
| 36 | toEndpointResponse, |
| 37 | toJobRunResult, |
| 38 | triggerEventPayloadSchema, |
| 39 | } from "./kinds"; |
| 40 | import { createJobRunInfo, parseEndpointJson } from "./runtime-helpers"; |
| 41 |
| 42 | /** |
| 43 | * The external app server handler (plan §10.3, §9.3): ONE handler serves |
| 44 | * every Coline → app-backend request kind. Mount it at |
| 45 | * `POST {baseUrl}/coline/app`. |
| 46 | * |
| 47 | * ```ts |
| 48 | * // app/coline/app/route.ts (Next.js) — or any fetch-shaped server |
| 49 | * import app from "../../../app.config"; |
| 50 | * import { createColineAppHandler } from "@colineapp/sdk/v2"; |
| 51 | * |
| 52 | * export const POST = createColineAppHandler({ |
| 53 | * app, |
| 54 | * secret: process.env.COLINE_DELIVERY_SECRET!, |
| 55 | * }); |
| 56 | * ``` |
| 57 | * |
| 58 | * Kinds handled: |
| 59 | * - `tool.invoke` → the tool's `execute` with a capability-bound context |
| 60 | * - `render.surface` → the file type's tree render handlers, or `renderHome` |
| 61 | * |
| 62 | * Render contexts carry a read-ceiling invocation token: a render handler |
| 63 | * that attempts a mutation fails at Coline's capability gate, not here. |
| 64 | */ |
| 65 |
| 66 | interface WireError { |
| 67 | error: { code: string; message: string }; |
| 68 | } |
| 69 |
| 70 | function json(body: unknown, status: number): Response { |
| 71 | return new Response(JSON.stringify(body), { |
| 72 | status, |
| 73 | headers: { "content-type": "application/json" }, |
| 74 | }); |
| 75 | } |
| 76 |
| 77 | function wireError(code: string, message: string, status: number): Response { |
| 78 | const body: WireError = { error: { code, message } }; |
| 79 | return json(body, status); |
| 80 | } |
| 81 |
| 82 | function actorFromEnvelope(envelope: ColineProtocolEnvelope): ColineActorRef { |
| 83 | return envelope.actor.kind === "user" |
| 84 | ? { kind: "user", userId: envelope.actor.userId } |
| 85 | : { kind: "app" }; |
| 86 | } |
| 87 |
| 88 | async function handleToolInvoke( |
| 89 | app: ColineAppV2, |
| 90 | envelope: ColineProtocolEnvelope, |
| 91 | ): Promise<Response> { |
| 92 | const payload = toolInvokePayloadSchema.safeParse(envelope.payload); |
| 93 | if (!payload.success) { |
| 94 | return wireError("invalid_payload", "tool.invoke payload is invalid.", 400); |
| 95 | } |
| 96 |
| 97 | const tool = app.runtime.tools.get(payload.data.tool); |
| 98 | if (!tool) { |
| 99 | return wireError( |
| 100 | "unknown_tool", |
| 101 | `App "${app.manifest.key}" does not define a tool named "${payload.data.tool}".`, |
| 102 | 404, |
| 103 | ); |
| 104 | } |
| 105 |
| 106 | const input = tool.input.safeParse(payload.data.input); |
| 107 | if (!input.success) { |
| 108 | return wireError( |
| 109 | "invalid_input", |
| 110 | `Tool input is invalid: ${input.error.issues |
| 111 | .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) |
| 112 | .join("; ")}`, |
| 113 | 400, |
| 114 | ); |
| 115 | } |
| 116 |
| 117 | const context: ColineToolContext = { |
| 118 | coline: createHttpCapabilities(payload.data.capabilities), |
| 119 | workspace: { |
| 120 | workspaceId: envelope.workspace.workspaceId, |
| 121 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 122 | workspaceName: envelope.workspace.workspaceSlug, |
| 123 | }, |
| 124 | actor: actorFromEnvelope(envelope), |
| 125 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 126 | }; |
| 127 |
| 128 | try { |
| 129 | const result = await tool.execute(input.data as never, context); |
| 130 | if (result.card) { |
| 131 | const validation = validateChatCardTree(result.card); |
| 132 | if (!validation.ok) { |
| 133 | return wireError("invalid_card", `Tool result card rejected: ${validation.reason}`, 400); |
| 134 | } |
| 135 | } |
| 136 | return json( |
| 137 | { |
| 138 | result: { |
| 139 | ...(result.output !== undefined ? { output: result.output } : {}), |
| 140 | ...(result.card ? { card: result.card } : {}), |
| 141 | }, |
| 142 | }, |
| 143 | 200, |
| 144 | ); |
| 145 | } catch (error) { |
| 146 | return wireError( |
| 147 | "tool_execution_failed", |
| 148 | error instanceof Error ? error.message : String(error), |
| 149 | 500, |
| 150 | ); |
| 151 | } |
| 152 | } |
| 153 |
| 154 | async function handleRenderSurface( |
| 155 | app: ColineAppV2, |
| 156 | envelope: ColineProtocolEnvelope, |
| 157 | ): Promise<Response> { |
| 158 | const payload = renderSurfacePayloadSchema.safeParse(envelope.payload); |
| 159 | if (!payload.success) { |
| 160 | return wireError("invalid_payload", "render.surface payload is invalid.", 400); |
| 161 | } |
| 162 |
| 163 | const coline = createHttpCapabilities(payload.data.capabilities); |
| 164 | const base = { |
| 165 | coline, |
| 166 | workspace: { |
| 167 | workspaceId: envelope.workspace.workspaceId, |
| 168 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 169 | workspaceName: envelope.workspace.workspaceSlug, |
| 170 | }, |
| 171 | actor: envelope.actor.kind === "user" ? actorFromEnvelope(envelope) : null, |
| 172 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 173 | }; |
| 174 |
| 175 | try { |
| 176 | if (payload.data.surface === "home") { |
| 177 | if (!app.runtime.renderHome) { |
| 178 | return wireError("unknown_surface", "This app has no tree home surface.", 404); |
| 179 | } |
| 180 | const context: ColineHomeRenderContextV2 = base; |
| 181 | const tree = await app.runtime.renderHome(context); |
| 182 | return json({ result: { tree } }, 200); |
| 183 | } |
| 184 |
| 185 | const file = payload.data.file; |
| 186 | if (!file) { |
| 187 | return wireError("invalid_payload", "File surfaces require a file reference.", 400); |
| 188 | } |
| 189 | const runtime = app.runtime.fileTypes.get(file.typeKey); |
| 190 | const handler = |
| 191 | payload.data.surface === "editor" |
| 192 | ? runtime?.renderEditor |
| 193 | : payload.data.surface === "preview" |
| 194 | ? runtime?.renderPreview |
| 195 | : runtime?.renderInline; |
| 196 | if (!handler) { |
| 197 | return wireError( |
| 198 | "unknown_surface", |
| 199 | `File type "${file.typeKey}" has no ${payload.data.surface} handler.`, |
| 200 | 404, |
| 201 | ); |
| 202 | } |
| 203 |
| 204 | const context: ColineFileRenderContextV2 = { |
| 205 | ...base, |
| 206 | file, |
| 207 | document: payload.data.document ?? {}, |
| 208 | }; |
| 209 | const tree = await handler(context); |
| 210 |
| 211 | if (payload.data.surface === "inline") { |
| 212 | const validation = validateChatCardTree(tree); |
| 213 | if (!validation.ok) { |
| 214 | return wireError("invalid_tree", `Inline surface rejected: ${validation.reason}`, 400); |
| 215 | } |
| 216 | } |
| 217 | return json({ result: { tree } }, 200); |
| 218 | } catch (error) { |
| 219 | return wireError( |
| 220 | "render_failed", |
| 221 | error instanceof Error ? error.message : String(error), |
| 222 | 500, |
| 223 | ); |
| 224 | } |
| 225 | } |
| 226 |
| 227 | async function handleTriggerSchedule( |
| 228 | app: ColineAppV2, |
| 229 | envelope: ColineProtocolEnvelope, |
| 230 | ): Promise<Response> { |
| 231 | const payload = triggerSchedulePayloadSchema.safeParse(envelope.payload); |
| 232 | if (!payload.success) { |
| 233 | return wireError("invalid_payload", "trigger.schedule payload is invalid.", 400); |
| 234 | } |
| 235 | if (!app.runtime.onSchedule) { |
| 236 | return wireError("unknown_schedule", "This app has no schedule handler.", 404); |
| 237 | } |
| 238 | const context: ColineScheduleContextV2 = { |
| 239 | coline: createHttpCapabilities(payload.data.capabilities), |
| 240 | workspace: { |
| 241 | workspaceId: envelope.workspace.workspaceId, |
| 242 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 243 | workspaceName: envelope.workspace.workspaceSlug, |
| 244 | }, |
| 245 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 246 | scheduleId: payload.data.scheduleId, |
| 247 | firedAt: payload.data.firedAt, |
| 248 | }; |
| 249 | try { |
| 250 | await app.runtime.onSchedule(context); |
| 251 | return json({ result: { ok: true } }, 200); |
| 252 | } catch (error) { |
| 253 | return wireError( |
| 254 | "schedule_failed", |
| 255 | error instanceof Error ? error.message : String(error), |
| 256 | 500, |
| 257 | ); |
| 258 | } |
| 259 | } |
| 260 |
| 261 | function workspaceFromEnvelope(envelope: ColineProtocolEnvelope) { |
| 262 | return { |
| 263 | workspaceId: envelope.workspace.workspaceId, |
| 264 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 265 | workspaceName: envelope.workspace.workspaceSlug, |
| 266 | }; |
| 267 | } |
| 268 |
| 269 | function failure(code: string, error: unknown, status = 500): Response { |
| 270 | const explicit = |
| 271 | error && typeof error === "object" && "code" in error && typeof error.code === "string" |
| 272 | ? error.code |
| 273 | : code; |
| 274 | return wireError(explicit, error instanceof Error ? error.message : String(error), status); |
| 275 | } |
| 276 |
| 277 | async function handleTriggerEvent( |
| 278 | app: ColineAppV2, |
| 279 | envelope: ColineProtocolEnvelope, |
| 280 | ): Promise<Response> { |
| 281 | const payload = triggerEventPayloadSchema.safeParse(envelope.payload); |
| 282 | if (!payload.success) { |
| 283 | return wireError("invalid_payload", "trigger.event payload is invalid.", 400); |
| 284 | } |
| 285 | if (!app.runtime.onEvent) { |
| 286 | return wireError("unknown_event_handler", "This app has no onEvent handler.", 404); |
| 287 | } |
| 288 | try { |
| 289 | await app.runtime.onEvent({ |
| 290 | coline: createHttpCapabilities(payload.data.capabilities), |
| 291 | workspace: workspaceFromEnvelope(envelope), |
| 292 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 293 | event: payload.data.event, |
| 294 | attempt: payload.data.attempt, |
| 295 | }); |
| 296 | return json({ result: { ok: true } }, 200); |
| 297 | } catch (error) { |
| 298 | return failure("event_failed", error); |
| 299 | } |
| 300 | } |
| 301 |
| 302 | async function handleEndpointRequest( |
| 303 | app: ColineAppV2, |
| 304 | envelope: ColineProtocolEnvelope, |
| 305 | ): Promise<Response> { |
| 306 | const payload = endpointRequestPayloadSchema.safeParse(envelope.payload); |
| 307 | if (!payload.success) { |
| 308 | return wireError("invalid_payload", "endpoint.request payload is invalid.", 400); |
| 309 | } |
| 310 | const request = payload.data.request; |
| 311 | const handler = app.runtime.endpoints.get(request.endpointId); |
| 312 | if (!handler) { |
| 313 | return wireError("unknown_endpoint", `This app has no endpoint "${request.endpointId}".`, 404); |
| 314 | } |
| 315 | try { |
| 316 | const response = await handler({ |
| 317 | coline: createHttpCapabilities(payload.data.capabilities), |
| 318 | workspace: workspaceFromEnvelope(envelope), |
| 319 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 320 | request, |
| 321 | json: <T>() => parseEndpointJson<T>(request.body), |
| 322 | }); |
| 323 | return json({ result: toEndpointResponse(response) }, 200); |
| 324 | } catch (error) { |
| 325 | return failure("endpoint_failed", error); |
| 326 | } |
| 327 | } |
| 328 |
| 329 | async function handleJobRun(app: ColineAppV2, envelope: ColineProtocolEnvelope): Promise<Response> { |
| 330 | const payload = jobRunPayloadSchema.safeParse(envelope.payload); |
| 331 | if (!payload.success) { |
| 332 | return wireError("invalid_payload", "job.run payload is invalid.", 400); |
| 333 | } |
| 334 | const definition = app.runtime.jobs.get(payload.data.job.name); |
| 335 | if (!definition) { |
| 336 | return wireError("unknown_job", `This app has no job "${payload.data.job.name}".`, 404); |
| 337 | } |
| 338 | let input: unknown = payload.data.job.payload; |
| 339 | if (definition.payload) { |
| 340 | const parsed = definition.payload.safeParse(input); |
| 341 | if (!parsed.success) { |
| 342 | return wireError("invalid_job_payload", "Job payload failed schema validation.", 400); |
| 343 | } |
| 344 | input = parsed.data; |
| 345 | } |
| 346 | const coline = createHttpCapabilities(payload.data.capabilities); |
| 347 | try { |
| 348 | const outcome = await definition.run(input as never, { |
| 349 | coline, |
| 350 | workspace: workspaceFromEnvelope(envelope), |
| 351 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 352 | actor: payload.data.actor, |
| 353 | job: createJobRunInfo(payload.data.capabilities, payload.data.job), |
| 354 | }); |
| 355 | return json({ result: toJobRunResult(outcome) }, 200); |
| 356 | } catch (error) { |
| 357 | return failure("job_failed", error); |
| 358 | } |
| 359 | } |
| 360 |
| 361 | async function handleRenderPublic( |
| 362 | app: ColineAppV2, |
| 363 | envelope: ColineProtocolEnvelope, |
| 364 | ): Promise<Response> { |
| 365 | const payload = renderPublicPayloadSchema.safeParse(envelope.payload); |
| 366 | if (!payload.success) { |
| 367 | return wireError("invalid_payload", "render.public payload is invalid.", 400); |
| 368 | } |
| 369 | if (!app.runtime.renderPublic) { |
| 370 | return wireError("unknown_surface", "This app has no tree public surface.", 404); |
| 371 | } |
| 372 | try { |
| 373 | const tree = await app.runtime.renderPublic({ |
| 374 | coline: createHttpCapabilities(payload.data.capabilities), |
| 375 | workspace: workspaceFromEnvelope(envelope), |
| 376 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 377 | path: payload.data.path, |
| 378 | query: payload.data.query, |
| 379 | visitor: payload.data.visitor, |
| 380 | }); |
| 381 | return json({ result: { tree } }, 200); |
| 382 | } catch (error) { |
| 383 | return failure("render_failed", error); |
| 384 | } |
| 385 | } |
| 386 |
| 387 | export function createColineAppHandler(params: { |
| 388 | app: ColineAppV2; |
| 389 | secret: string; |
| 390 | }): (request: Request) => Promise<Response> { |
| 391 | return async (request) => { |
| 392 | let envelope: ColineProtocolEnvelope; |
| 393 | try { |
| 394 | const parsed = await parseSignedProtocolRequest({ |
| 395 | request, |
| 396 | secret: params.secret, |
| 397 | }); |
| 398 | envelope = parsed.envelope; |
| 399 | } catch (error) { |
| 400 | if (error instanceof ColineProtocolError) { |
| 401 | return wireError(error.code, error.message, 401); |
| 402 | } |
| 403 | throw error; |
| 404 | } |
| 405 |
| 406 | switch (envelope.kind) { |
| 407 | case TOOL_INVOKE_KIND: |
| 408 | return handleToolInvoke(params.app, envelope); |
| 409 | case RENDER_SURFACE_KIND: |
| 410 | return handleRenderSurface(params.app, envelope); |
| 411 | case TRIGGER_SCHEDULE_KIND: |
| 412 | return handleTriggerSchedule(params.app, envelope); |
| 413 | case TRIGGER_EVENT_KIND: |
| 414 | return handleTriggerEvent(params.app, envelope); |
| 415 | case ENDPOINT_REQUEST_KIND: |
| 416 | return handleEndpointRequest(params.app, envelope); |
| 417 | case JOB_RUN_KIND: |
| 418 | return handleJobRun(params.app, envelope); |
| 419 | case RENDER_PUBLIC_KIND: |
| 420 | return handleRenderPublic(params.app, envelope); |
| 421 | default: |
| 422 | return wireError( |
| 423 | "unsupported_kind", |
| 424 | `This SDK version does not handle "${envelope.kind}". Upgrade @colineapp/sdk.`, |
| 425 | 400, |
| 426 | ); |
| 427 | } |
| 428 | }; |
| 429 | } |
| 430 |