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 |
| 29 | /** |
| 30 | * The external app server handler (plan §10.3, §9.3): ONE handler serves |
| 31 | * every Coline → app-backend request kind. Mount it at |
| 32 | * `POST {baseUrl}/coline/app`. |
| 33 | * |
| 34 | * ```ts |
| 35 | * // app/coline/app/route.ts (Next.js) — or any fetch-shaped server |
| 36 | * import app from "../../../app.config"; |
| 37 | * import { createColineAppHandler } from "@colineapp/sdk/v2"; |
| 38 | * |
| 39 | * export const POST = createColineAppHandler({ |
| 40 | * app, |
| 41 | * secret: process.env.COLINE_DELIVERY_SECRET!, |
| 42 | * }); |
| 43 | * ``` |
| 44 | * |
| 45 | * Kinds handled: |
| 46 | * - `tool.invoke` → the tool's `execute` with a capability-bound context |
| 47 | * - `render.surface` → the file type's tree render handlers, or `renderHome` |
| 48 | * |
| 49 | * Render contexts carry a read-ceiling invocation token: a render handler |
| 50 | * that attempts a mutation fails at Coline's capability gate, not here. |
| 51 | */ |
| 52 |
| 53 | interface WireError { |
| 54 | error: { code: string; message: string }; |
| 55 | } |
| 56 |
| 57 | function json(body: unknown, status: number): Response { |
| 58 | return new Response(JSON.stringify(body), { |
| 59 | status, |
| 60 | headers: { "content-type": "application/json" }, |
| 61 | }); |
| 62 | } |
| 63 |
| 64 | function wireError(code: string, message: string, status: number): Response { |
| 65 | const body: WireError = { error: { code, message } }; |
| 66 | return json(body, status); |
| 67 | } |
| 68 |
| 69 | function actorFromEnvelope(envelope: ColineProtocolEnvelope): ColineActorRef { |
| 70 | return envelope.actor.kind === "user" |
| 71 | ? { kind: "user", userId: envelope.actor.userId } |
| 72 | : { kind: "app" }; |
| 73 | } |
| 74 |
| 75 | async function handleToolInvoke( |
| 76 | app: ColineAppV2, |
| 77 | envelope: ColineProtocolEnvelope, |
| 78 | ): Promise<Response> { |
| 79 | const payload = toolInvokePayloadSchema.safeParse(envelope.payload); |
| 80 | if (!payload.success) { |
| 81 | return wireError("invalid_payload", "tool.invoke payload is invalid.", 400); |
| 82 | } |
| 83 |
| 84 | const tool = app.runtime.tools.get(payload.data.tool); |
| 85 | if (!tool) { |
| 86 | return wireError( |
| 87 | "unknown_tool", |
| 88 | `App "${app.manifest.key}" does not define a tool named "${payload.data.tool}".`, |
| 89 | 404, |
| 90 | ); |
| 91 | } |
| 92 |
| 93 | const input = tool.input.safeParse(payload.data.input); |
| 94 | if (!input.success) { |
| 95 | return wireError( |
| 96 | "invalid_input", |
| 97 | `Tool input is invalid: ${input.error.issues |
| 98 | .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) |
| 99 | .join("; ")}`, |
| 100 | 400, |
| 101 | ); |
| 102 | } |
| 103 |
| 104 | const context: ColineToolContext = { |
| 105 | coline: createHttpCapabilities(payload.data.capabilities), |
| 106 | workspace: { |
| 107 | workspaceId: envelope.workspace.workspaceId, |
| 108 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 109 | workspaceName: envelope.workspace.workspaceSlug, |
| 110 | }, |
| 111 | actor: actorFromEnvelope(envelope), |
| 112 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 113 | }; |
| 114 |
| 115 | try { |
| 116 | const result = await tool.execute(input.data as never, context); |
| 117 | if (result.card) { |
| 118 | const validation = validateChatCardTree(result.card); |
| 119 | if (!validation.ok) { |
| 120 | return wireError("invalid_card", `Tool result card rejected: ${validation.reason}`, 400); |
| 121 | } |
| 122 | } |
| 123 | return json( |
| 124 | { |
| 125 | result: { |
| 126 | ...(result.output !== undefined ? { output: result.output } : {}), |
| 127 | ...(result.card ? { card: result.card } : {}), |
| 128 | }, |
| 129 | }, |
| 130 | 200, |
| 131 | ); |
| 132 | } catch (error) { |
| 133 | return wireError( |
| 134 | "tool_execution_failed", |
| 135 | error instanceof Error ? error.message : String(error), |
| 136 | 500, |
| 137 | ); |
| 138 | } |
| 139 | } |
| 140 |
| 141 | async function handleRenderSurface( |
| 142 | app: ColineAppV2, |
| 143 | envelope: ColineProtocolEnvelope, |
| 144 | ): Promise<Response> { |
| 145 | const payload = renderSurfacePayloadSchema.safeParse(envelope.payload); |
| 146 | if (!payload.success) { |
| 147 | return wireError("invalid_payload", "render.surface payload is invalid.", 400); |
| 148 | } |
| 149 |
| 150 | const coline = createHttpCapabilities(payload.data.capabilities); |
| 151 | const base = { |
| 152 | coline, |
| 153 | workspace: { |
| 154 | workspaceId: envelope.workspace.workspaceId, |
| 155 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 156 | workspaceName: envelope.workspace.workspaceSlug, |
| 157 | }, |
| 158 | actor: envelope.actor.kind === "user" ? actorFromEnvelope(envelope) : null, |
| 159 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 160 | }; |
| 161 |
| 162 | try { |
| 163 | if (payload.data.surface === "home") { |
| 164 | if (!app.runtime.renderHome) { |
| 165 | return wireError("unknown_surface", "This app has no tree home surface.", 404); |
| 166 | } |
| 167 | const context: ColineHomeRenderContextV2 = base; |
| 168 | const tree = await app.runtime.renderHome(context); |
| 169 | return json({ result: { tree } }, 200); |
| 170 | } |
| 171 |
| 172 | const file = payload.data.file; |
| 173 | if (!file) { |
| 174 | return wireError("invalid_payload", "File surfaces require a file reference.", 400); |
| 175 | } |
| 176 | const runtime = app.runtime.fileTypes.get(file.typeKey); |
| 177 | const handler = |
| 178 | payload.data.surface === "editor" |
| 179 | ? runtime?.renderEditor |
| 180 | : payload.data.surface === "preview" |
| 181 | ? runtime?.renderPreview |
| 182 | : runtime?.renderInline; |
| 183 | if (!handler) { |
| 184 | return wireError( |
| 185 | "unknown_surface", |
| 186 | `File type "${file.typeKey}" has no ${payload.data.surface} handler.`, |
| 187 | 404, |
| 188 | ); |
| 189 | } |
| 190 |
| 191 | const context: ColineFileRenderContextV2 = { |
| 192 | ...base, |
| 193 | file, |
| 194 | document: payload.data.document ?? {}, |
| 195 | }; |
| 196 | const tree = await handler(context); |
| 197 |
| 198 | if (payload.data.surface === "inline") { |
| 199 | const validation = validateChatCardTree(tree); |
| 200 | if (!validation.ok) { |
| 201 | return wireError("invalid_tree", `Inline surface rejected: ${validation.reason}`, 400); |
| 202 | } |
| 203 | } |
| 204 | return json({ result: { tree } }, 200); |
| 205 | } catch (error) { |
| 206 | return wireError( |
| 207 | "render_failed", |
| 208 | error instanceof Error ? error.message : String(error), |
| 209 | 500, |
| 210 | ); |
| 211 | } |
| 212 | } |
| 213 |
| 214 | async function handleTriggerSchedule( |
| 215 | app: ColineAppV2, |
| 216 | envelope: ColineProtocolEnvelope, |
| 217 | ): Promise<Response> { |
| 218 | const payload = triggerSchedulePayloadSchema.safeParse(envelope.payload); |
| 219 | if (!payload.success) { |
| 220 | return wireError("invalid_payload", "trigger.schedule payload is invalid.", 400); |
| 221 | } |
| 222 | if (!app.runtime.onSchedule) { |
| 223 | return wireError("unknown_schedule", "This app has no schedule handler.", 404); |
| 224 | } |
| 225 | const context: ColineScheduleContextV2 = { |
| 226 | coline: createHttpCapabilities(payload.data.capabilities), |
| 227 | workspace: { |
| 228 | workspaceId: envelope.workspace.workspaceId, |
| 229 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 230 | workspaceName: envelope.workspace.workspaceSlug, |
| 231 | }, |
| 232 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 233 | scheduleId: payload.data.scheduleId, |
| 234 | firedAt: payload.data.firedAt, |
| 235 | }; |
| 236 | try { |
| 237 | await app.runtime.onSchedule(context); |
| 238 | return json({ result: { ok: true } }, 200); |
| 239 | } catch (error) { |
| 240 | return wireError( |
| 241 | "schedule_failed", |
| 242 | error instanceof Error ? error.message : String(error), |
| 243 | 500, |
| 244 | ); |
| 245 | } |
| 246 | } |
| 247 |
| 248 | export function createColineAppHandler(params: { |
| 249 | app: ColineAppV2; |
| 250 | secret: string; |
| 251 | }): (request: Request) => Promise<Response> { |
| 252 | return async (request) => { |
| 253 | let envelope: ColineProtocolEnvelope; |
| 254 | try { |
| 255 | const parsed = await parseSignedProtocolRequest({ |
| 256 | request, |
| 257 | secret: params.secret, |
| 258 | }); |
| 259 | envelope = parsed.envelope; |
| 260 | } catch (error) { |
| 261 | if (error instanceof ColineProtocolError) { |
| 262 | return wireError(error.code, error.message, 401); |
| 263 | } |
| 264 | throw error; |
| 265 | } |
| 266 |
| 267 | switch (envelope.kind) { |
| 268 | case TOOL_INVOKE_KIND: |
| 269 | return handleToolInvoke(params.app, envelope); |
| 270 | case RENDER_SURFACE_KIND: |
| 271 | return handleRenderSurface(params.app, envelope); |
| 272 | case TRIGGER_SCHEDULE_KIND: |
| 273 | return handleTriggerSchedule(params.app, envelope); |
| 274 | default: |
| 275 | return wireError( |
| 276 | "unsupported_kind", |
| 277 | `This endpoint handles "${TOOL_INVOKE_KIND}", "${RENDER_SURFACE_KIND}", and "${TRIGGER_SCHEDULE_KIND}"; received "${envelope.kind}".`, |
| 278 | 400, |
| 279 | ); |
| 280 | } |
| 281 | }; |
| 282 | } |
| 283 |