Build apps on Coline
Define tools, file types, and surfaces in TypeScript — Coline hosts your logic, renders your UI on every platform, and hands your tools to Kairo. No servers unless you want them.
Getting Started
Scaffold an app
One command creates a working app: a Kairo tool, a file type with previews, a home surface, and tests.
npx create-coline-app my-app
cd my-app && npm install && npm run check
# Or start from a template: editor, integration, external-backend
npx create-coline-app my-app --template integrationDefine your app
defineApp is the whole contract: permissions, surfaces, tools, file types, and handlers in one place. Tools declare an honest effect — the runtime enforces it as a hard ceiling on what their code can do.
import { z } from "zod/v4";
import { defineApp, defineFileType, defineTool, ui } from "@colineapp/sdk/v2";
const todoFileType = defineFileType({
metadata: {
typeKey: "todos.todo",
name: "Todo",
indexable: true,
surfaces: {
preview: { tier: "tree" },
inline: { tier: "tree" },
},
},
runtime: {
renderPreview: (ctx) =>
ui.stack([
ui.heading(ctx.file.name),
ui.badge(String(ctx.document.status ?? "open")),
]),
renderInline: (ctx) => ui.text(ctx.file.name),
index: {
title: (doc) => String(doc.title ?? "Todo"),
body: (doc) => String(doc.notes ?? ""),
},
},
});
const createTodo = defineTool({
name: "todos.create_todo",
description: "Create a todo with a title.",
input: z.object({ title: z.string().min(1) }),
effect: "write", // enforced as a runtime capability ceiling
execute: async (input, ctx) => {
const file = await ctx.coline.files.create({
typeKey: "todos.todo",
name: input.title,
document: { title: input.title, status: "open" },
});
return {
output: { fileId: file.fileId },
card: ui.reference({ kind: "file", id: file.fileId }),
};
},
});
export default defineApp({
key: "todos",
name: "Todos",
permissions: ["files.read", "files.write", "ai.tools", "search.index"],
hosting: { default: "coline" }, // runs on Coline — no servers
surfaces: { home: { tier: "tree" } },
files: [todoFileType],
tools: [createTodo],
handlers: {
renderHome: async (ctx) => {
const { files } = await ctx.coline.files.list({ typeKey: "todos.todo" });
return ui.stack([
ui.heading("Todos"),
...files.map((file) => ui.fileCard({ title: file.name, fileId: file.fileId })),
]);
},
},
});Test it
createTestWorkspace runs your app against an in-memory workspace with the same coline.* API — invoke tools, render surfaces, fire schedules, assert on state.
import { describe, expect, it } from "vitest";
import { createTestWorkspace } from "@colineapp/sdk/testing";
import app from "./app.config";
it("creates a todo through the Kairo tool", async () => {
const ws = createTestWorkspace(app);
const result = await ws.invokeTool("todos.create_todo", { title: "Ship it" });
expect(result.card).not.toBeNull();
expect(ws.files.byType("todos.todo")).toHaveLength(1);
});Push to Coline
The CLI builds your logic bundle, ships client source when you use React surfaces, and uploads the version. Coline builds, reviews, and runs everything.
# Create a workspace API key with the apps.write scope
# in Workspace Settings → API, then:
export COLINE_API_KEY=col_ws_...
npx coline-app push # build + upload a version
npx coline-app dev # watch + re-push on every change
# Versions land in your developer console in draft state.
# Submit for review to list on the store, or install
# privately into your own workspace right away.The capability API
One coline.* API everywhere your code runs. Every call is scoped to what the installing workspace granted, what the acting user can access, and what your tool's declared effect allows.
// The same coline.* API everywhere your code runs —
// tools, render handlers, schedules, hosted or external.
// Files (scoped to what the user AND the app can touch)
const file = await ctx.coline.files.create({
typeKey: "todos.todo",
name: "Buy groceries",
document: { status: "open" },
});
await ctx.coline.files.updateDocument(file.fileId, { status: "done" }, { expectedVersion: file.version });
// App storage — key-value + records with workspace identity
await ctx.coline.storage.kv.set("last-sync", new Date().toISOString());
const contacts = ctx.coline.storage.collection("contacts");
const record = await contacts.insert({ name: "Sarah", tier: "vip" });
const { records } = await contacts.query({
where: { tier: "vip" },
orderBy: { field: "name" },
limit: 50,
});
// Workspace
const { members } = await ctx.coline.members.list();
await ctx.coline.notifications.create({
title: "Todo completed",
body: "Buy groceries is done!",
recipients: [{ userId: members[0].userId }],
});
// External APIs — through Coline, credentials injected server-side
const response = await ctx.coline.net.fetch({
url: "https://api.stripe.com/v1/charges",
headers: { authorization: "Bearer {{secret:STRIPE_KEY}}" },
});Scheduled triggers
Declare cron schedules in the manifest and Coline runs your handler on time — no job queues, no workers to deploy. Pair with net.fetch to sync external services on a cadence.
export default defineApp({
// ...
permissions: ["storage.app", "network.external"],
network: { allowedHosts: ["api.example.com"] },
triggers: {
schedules: [{ id: "daily-sync", cron: "0 9 * * 1-5" }],
},
handlers: {
onSchedule: async (ctx) => {
// Runs as the app principal on Coline's scheduler —
// your app syncs even when nobody has it open.
const response = await ctx.coline.net.fetch({ url: "https://api.example.com/sync" });
await ctx.coline.storage.kv.set("last-sync", ctx.firedAt);
},
},
});UI Components
Tree surfaces build native Coline UI from your handlers — rendered by Coline on web, desktop, mobile, and in Kairo chat. React surfaces get the full component library in a sandbox.
import { ui, actions } from "@colineapp/sdk/v2";
// Text & typography
ui.heading("My App");
ui.text("Hello world");
ui.text("Something went wrong", { tone: "danger" });
ui.badge("active", { tone: "positive" });
ui.link("View docs", "https://docs.example.com", { external: true });
// Layout
ui.stack([...], { gap: "md" });
ui.row([...], { gap: "sm" }); // horizontal stack shorthand
ui.divider();
// Interactive
ui.button("Save", { action: actions.custom("my-app.save") });
ui.button("Open", { action: actions.openFile("file_123") });
ui.button("Create", { action: actions.createFile({ name: "New", typeKey: "todos.todo" }) });
// Media & code
ui.image({ src: "https://...", alt: "Screenshot", width: 800, height: 400 });
ui.codeBlock("const x = 42;", { language: "typescript" });
// Form inputs (submitted via action handlers)
ui.input({ name: "title", label: "Title", placeholder: "Enter a title…" });
ui.select({
name: "priority",
label: "Priority",
options: [
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
],
});
// Data display
ui.table({
columns: [
{ key: "name", label: "Name" },
{ key: "status", label: "Status" },
],
rows: [{ name: "Sprint planning", status: "Done" }],
});
// Workspace references — resolve to live previews everywhere
ui.reference({ kind: "file", id: "file_123" });
ui.fileCard({ title: "Q4 Report", subtitle: "Updated yesterday", fileId: "file_123" });
ui.userChip({ label: "Alice", userId: "user_123" });
ui.emptyState({ title: "No items yet", description: "Create your first item." });
// Tree surfaces render on web, desktop, mobile, and inside
// Kairo chat — one definition, every platform. Need richer UI?
// Set a surface to { tier: "react", entry: "main.tsx" } and ship
// React components into Coline's sandboxed runtime.External hosting
Per-capability, not all-or-nothing: keep most of the app on Coline and route specific tools to your own backend. Coline signs every delivery; one endpoint serves them all.
// Most apps never need this — hosting: { default: "coline" }
// runs your logic on Coline. Reach for external hosting when a
// capability needs YOUR infrastructure (private databases,
// long-running jobs, existing services).
import { createColineAppHandler } from "@colineapp/sdk/v2";
import app from "./app.config";
// COLINE_DELIVERY_SECRET: set it once as a developer secret in the
// developer console; use the same value here.
const handler = createColineAppHandler({
app,
secret: process.env.COLINE_DELIVERY_SECRET!,
});
// One endpoint serves everything — tools, renders, schedules, events,
// webhooks, and jobs. Signatures, timestamps, and replays are checked.
// Next.js: app/coline/app/route.ts
export const POST = handler;
// Manifest change:
// hosting: {
// default: "coline",
// overrides: { "todos.sync_crm": "external" },
// external: { baseUrl: "https://your-app.example.com" },
// }Full-stack building blocks
React to the workspace, take webhooks from other services, run long work in the background, and act on people's other accounts — each declared in the manifest, shown on the install consent screen, and enforced at runtime.
Events
Subscribe to task, message, note, calendar, and app events, or emit your own. Delivery is durable and retried; payloads are identifiers only.
export default defineApp({
// ...
permissions: ["tasks.read", "webhooks.subscribe", "events.emit"],
triggers: { webhooks: [{ event: "task.created" }, { event: "crm.*" }] },
handlers: {
onEvent: async ({ coline, event }) => {
// Payloads carry ids, never content. Read what you need.
const task = await coline.tasks.get(String(event.data.taskId));
await coline.events.emit("triage.task_seen", { taskId: event.data.taskId });
},
},
});Webhook endpoints
Stripe, GitHub, Slack, Shopify, and Linear signatures are verified for you, plus generic HMAC and shared tokens. The signing secret stays with Coline.
import { defineEndpoint } from "@colineapp/sdk/v2";
// Each install gets a private URL: coline.endpoints.url("stripe").
// Coline checks the signature before your handler runs.
export const stripe = defineEndpoint({
id: "stripe",
methods: ["POST"],
verify: { kind: "stripe", secret: "STRIPE_WEBHOOK_SECRET" },
mode: "async", // 202 now, durable retried handler
handle: async ({ coline, json }) => {
const event = json<{ type: string; data: { object: { id: string } } }>();
await coline.storage.collection("payments").insert({ stripeId: event.data.object.id });
return { status: 200 };
},
});Background jobs
Durable work with retries, progress, checkpoints, and continuation — so an import of a hundred thousand rows runs in bounded steps instead of one timeout.
import { defineJob } from "@colineapp/sdk/v2";
import { useJob } from "@colineapp/ui";
export const importAll = defineJob({
name: "import",
timeoutSeconds: 300, // per attempt, up to 15 minutes
payload: z.object({ page: z.number().default(1) }),
run: async ({ page }, { coline, job }) => {
const { items, more } = await fetchPage(coline, page);
await job.progress(more ? 0.5 : 1, `Page ${page}`);
return more ? { continueWith: { page: page + 1 } } : { result: { done: true } };
},
});
// In the UI: start it and watch it.
const run = await coline.jobs.enqueue("import", { page: 1 });
const { progress, isRunning } = useJob(run.id);Connected accounts
OAuth handled by Coline: sign-in window, encrypted tokens, refresh, and injection only into requests to the hosts you declare.
export default defineApp({
// ...
permissions: ["connections.use", "network.external"],
network: { allowedHosts: ["api.github.com"] },
connections: [{
key: "github",
name: "GitHub",
scope: "user", // each member connects their own account
hosts: ["api.github.com"],
oauth2: {
authorizeUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
clientId: "Iv1.…",
clientSecret: "GITHUB_CLIENT_SECRET", // a developer secret
scopes: ["repo"],
},
}],
});
// Coline holds the token; your code never sees it.
await coline.net.fetch({
url: "https://api.github.com/user/issues",
headers: { authorization: "Bearer {{connection:github}}" },
});
// React: <ConnectButton connectionKey="github" />Build in Coline Code
In Code, choose Create project → Coline App, pick a template, and describe what the app should do. Code creates the repository, scaffolds the template, and starts an agent that builds and checks the app against simulated data. When the preview looks right, press Publish in the project's Code settings to install a new version in your workspace. You can also ask Kairo to build an app; it drafts the brief and opens this flow for you to confirm.
Agents never receive publishing credentials. Workspace admins get a project-scoped key with apps.write, used only when a person presses Publish.
Develop and diagnose locally
From your generated app project, run tests and check your bundles before uploading. Local preview uses simulated data and does not prove production permissions or external services.
npm run check
npm run dev # local preview with simulated datanpx coline-app preview --screenshots .coline/screenshotsBefore pushing, create a workspace API key with apps.write and set COLINE_API_KEY in your local environment. Set COLINE_BASE_URL when targeting a different Coline instance. Keep API keys out of app source and version control.
npx coline-app push --version 0.1.0
# or keep a development version current:
npm run push:watch -- --internalOpen your app in the developer console, install it in a workspace, then check Overview and Executions. Error details link to the relevant release or installation settings.
Choose a workspace and create an API keyHow your app runs
Three UI tiers, one capability contract. Start with tree surfaces — they render everywhere automatically — and reach for React or external hosting only where you need them.
| Tier | You write | Coline provides | Runs on |
|---|---|---|---|
| tree | Handlers returning ui.* trees | Native rendering, theming, actions | Web, desktop, mobile, Kairo chat |
| react | React components (main.tsx) | Sandboxed runtime, @colineapp/ui, build pipeline | Web and desktop |
| web | Pages on your own origin | Signed embeds | Web and desktop |
Payments & refunds
Paid apps are subscriptions billed through Coline. You connect a payout account, set your prices, and Coline handles checkout, invoices, and entitlements — workspaces get one billing surface, you get a payout.
- Platform fee. Coline keeps 15% of each transaction; the rest is paid out to your connected account on Stripe's standard payout schedule.
- Refunds are handled by Coline. Workspaces can request a refund of their latest subscription charge within 7 days, granted at Coline's discretion. Refunds are deducted from your payout balance. Apps must not implement their own refund, billing, or payment flows for Coline purchases.
- Cancellation. Subscriptions cancel at period end and access runs to period close. Uninstalling does not cancel a subscription — billing stays with the workspace until they cancel it.
API Reference
All endpoints available to your app via the API client or direct HTTP.
Members1→
List workspace members
/api/v1/workspaces/{workspaceSlug}/members
Notes5→
List workspace notes
/api/v1/workspaces/{workspaceSlug}/notes
Create note
/api/v1/workspaces/{workspaceSlug}/notes
Get note
/api/v1/workspaces/{workspaceSlug}/notes/{noteId}
Update note
/api/v1/workspaces/{workspaceSlug}/notes/{noteId}
Delete note
/api/v1/workspaces/{workspaceSlug}/notes/{noteId}
Docs5→
List workspace docs
/api/v1/workspaces/{workspaceSlug}/docs
Create doc
/api/v1/workspaces/{workspaceSlug}/docs
Get doc
/api/v1/workspaces/{workspaceSlug}/docs/{docId}
Update doc
/api/v1/workspaces/{workspaceSlug}/docs/{docId}
Delete doc
/api/v1/workspaces/{workspaceSlug}/docs/{docId}
Drives & Files5→
List workspace drives
/api/v1/workspaces/{workspaceSlug}/drives
List drive files
/api/v1/workspaces/{workspaceSlug}/drives/{driveId}/files
Get file
/api/v1/workspaces/{workspaceSlug}/files/{fileId}
Update file
/api/v1/workspaces/{workspaceSlug}/files/{fileId}
Delete file
/api/v1/workspaces/{workspaceSlug}/files/{fileId}
Channels3→
List channels
/api/v1/workspaces/{workspaceSlug}/channels
List channel messages
/api/v1/workspaces/{workspaceSlug}/channels/{channelId}/messages
Send channel message
/api/v1/workspaces/{workspaceSlug}/channels/{channelId}/messages
Direct Messages3→
List direct messages
/api/v1/workspaces/{workspaceSlug}/dms
List DM messages
/api/v1/workspaces/{workspaceSlug}/dms/{dmId}/messages
Send DM message
/api/v1/workspaces/{workspaceSlug}/dms/{dmId}/messages
Messages9→
Get message
/api/v1/workspaces/{workspaceSlug}/messages/{messageId}
Edit message
/api/v1/workspaces/{workspaceSlug}/messages/{messageId}
Delete message
/api/v1/workspaces/{workspaceSlug}/messages/{messageId}
Get thread
/api/v1/workspaces/{workspaceSlug}/messages/{messageId}/thread
Reply to thread
/api/v1/workspaces/{workspaceSlug}/messages/{messageId}/thread
Add reaction
/api/v1/workspaces/{workspaceSlug}/messages/{messageId}/reactions/{emoji}
Remove reaction
/api/v1/workspaces/{workspaceSlug}/messages/{messageId}/reactions/{emoji}
Pin message
/api/v1/workspaces/{workspaceSlug}/messages/{messageId}/pin
Unpin message
/api/v1/workspaces/{workspaceSlug}/messages/{messageId}/pin
Calendar24→
List calendars
/api/v1/workspaces/{workspaceSlug}/calendar/calendars
Create calendar
/api/v1/workspaces/{workspaceSlug}/calendar/calendars
Update calendar and preferences
/api/v1/workspaces/{workspaceSlug}/calendar/calendars/{calendarId}
Archive calendar
/api/v1/workspaces/{workspaceSlug}/calendar/calendars/{calendarId}
List calendar changes
/api/v1/workspaces/{workspaceSlug}/calendar/calendars/{calendarId}/changes
Restore calendar
/api/v1/workspaces/{workspaceSlug}/calendar/calendars/{calendarId}/restore
List calendar grants
/api/v1/workspaces/{workspaceSlug}/calendar/calendars/{calendarId}/grants
Create or update calendar grant
/api/v1/workspaces/{workspaceSlug}/calendar/calendars/{calendarId}/grants
Delete calendar grant
/api/v1/workspaces/{workspaceSlug}/calendar/calendars/{calendarId}/grants
Import iCalendar archive
/api/v1/workspaces/{workspaceSlug}/calendar/calendars/{calendarId}/import
Export iCalendar archive
/api/v1/workspaces/{workspaceSlug}/calendar/calendars/{calendarId}/export
List combined schedule
/api/v1/workspaces/{workspaceSlug}/calendar/my-schedule
Search calendars
/api/v1/workspaces/{workspaceSlug}/calendar/search
List calendar events
/api/v1/workspaces/{workspaceSlug}/calendar/events
Create calendar event
/api/v1/workspaces/{workspaceSlug}/calendar/events
Get calendar event
/api/v1/workspaces/{workspaceSlug}/calendar/events/{eventId}
Update calendar event
/api/v1/workspaces/{workspaceSlug}/calendar/events/{eventId}
Delete calendar event
/api/v1/workspaces/{workspaceSlug}/calendar/events/{eventId}
List event reminders
/api/v1/workspaces/{workspaceSlug}/calendar/events/{eventId}/reminders
Replace event reminders
/api/v1/workspaces/{workspaceSlug}/calendar/events/{eventId}/reminders
Respond to invitation
/api/v1/workspaces/{workspaceSlug}/calendar/events/{eventId}/response
List proposed times
/api/v1/workspaces/{workspaceSlug}/calendar/events/{eventId}/counter-proposals
Propose a new time
/api/v1/workspaces/{workspaceSlug}/calendar/events/{eventId}/counter-proposals
Respond to a proposed time
/api/v1/workspaces/{workspaceSlug}/calendar/events/{eventId}/counter-proposals
Search1→
Search workspace
/api/v1/workspaces/{workspaceSlug}/search
Taskboards9→
List taskboards
/api/v1/workspaces/{workspaceSlug}/taskboards
List taskboard tasks
/api/v1/workspaces/{workspaceSlug}/taskboards/{taskboardId}/tasks
Create task
/api/v1/workspaces/{workspaceSlug}/taskboards/{taskboardId}/tasks
Get task
/api/v1/workspaces/{workspaceSlug}/taskboards/{taskboardId}/tasks/{taskId}
Update task
/api/v1/workspaces/{workspaceSlug}/taskboards/{taskboardId}/tasks/{taskId}
Delete task
/api/v1/workspaces/{workspaceSlug}/taskboards/{taskboardId}/tasks/{taskId}
Batch create tasks
/api/v1/workspaces/{workspaceSlug}/taskboards/{taskboardId}/tasks/batch-create
Batch update tasks
/api/v1/workspaces/{workspaceSlug}/taskboards/{taskboardId}/tasks/batch-update
Batch delete tasks
/api/v1/workspaces/{workspaceSlug}/taskboards/{taskboardId}/tasks/batch-delete
Store3→
List store apps
/api/v1/apps
Get store app
/api/v1/apps/{appKey}
List approved app versions
/api/v1/apps/{appKey}/versions
Publisher3→
Register app
/api/v1/apps
Create app version
/api/v1/apps/{appKey}/versions
Submit app version for review
/api/v1/apps/{appKey}/versions/{versionId}/submit
Auth3→
Authorize OAuth client
/api/v1/oauth/authorize
Exchange OAuth authorization code
/api/v1/oauth/token
Resolve OAuth userinfo
/api/v1/oauth/userinfo
AI2→
List Tab models
/api/v1/tab/models
Create OpenAI-compatible Tab chat completion
/api/v1/tab/chat/completions
Workspace installs15→
List workspace apps and catalog
/api/v1/workspaces/{workspaceSlug}/apps
Install workspace app
/api/v1/workspaces/{workspaceSlug}/apps
Get installed app
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}
Uninstall workspace app
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}
Get granted app permissions
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/permissions
Update granted app permissions
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/permissions
List app secrets
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/secrets
Create or rotate app secret
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/secrets
Delete app secret
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/secrets/{secretId}
List app OAuth connections
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/oauth-connections
Create or update app OAuth connection
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/oauth-connections
Delete app OAuth connection
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/oauth-connections/{connectionId}
List app deliveries
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/deliveries
Send test app delivery
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/deliveries
Replay app delivery
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/deliveries/{deliveryId}/replay
Runtime10→
List app-backed files
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/files
Create an app-backed file
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/files
Execute a hosted app action
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/actions
Read app-backed file document
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/files/{fileId}
Update app-backed file document
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/files/{fileId}
Delete app-backed file
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/files/{fileId}
Create app notification
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/notifications
Upsert app index documents
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/index-documents
Delete app index documents
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/index-documents
Emit app ambient events
/api/v1/workspaces/{workspaceSlug}/apps/{appKey}/ambient/events
Identity1→
List my workspaces
/api/v1/me/workspaces
Sheets9→
List workspace sheets
/api/v1/workspaces/{workspaceSlug}/sheets
Create sheet
/api/v1/workspaces/{workspaceSlug}/sheets
Get sheet
/api/v1/workspaces/{workspaceSlug}/sheets/{sheetId}
Update sheet
/api/v1/workspaces/{workspaceSlug}/sheets/{sheetId}
Delete sheet
/api/v1/workspaces/{workspaceSlug}/sheets/{sheetId}
Create sheet view
/api/v1/workspaces/{workspaceSlug}/sheets/{sheetId}/views
Update sheet view
/api/v1/workspaces/{workspaceSlug}/sheets/{sheetId}/views/{viewId}
Delete sheet view
/api/v1/workspaces/{workspaceSlug}/sheets/{sheetId}/views/{viewId}
Import CSV into sheet
/api/v1/workspaces/{workspaceSlug}/sheets/{sheetId}/import
Uploads4→
Initiate multipart upload
/api/v1/workspaces/{workspaceSlug}/uploads/initiate
Get upload part URL
/api/v1/workspaces/{workspaceSlug}/uploads/part
Upload part
/api/v1/workspaces/{workspaceSlug}/uploads/part
Complete multipart upload
/api/v1/workspaces/{workspaceSlug}/uploads/complete