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