TaskLite · Developer Manual

Building on TaskLite

TaskLite gives you two ways in, and almost every wasted afternoon starts by reaching for the wrong one. MCP is how you build the backend. The app API is how your application talks to it. This manual covers both, all 48 tools, and how to read a failure that arrives disguised as a success.

Prefer a walkthrough? The step-by-step guides build a backend, a CRM and an inventory app from the first prompt to a working admin.

@tasklite/mcp 0.14.3 · current release, generally availableapi.tasklite.netRevised 15 September 2026

Start here

Two channels, and which one you want

Both channels reach the same boards and the same rows. They differ in who holds the credential and who is making the call.

MCP

For you and Claude

An agent protocol. Claude Code holds your personal key (tl_…) and works directly on projects, boards, columns and rows. This is how the system gets designed and built, and how you seed and repair data afterwards.

get_board_schema · create_item · set_cell

App API

For your application

Plain REST over HTTPS. Your server holds an app key (tk_…) and calls published endpoints. A Next.js route or any other backend uses this, a web app cannot speak MCP, and should not try.

GET · POST · PATCH · DELETE

The confusion is understandable: MCP contains the tools that create the app API, create_app, create_app_endpoint, create_app_api_key. Those are MCP tools for setting up the second channel, not a requirement of the first. You do not need an app to use MCP.

Setup

Connecting

Three ways in, easiest first. They reach the same tools, pick by where you want to work.

The connector, nothing to install

In Claude (claude.ai or the desktop app), open Settings → Connectors, find TaskLite in the directory, and add it. You sign in once and Claude keeps the connection, there is no key to create, copy or store. If you would rather add it by address, use https://mcp.tasklite.net/mcp.

After connecting on claude.ai, the tools appear in a new chat, an existing conversation will not pick them up. That trips up more people than any other step.

One-click install

The hosted server, added to your client with its OAuth sign-in. Nothing to paste.

Claude Code, in your terminal

Install by binary, npx -y is mis-parsed on Windows.

npm install -g @anthropic-ai/claude-code @tasklite/mcp
claude mcp add tasklite -- tasklite-mcp

Add -s user to make it available in every project rather than only the current one. With an existing account, pass a key created at TaskLite → Integrations → Connect Claude Code:

claude mcp add tasklite -e TASKLITE_API_KEY=tl_xxx -- tasklite-mcp

ChatGPT, Gemini, Cursor, Codex, VS Code, Windsurf, the OpenAI and Gemini APIs

All of them speak MCP, and the hosted server already has what each one needs: OAuth with dynamic client registration for the clients that sign you in through a browser (ChatGPT, Gemini CLI, VS Code, Cursor), bearer API keys for the ones that do not (the OpenAI Responses API and Agents SDK, the Gemini API SDK, CI), and the search and fetch tools ChatGPT requires. Config snippets for every client are in the client setup guide.

Any other MCP client

Point it at the hosted server with a personal key:

claude mcp add --transport http tasklite https://mcp.tasklite.net/mcp \
  --header "Authorization: Bearer tl_xxx"

The hosted server is stateless, every request carries its own credential, so one endpoint serves every account.

If any tool returns an auth error, call connection_status first, it tells you whether this machine is connected at all.

The sequence matters

Build order

Each step needs identifiers produced by the one before it. Skipping ahead is the most common cause of rows that save but hold nothing.

  1. 1create_project, one project models one business process.
  2. 2create_board, a board is a table inside that project.
  3. 3create_column per field. Choose types by meaning: a status field is a dropdown with real options, never free text. Types carry the whole UX, date unlocks calendar and timeline views, phone gives tap-to-call, currency formats money.
  4. 4get_board_schema, read back the real column ids. Cells are keyed by id, and a guessed name writes nothing.
  5. 5create_item with the title and every cell in one call. Reach for set_cell only to change a value later, and for update_column when the model itself turns out wrong.

Boards double as the admin interface your client operates, so this is a real system from the first column, not a staging area for one.

The part people get wrong

Wiring an external frontend

The host

The API is served from api.tasklite.net. Nothing else. app.tasklite.net is the admin interface, a different origin entirely. Take the baseUrl that get_app_spec returns and use it verbatim, do not rebuild it from the URL you see in the browser.

Four things must be true

RequirementSet byIf missing
The app is publishedpublish_app401 App not found or not published
The endpoint exists and is activecreate_app_endpoint401 Endpoint not found
A valid, unexpired API keycreate_app_api_key401 API key required
The endpoint exposes columnsexposedColumnsReads return metadata only; writes are refused

Calling it

Two ways, and which one you need depends on where the call comes from.

From a phone or a browser, the caller is the person using it, and they send the token they got from signing in. Nothing secret sits on the device, and there is no server of yours in the middle:

curl -i -H "Authorization: Bearer <the jwt from /auth/login>" \
  https://api.tasklite.net/apps/app-0cu5fs/api/board5

The token decides who the caller is, so nothing in the request can name someone else, and row-level security applies to it. See Calling straight from a phone for what that means.

From a server of yours, a Next.js route, a cron job, another backend, use the app key instead:

curl -i -H "x-api-key: tk_xxx" \
  https://api.tasklite.net/apps/app-0cu5fs/api/board5

Authorization: Bearer tk_… works for the key too. A key is the app speaking, not a person, so it must stay server-side: an environment variable read by an API route, never anything the browser can see. If you have no server, you do not need one, use the first form.

Querying

A list endpoint takes page and pageSize (up to 100) and returns { items, total, page, pageSize }. Filter on any item field or exposed column by alias, name or id:

GET /apps/{app}/api/orders?filter[status]=open&filter[total][gte]=500&sort=-createdAt
GET /apps/{app}/api/orders?filter[customer][in]=<id1>,<id2>&filter[invoice][empty]=true
GET /apps/{app}/api/orders?search=cohen&pageSize=50
GET /apps/{app}/api/orders/<itemId>            # one row

Operators: eq (the default), ne, gt, gte, lt, lte, contains, in (comma list), empty (true or false). Conditions AND together. Numeric and currency columns compare as numbers, relation columns by item id, everything else as text. An unknown key or operator answers 400 listing what is allowed.

Live updates

Push reaches someone whose app is closed. This is the other half: the message that should appear while they are looking at the screen, without asking the server every few seconds whether anything happened.

GET /apps/<app>/api/<endpoint>/stream
Accept: text/event-stream
Authorization: Bearer <the jwt from /auth/login>

event: ready
data: {"endpoint":"messages","syncedAt":"2026-09-08T09:14:02.881Z", ...}

event: created
id: 2026-09-08T09:15:44.102Z
data: {"id":"9c2...","boardId":"...","title":"See you at six","updatedAt":"..."}

Three event types arrive: created, updated and deleted. A comment line every 25 seconds keeps the connection from being cut as idle.

It carries what changed, not the whole row. A client that needs the new values calls the endpoint with ?since= and the timestamp it was last given, which is the same catch-up path it uses after being offline. One mechanism covers both, and an event missed while the connection was down is not a lost message.

Advance your cursor as events arrive: each one carries an id, and that id is the timestamp to send as since next time. A client that keeps only the syncedAt from when it first connected will still lose nothing, but every reconnect will re-fetch the whole session instead of the gap.

Row-level security applies. A stream on a per-user endpoint only ever mentions that user’s rows, and it needs to know who is listening: the end user’s own token, or the app key with X-App-User. Without one it answers 401 rather than opening a feed it would have to fill with everyone’s data.

Writes made anywhere reach the stream, including edits people make in the TaskLite admin. A coach replying from their desk appears on the athlete’s phone; that is usually the point.

An app may hold 500 open streams at once. Past that the stream closes with an error event telling the client to fall back to ?since= polling, which stays correct at any scale, just less immediate.

Push notifications

A live connection reaches a phone only while its screen is on and the app is in front of the user; the operating system closes it as soon as the app goes to the background. Everything worth telling someone: a message arrived, a session is in an hour, a task was assigned, happens while the app is closed. That is what push is for, and it is why it exists here before a live connection does.

The credentials are yours. Notifications go out through your Firebase project, so they carry your app's name and icon and you keep control of the project. Download the service account from Firebase console → Project settings → Service accounts → Generate new private key, and post the file unchanged:

POST /organizations/<org>/apps/<appId>/push/credentials
{ "serviceAccount": { …the JSON file, exactly as downloaded… } }

GET  /organizations/<org>/apps/<appId>/push        # what is configured, how many devices
POST /organizations/<org>/apps/<appId>/push/test   # { "userIds": ["…"] }, prove it works

The private key is encrypted before it is stored and is never returned by any endpoint. If the server has no encryption key configured, the request is refused rather than storing the key in the clear.

The phone registers itself. On every launch, after sign-in, send the token Firebase gave the app. Re-sending the same token moves it to the calling user, so a phone that changes hands stops delivering the previous owner's notifications. Delete it on sign-out.

POST   /apps/<app>/api/devices        { "token": "<fcm token>", "platform": "android" }
DELETE /apps/<app>/api/devices/<token>
GET    /apps/<app>/api/devices         # this user's devices, without the tokens

These need to know who is calling, the end user's own token (Authorization: Bearer <jwt>), or the app key together with X-App-User. A device belongs to a person, so an anonymous call has nobody to attach it to and answers 401.

Sending. Add a send_push action to an automation. By default it goes to the row's owner, which is usually the whole answer: a workout assigned to someone is a row that belongs to them. recipientsFromRelation follows a relation or people column instead, a message row pointing at its coach, say, and userIds names people outright. Title and body take the same column placeholders as the other actions.

A token the service reports as dead is deleted rather than retried forever, and a delivery failure never fails the write that triggered it: the automation log records what happened.

Offline and retries

A phone in a gym basement is the normal case, not the edge case. Three things make a client safe to write from there.

Catch up. ?since=<timestamp> turns a list into a change feed: the rows that moved after that moment, oldest first, plus deleted with the ids of rows removed since then, and syncedAt. Pass syncedAt back as the next since and nothing falls between two calls. It combines with the filters and with paging.

Retry safely. Send Idempotency-Key on a POST and the same key returns the first answer rather than writing a second row, so a request that timed out can simply be sent again. Keys are remembered for seven days. The same key with a different body answers 409 instead of quietly returning the wrong row.

Send the queue. POST …/<endpoint>/batch takes up to 50 operations - { op: "create" | "update" | "delete", id?, data?, clientId? }, and reports each one separately, so one rejected row does not discard the other forty-nine. An operation with a clientId is idempotent under that id, which means replaying the whole queue writes nothing twice. Each operation still obeys the methods the endpoint allows.

# what changed while I was away
GET /apps/gym/api/sets?since=2026-09-07T06:00:00.000Z
→ { items: [...], deleted: ["b1f0…"], syncedAt: "2026-09-07T09:14:02.881Z" }

# the queue that piled up, sent once
POST /apps/gym/api/sets/batch
{ "operations": [
    { "op": "create", "clientId": "local-41", "data": { "reps": 8 } },
    { "op": "update", "clientId": "local-42", "id": "9c2…", "data": { "reps": 10 } }
] }
→ { "succeeded": 2, "failed": 0, "results": [ … ] }

Nested rows in one row

A workout has exercises and an exercise has sets; an order has lines. A repeater column holds that directly: an array of objects with a declared shape, stored on the row, read and written in one call. Two boards linked by a relation is the other way to model it and is right when each exercise is a thing in its own right that people open, filter and report on. A repeater is right when the rows only exist inside their parent.

The shape is declared in the column settings as subFields, a list of { key, label, type }. Set isGroup: true for a single object instead of a list. The OpenAPI document for your app describes the exact shape, so a generated client gets a real type rather than a string:

"exercises": [
  { "name": "Bench press", "reps": 8, "weight": 60, "done": true },
  { "name": "Squat",       "reps": 5, "weight": 90, "done": false }
]

Sub-field types follow the same meanings as column types: number, rating, duration and currency arrive as numbers, checkbox as a boolean, everything else as a string. A repeater with no declared sub-fields is still accepted, and the spec then says only that the entries are objects, because nothing has promised what is in them.

Files

A file column exposed on an endpoint gets four calls under the row: POST …/<id>/files/<field>/upload-url with { fileName, mimeType, fileSize } returns a presigned PUT and a fileKey; upload the bytes to that URL; then POST …/files/<field>/attach with the fileKey records it on the row. GET …/files/<field> lists files with short-lived download URLs, and DELETE …/files/<field>/<attachmentId> removes one. Bytes never pass through the API.

Thumbnails. That last sentence is also the answer to resizing: the bytes go straight from the device to storage and back, so there is no point at which we could shrink an image even if we wanted to. Make the thumbnail on the client, where the picture already is, and attach both to the same column: progress.jpg and progress.thumb.jpg. A file column holds a list, so the gallery reads the small one and the detail view reads the large one, and a phone on a slow connection never downloads a 4 MB photo to draw it 80 pixels wide.

Limits. 100 MB per file. The type is checked twice: against the mimeType you declare, and against the bytes that actually arrived, so a renamed executable is rejected at attach time. Allowed types:

  • Images, image/jpeg, image/png, image/gif, image/webp, image/svg+xml
  • Documents, application/pdf, Word, Excel and PowerPoint (both the old and the Open XML types), text/plain, text/csv
  • Video, video/mp4, video/webm, video/quicktime, video/x-msvideo, video/x-matroska, video/3gpp, video/mpeg
  • Archives, application/zip, RAR, 7z

Anything else answers 400 listing the allowed types. Storage is metered against the plan quota, not the per-file limit. Files are stored as uploaded: there is no server-side resizing or thumbnail generation yet, so generate the sizes you need on the client and attach them as separate files.

Required columns

Columns marked required on the board are enforced here: a POST without one, or a PATCH that blanks one, answers 400 naming the field. Writes are atomic, the row and every cell commit together or not at all.

Validation rules

Every column checks its own type on write, a number column refuses text, an email column refuses nope, a dropdown refuses a value that is not one of its options. On top of that, settings.validation on a column adds rules that hold on every write path, the UI and the MCP included:

{ "validation": { "unique": true, "min": 0, "max": 100000,
                 "minLength": 3, "maxLength": 32,
                 "pattern": "^[A-Z]{2}-\d{4}$", "patternMessage": "Format is XX-0000" } }

A violation answers 400 with the column name and the rule: "SKU": "AB1" already exists on this board (column is unique).

Changing a column’s type

update_column with a new type converts the stored values: number and currency both ways, text into number, date or checkbox, anything into text. Values that cannot convert are cleared, never left in the old shape, and the response says { converted, cleared }. Relation, people and file columns cannot change type.

Every app publishes its own OpenAPI

GET /apps/<app>/api/openapi.json with the app key returns an OpenAPI 3.1 document for that app: every endpoint with list, single row, create, update, delete and file routes, field schemas derived from the column types (enums for closed choices, the validation rules as constraints), the query grammar, auth and the error shape. Open it in Scalar or Postman, or feed it to a client generator. The same document is get_app_spec’s sibling at …/apps/<appId>/spec/openapi for signed-in users.

exposedColumns is not optional in practice

It decides which columns are readable and writable. An endpoint without it answers GET with only id, title, status, priority and timestamps, none of your actual fields. Give every endpoint the column ids from get_board_schema, and set an alias when you want a stable key name in the JSON.

Per-user data

When your app has its own users, set rowLevelSecurity.enabled on the endpoint. Your server then sends X-App-User: <your user id> alongside the API key, and the endpoint returns, updates and deletes only that user’s rows, refusing the request outright when the header is absent.

Your app’s users

Two ways, and they combine. Bring your own sign-in: keep Auth0, Clerk, NextAuth or your own sessions, and send the signed-in user’s stable id as X-App-User from your server. TaskLite mints a matching external user on first sight and row-level security scopes every read and write to it. Or let TaskLite be the user store: your app calls the sign-up and sign-in endpoints, keeps the returned token, and passes the user id the same way.

# 1. sign-up (open or admin-approved, configure_external_access decides)
POST https://api.tasklite.net/auth/register-external
{ "organizationId": "<orgId>", "name": "Dana Levi", "email": "[email protected]", "password": "…" }
→ { "user": { "id": "u_…" }, "token": "<jwt>" }          # or pendingApproval: true

# 2. sign-in
POST https://api.tasklite.net/auth/login  { "email": "[email protected]", "password": "…" }
→ { "user": { "id": "u_…" }, "token": "<jwt>" }

# 3. from YOUR server, on every App API call for that user
GET https://api.tasklite.net/apps/<app>/api/orders
Authorization: Bearer tk_…           # the app key, server-side only
X-App-User: u_…                      # the user id from step 1 or 2

The app key never reaches the browser, and a user only ever sees rows written under their own id. Roles for app users (viewer, editor, submitter) are set per app.

Signing in with Google works for these users too, which on Android is the way people expect to get in. Get an ID token from Google on the client, then hand it over:

POST https://api.tasklite.net/auth/google-external
{ "credential": "<the Google ID token>", "organizationId": "<orgId>" }
→ { "user": { "id": "u_…" }, "token": "<jwt>" }   # or pendingApproval: true

Register your own Google client id first. Put it on the organization as settings.googleClientId. Without it we fall back to TaskLite's own client, which puts our name on the consent screen and, on Android, does not work at all: Google ties the token to the requesting app's package name and signing certificate, and your app is not in our Google project. Both are accepted, so anything built before this keeps working.

The token is verified against that client id, and the same registration policy applies as for email sign-up: open lets them in at once, approval holds them until an admin approves, closed refuses self-signup. Somebody who already has an account with that email simply signs in. A credential Google does not recognise answers 401, and an unknown organization 404.

The token that comes back is the one the app then sends as Authorization: Bearer on every App API call, so Google sign-in and calling straight from a phone are two halves of the same flow.

Calling straight from a phone

Everything above assumes a server of yours in the middle, holding the key. A mobile app has no such place. A phone cannot keep a secret, and a shared key plus X-App-User means anyone holding the key can read anyone’s rows by editing a header.

So an end user may call the App API with the token they got from signing in, and then they are simply themselves:

GET https://api.tasklite.net/apps/<app>/api/workouts
Authorization: Bearer <the jwt from /auth/login>     # no app key on the device

The token decides who the caller is, so nothing in the request can name a different user — an X-App-User header sent alongside it is ignored. The organization is still the boundary: a valid TaskLite session belonging to someone else is not a user of your app and answers 401. Blocked and deleted accounts are refused, and the per-app role still decides what the person may do.

A signed-in caller also gets their own rate-limit allowance rather than sharing the app’s, so one person on their phone cannot spend everyone else’s and cannot be starved by them. Authorization: Bearer tk_… still means the app key, and still belongs on a server.

Audit trail

Every create, update, delete, status change, assignment, login and export is recorded with who, when, from which address. Owners and admins read their own organization’s trail:

GET /organizations/<orgId>/audit-logs?entityType=item&entityId=<itemId>&startDate=2026-09-01&limit=50

Export

The whole project as one JSON document: boards, columns with their settings, items with cells keyed by column id, from GET /organizations/<orgId>/projects/<projectId>/export.json or the export_project tool. It is the shape a migration or a backup needs, and the one an agent can read back.

Automations: retry and delay

Any network action (http_request, send_webhook, send_email, send_whatsapp) takes config.retry: { attempts, delaySeconds }, up to five tries. A delay action with { minutes | hours | days } pauses the run and resumes the actions after it on time, so “remind the customer 24 hours later” is one automation, not a scheduler.

Rate limit

600 requests per minute per app key, with a burst allowance of 200 so a screen that fires a dozen calls at once is not penalised. Calls without a key get 60 per minute per IP. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; over the limit you get 429 with a Retry-After header. The limit is per key, not per IP, so one server can serve all your users.

From backend to a URL

Hosting a frontend

TaskLite hosts static frontends. deploy_frontend puts a site live at https://{slug}.tasklite.dev with HTTPS, keeps every version, and needs no server, hosting account or CI on your side. The app is published on the first deploy.

Three ways to hand over the site

Exactly one of them per call. Which one depends on where the files are, not on what the site is.

InputWhenLimits
filesThe assistant wrote the page in the conversation: ChatGPT, Claude, any hosted client. Pass each file as { path, content }; binaries with encoding: "base64". Must include index.html.500 files, 8MB decoded
zipUrlA build already exists somewhere public: a Lovable or Bolt export, a GitHub release asset. https only. A zip whose only top-level entry is a folder is re-rooted.50MB
dirThe MCP runs on the machine with the build output (Claude Code, Cursor, a local install). Pass the folder that contains index.html: dist/, build/, out/.50MB zipped

From ChatGPT, in one turn

deploy_frontend(
  appId: "app-xxxxxx",
  files: [
    { path: "index.html", content: "<!doctype html>…<script src=\"app.js\"></script>" },
    { path: "app.js",     content: "fetch('/api/orders').then(r => r.json()).then(render)" }
  ]
)
→ { version: 1,
    url:        "https://bike-repair-api.tasklite.dev",
    previewUrl: "https://v1--bike-repair-api.tasklite.dev",
    fileCount:  2 }

Calling the backend from the page

Hosted pages call the app API at the relative path /api/{endpoint}. The hosting proxy attaches the app identity server-side, so there is no base URL to configure, no CORS, and no API key in browser code. Generated frontends should be told exactly that: relative /api, no key.

Versions, previews and rollback

Every deploy is a numbered version, and each one keeps an address of its own: https://v{n}--{slug}.tasklite.dev serves that exact release, straight from its frozen directory, without changing what the live URL shows. Open one to check what a build looked like, or hand the link to someone before you decide to restore it. Previews are public to anyone with the link and carry noindex, nofollow, so an old build never competes with the live site in search.

list_deployments shows every version, which one is live, and each version's previewUrl. Only the last five bundles stay on disk: an older version reports onDisk: false and has no preview, it cannot be restored either. rollback_deployment points the live URL at any version still on disk. A custom domain is not offered yet: every address is under tasklite.dev.

Diagnostics

Reading a failure

Every genuine authentication or publishing failure returns 401 with a message naming the cause. That single fact resolves most confusion: if you did not get a 4xx, you did not reach the API.

What you sawWhat it meansDo this
200 text/htmlYou called the admin host, not the API. The page you received is the admin interface answering an unknown path.Point the client at api.tasklite.net.
401 application/jsonYou reached the API. The message names the exact cause: missing key, invalid key, unpublished app, or unknown endpoint.Read the message field and fix that one thing.
404 on the admin hostThe guard on app.tasklite.net, telling you the API lives elsewhere.Same fix: switch hosts.
400 exposes no writable columnsThe endpoint has no exposedColumns, so every field you sent would have been discarded.update_app_endpoint with the column ids.
200 rows, no fieldsSame cause, read side. Only item metadata is exposed.list_app_endpoints, an endpoint showing 0 columns is broken.
404 App not foundNo app with that id or slug in this organization.list_apps to see what actually exists.
403 not enabled on endpointA deliberate configuration, not a fault: the endpoint’s allowedMethods omit that verb. New endpoints default to GET alone.update_app_endpoint to add it: or, for deletes, use the admin, which is what most apps should do.
400 text arrived corruptedThe body contains U+FFFD, so the encoding broke before the request was sent and those characters are already lost.Send the body as UTF-8. On Windows shells this is the console codepage, not TaskLite.
429Rate limited.Honour Retry-After; batch server-side.

A 200 is the failure worth learning to distrust. Both of the silent modes above answer successfully, one with a web page, one with rows stripped of every field you care about. Check the content-type before you debug anything else.

48 tools · @tasklite/mcp 0.14.3

Tool reference

Grouped in the order you reach for them; names, descriptions and parameters are read from the connector itself at build time, so this list is exactly what the server declares. Open a tool's parameter line for the full schema. Tools marked destructive are worth confirming with a human before calling. The 5 tools marked local install only are the account tools, the hosted connector authenticates with OAuth instead. Everything else, hosting included, works from ChatGPT, Claude and every hosted client.

Account & connection

connection_statusread-onlylocal install only
Whether this machine is connected. Call it first when anything fails with an auth error.
sign_uplocal install only
Create a new account and organization from the conversation. Local install only; hosted connections use OAuth.
email · name · organizationName
email * string
Email address
name * string
The user's full name
organizationName * string
Business/organization name
connectlocal install only
Connect with a personal API key (tl_…), or switch accounts.
apiKey
apiKey * string
Personal TaskLite API key, starts with tl_
loginlocal install only
Connect with email and password. Creates and stores a key, so the password is used once.
email · password
email * string
Email address
password * string
Used once to mint an API key; never stored
disconnectdestructivelocal install only
Forget the stored credential. Deletes nothing server-side and does not revoke the key.
list_organizationsread-only
Organizations you belong to. The id feeds every other tool's organizationId.
configure_external_access
Read or change how external users get into an organization: registrationPolicy open or approval, and the app login URL. Reads when called without fields.
organizationId? · registrationPolicy? · appLoginUrl?
organizationId string
Defaults to the credential organization
registrationPolicy "closed" | "approval" | "open"
How external sign-ups are admitted: open, approval or closed
appLoginUrl string
https URL of your app's login page for external users; "" clears it

Modelling

list_projectsread-only
Projects in an organization.
organizationId? · page? · limit?
organizationId string
Defaults to the credential organization
page number
1-based; defaults to 1
limit number
Defaults to 50
list_boardsread-only
Boards inside a project: id, name, description. The only way to discover a boardId without being handed a URL.
projectId · page? · limit?
projectId * string
Project id (from list_projects / create_project)
page number
1-based; defaults to 1
limit number
Defaults to 50
build_backend
One call: the project, its boards (plain data tables by default), typed columns, relations between the boards, optional sample rows (relation cells by the related row's title), and optionally a published API with an endpoint per board and a server-side key. The model designs the schema, the tool executes it and returns one summary; API field names are derived from column names and never collide with reserved ones. Start here when the user describes a whole system.
project · boards · api? · organizationId?
project * object
The project that holds the boards
boards * object[]
The boards (tables) of the backend, in any order
api object
Include to publish a REST API over every board and mint a key; omit for a boards-only build
organizationId string
Organization id; needed only when the account belongs to several (the error then lists them)
create_project
A project is one business process. Boards live inside it.
name · description? · organizationId?
name * string
Human-readable name
description string
Free-text description
organizationId string
Organization id; defaults to the credential organization when omitted
create_board
A board is a data table. kind: "tasks" (default) adds the built-in task columns; kind: "data" is a plain table with only the columns you add: customers, orders, products. Add columns next.
projectId · name · description? · kind?
projectId * string
Project id (from list_projects / create_project)
name * string
Human-readable name
description string
Free-text description
kind "tasks" | "data"
"tasks": with the built-in task columns (status, priority, assignee, due date, tags). "data": only the columns you add. Default tasks.
create_column
A typed column. text, rich_text, number, status, date, datetime, duration, people, checkbox, dropdown, label, priority, link, email, phone, relation, lookup, rollup, rating, currency, file. Rules live in settings.validation: unique, min, max, minLength, maxLength, pattern.
projectId · boardId · name · type · settings? · isRequired? · force?
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
name * string
Human-readable name
type * string
Column type: text, rich_text, number, status, date, datetime, duration, people, checkbox, dropdown, label, priority, link, email, phone, relation, lookup, rollup, rating, currency, file
settings object
Type-specific settings. For dropdown/status/priority: options, either as labels ["A","B"] or as full objects [{value,label,color}], labels are expanded server-side, and colors are assigned if you do not supply them.
isRequired boolean
Require a non-blank value on every App API create
force boolean
Create the column even when the name suggests a different type
get_board_schemaread-only
The board with its full column schema: ids, names, types, settings. Required before writing cells.
projectId · boardId
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
update_column
Change a column after the fact, rename, change type (number → currency), replace dropdown options, toggle required or hidden. A type change converts the values that convert and clears the rest, and says how many of each.
projectId · boardId · columnId · name? · type? · settings? · isRequired? · isHidden? · description? · force?
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
columnId * string
Column id (from get_board_schema)
name string
Human-readable name
type string
New column type (same list as create_column)
settings object
Replaces the column settings, e.g. { options: [...] } for dropdown/status
isRequired boolean
Require a non-blank value on every App API create
isHidden boolean
Hide the column in the TaskLite UI
description string
Free-text description
force boolean
Skip the name/type sanity check
delete_columndestructive
Remove a column and everything in it. Destructive.
projectId · boardId · columnId
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
columnId * string
Column id (from get_board_schema)
reorder_columns
Set the column order from a full list of column ids.
projectId · boardId · columnIds
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
columnIds * string[]
Column ids in the new order (every column of the board)
update_board
Rename a board or change its description.
projectId · boardId · name? · description?
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
name string
Human-readable name
description string
Free-text description
delete_boarddestructive
Delete a board with all its items. Destructive.
projectId · boardId
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
delete_projectdestructive
Delete a project with every board, column and row in it. Destructive; it lands in the organization recycle bin, so an admin can restore it.
projectId · organizationId?
projectId * string
Project id (from list_projects / create_project)
organizationId string
Organization id; defaults to the credential organization when omitted

Data

searchread-only
Full-text search across projects, boards and items in the organization. Returns { results: [{ id, title, url }] }, the shape ChatGPT connectors and deep research expect; pass an id to fetch.
query · projectId? · limit?
query * string
Search text
projectId string
Limit the search to one project
limit integer
Max results, default 20
fetchread-only
One project, board or item in full by the id search returned (or an app URL path). Returns { id, title, text, url, metadata }.
id · organizationId?
id * string
An id from search, or an app URL path such as /projects/…/boards/…/items/…
organizationId string
Only needed for project ids when the credential has no default organization; otherwise resolved automatically
query_itemsread-only
Rows with their cell values. Pages through automatically when unpaged.
projectId · boardId · search? · status? · excludeStatus? · priority? · sort? · archived? · limit? · page?
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
search string
Free text; matches the row title and its text cells
status string
Only rows whose status is one of these, comma separated. Matched against the board's status column by option value or label, case-insensitive: "todo,in_progress" on a task board, "Received,In Repair" on a data board whose status column lists those (get_board_schema shows the options)
excludeStatus string
Only rows whose status is NOT one of these, comma separated, resolved like status. The way to ask for everything that is not done: excludeStatus "done" (task board) or "Completed" (a data board with that option). Rows with no status at all are kept
priority string
Only rows with one of these priorities, comma separated (task boards only)
sort string
Sort by title, createdAt, updatedAt or status; prefix with "-" for descending, e.g. "-createdAt" for newest first. Anything else keeps the board order
archived "active" | "archived" | "all"
Which rows to include. Default active
limit integer
Page size; omit to fetch all items
page integer
1-based page, only with limit
export_projectread-only
The whole project as JSON: boards, columns, items and cells. GET …/projects/{id}/export.json returns it uncapped.
projectId · organizationId? · maxItemsPerBoard?
projectId * string
Project id (from list_projects / create_project)
organizationId string
Defaults to the credential organization
maxItemsPerBoard integer
Default 200
create_item
Create a row with all of its cells in one call, cells maps column id to value. set_cell is for later edits.
projectId · boardId · title · description? · status? · priority? · dueDate? · tags? · cells?
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
title * string
Item title, shown as the row name
description string
Free-text description
status string
Status value (todo, in_progress, done, or a value from the board's status options)
priority string
Priority: low, medium, high or urgent
dueDate string
ISO date
tags string[]
Tags as an array of strings
cells object
Cell values keyed by column id: { "<columnId>": value }. Scalars, { amount, currency } for currency, { relatedItemIds: [...] } for relations
update_item
Update title, description, status, priority, dueDate, tags.
projectId · boardId · itemId · title? · description? · status? · priority? · dueDate? · tags?
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
itemId * string
Item (row) id
title string
Item title, shown as the row name
description string
Free-text description
status string
Status value (todo, in_progress, done, or a value from the board's status options)
priority string
Priority: low, medium, high or urgent
dueDate string
Due date, ISO 8601 (YYYY-MM-DD or full timestamp)
tags string[]
Tags as an array of strings
set_cell
Set one cell by columnId. The reliable way to write column data.
projectId · boardId · itemId · columnId · value
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
itemId * string
Item (row) id
columnId * string
Column id (from get_board_schema)
value * string | number | boolean | null | any[] | object
The new cell value; shape depends on the column type
delete_itemdestructive
Delete a row. Destructive.
projectId · boardId · itemId
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
itemId * string
Item id (from query_items / create_item)

Threads

list_commentsread-only
An item’s correspondence thread, oldest first, with authors and mentions.
projectId · itemId · page? · limit?
projectId * string
Project id (from list_projects / create_project)
itemId * string
Item (row) id
page integer
1-based page, default 1
limit integer
Page size, default 20
add_comment
Post to the thread. mentionedUserIds notifies people; attachmentIds references uploaded files.
projectId · itemId · content · mentionedUserIds? · attachmentIds?
projectId * string
Project id (from list_projects / create_project)
itemId * string
Item (row) id
content * string
The comment text
mentionedUserIds string[]
User ids to @mention and notify
attachmentIds string[]
Ids of already-uploaded attachments to link
update_comment
Edit a comment. Author only.
projectId · itemId · commentId · content
projectId * string
Project id (from list_projects / create_project)
itemId * string
Item (row) id
commentId * string
Comment id (from list_comments)
content * string
The new comment text
delete_commentdestructive
Remove a comment. Destructive.
projectId · itemId · commentId
projectId * string
Project id (from list_projects / create_project)
itemId * string
Item id (from query_items / create_item)
commentId * string
Comment id (from list_comments / add_comment)

Apps & the REST surface

create_app
A named API surface over a project’s boards, for an external frontend.
name · projectId · organizationId?
name * string
Human-readable name
projectId * string
Project id (from list_projects / create_project)
organizationId string
Organization id; defaults to the credential organization when omitted
publish_app
Required before endpoints accept external calls.
appId · organizationId?
appId * string
App id or slug (from list_apps / create_app)
organizationId string
Organization id; defaults to the credential organization when omitted
list_appsread-only
Apps in the organization with id, slug and status. This is how you map a slug back to its app.
organizationId?
organizationId string
Organization id; defaults to the credential organization when omitted
create_app_endpoint
Expose a board at /apps/{appSlug}/api/{slug}. Pass exposedColumns; without it the endpoint is inert.
appId · boardId · slug · name · allowedMethods? · exposedColumns? · rowLevelSecurity? · organizationId?
appId * string
App id or slug (from list_apps / create_app)
boardId * string
Board id (from list_boards / create_board)
slug * string
URL slug: lowercase letters, digits and dashes
name * string
Human-readable name
allowedMethods "GET" | "POST" | "PATCH" | "DELETE"[]
HTTP methods the endpoint accepts: GET, POST, PATCH, DELETE
exposedColumns object[]
Columns the endpoint reads and writes, with the JSON key each one gets; without it the endpoint returns bare metadata
rowLevelSecurity object
Row-level security: when enabled, each app user sees and edits only their own rows
organizationId string
Organization id; defaults to the credential organization when omitted
list_app_endpointsread-only
Endpoints with their methods and exposed-column counts. A count of 0 means broken.
appId · organizationId?
appId * string
App id or slug (from list_apps / create_app)
organizationId string
Organization id; defaults to the credential organization when omitted
update_app_endpoint
Change an endpoint, most often to add exposedColumns to one created without them.
appId · endpointId · slug? · name? · allowedMethods? · exposedColumns? · isActive? · organizationId?
appId * string
App id or slug (from list_apps / create_app)
endpointId * string
Endpoint id (from list_app_endpoints)
slug string
URL slug: lowercase letters, digits and dashes
name string
Human-readable name
allowedMethods "GET" | "POST" | "PATCH" | "DELETE"[]
HTTP methods the endpoint accepts: GET, POST, PATCH, DELETE
exposedColumns object[]
Replacement list of exposed columns (same shape as create_app_endpoint)
isActive boolean
Whether it is active
organizationId string
Organization id; defaults to the credential organization when omitted
create_app_api_key
An app key (tk_…). Server-side only, never in browser code.
appId · name? · scopes? · organizationId?
appId * string
App id or slug (from list_apps / create_app)
name string
Human-readable name
scopes "read" | "write"[]
Permissions recorded on the key: ["read"] or ["read","write"]. Omit to match the app: write when any endpoint accepts POST, PATCH or DELETE.
organizationId string
Organization id; defaults to the credential organization when omitted
push_statusread-only
Whether this app can notify phones, and how many devices are registered.
appId · organizationId?
appId * string
App id or slug (from list_apps / create_app)
organizationId string
Organization id; defaults to the credential organization when omitted
send_test_push
One real notification, to prove the chain before an automation depends on it.
appId · userIds · title? · body? · organizationId?
appId * string
App id or slug (from list_apps / create_app)
userIds * string[]
App user ids to notify (the same ids row-level security uses)
title string
Notification title; defaults to "TaskLite"
body string
Notification body
organizationId string
Organization id; defaults to the credential organization when omitted
get_app_specread-only
Machine-readable spec: absolute base URL, endpoints, methods, fields. Accepts a UUID or a slug.
appId · format? · organizationId?
appId * string
App id or slug (from list_apps / create_app)
format "tasklite" | "openapi"
"tasklite" (default): compact endpoint list. "openapi": OpenAPI 3.1 document, every endpoint with typed fields, filter grammar, auth and errors
organizationId string
Organization id; defaults to the credential organization when omitted
get_frontend_promptread-only
A ready-made description of the backend for pasting into v0, bolt, lovable or cursor.
appId · tool · organizationId?
appId * string
App id or slug (from list_apps / create_app)
tool * "v0" | "bolt" | "lovable" | "cursor" | "claude-code"
Target tool the prompt is written for
organizationId string
Organization id; defaults to the credential organization when omitted

Frontend hosting

deploy_frontend
Put a static frontend live at {slug}.tasklite.dev. Hand it over as files (inline, the path from ChatGPT and every hosted client), a zipUrl (a Lovable/Bolt export or a GitHub release), or a local dir. Pages call the app API at the relative /api/…; the proxy adds the app identity, so no key reaches the browser.
appId · files? · zipUrl? · dir? · organizationId?
appId * string
App UUID or slug (app-xxxxxx), see list_apps
files object[]
The site files inline. Must include index.html. Up to 500 files / 8MB decoded, right for a frontend written in the conversation
zipUrl string
Public https URL of a zip of the BUILD OUTPUT (index.html at the root, or inside a single top-level folder). Up to 50MB
dir string
Local path to the BUILD OUTPUT directory (the one containing index.html), not the project root. Only where the MCP runs on the same machine as the files
organizationId string
Organization id; defaults to the credential organization when omitted
list_deploymentsread-only
Versions of an app’s hosted frontend, which one is live, and the public URL.
appId · organizationId?
appId * string
App id or slug (from list_apps / create_app)
organizationId string
Organization id; defaults to the credential organization when omitted
rollback_deployment
Point the live URL back at a previous version from list_deployments. The last five versions are kept, and each one is also readable on its own at v{n}--{slug}.tasklite.dev without changing what is live.
appId · version · organizationId?
appId * string
App id or slug (from list_apps / create_app)
version * integer
Deployment version number (from list_deployments)
organizationId string
Organization id; defaults to the credential organization when omitted

Automation

create_automation
When something happens, do something. http_request calls an external API and writes the answer back into columns, it needs responseMapping: [{ path, columnId }] or it runs and stores nothing. A delay action pauses the run; network actions take config.retry.
projectId · boardId · name · trigger · triggerConfig? · actions · isActive?
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)
name * string
Human-readable name
trigger * "item_created" | "status_changed" | "column_value_changed" | "date_approaching" | "scheduled"
Event that starts the automation: item_created, status_changed, column_value_changed, date_approaching, or scheduled (cron)
triggerConfig object
e.g. { cron: "0 8 * * *" } for scheduled, { columnName } for column_value_changed
actions * object[]
e.g. [{ type: "http_request", config: { url: "https://api.frankfurter.app/latest?from=USD&to=ILS", method: "GET", responseMapping: [{ path: "rates.ILS", columnId: "<column id from get_board_schema>" }] } }]
isActive boolean
Whether it is active
list_automationsread-only
What already runs on a board, before you add another.
projectId · boardId
projectId * string
Project id (from list_projects / create_project)
boardId * string
Board id (from list_boards / create_board)

Learned the hard way

Known traps

The first column mirrors the title, until you write to it

On a board without a column literally named Title, the first custom column shows the item title so the grid has something to display. Once you store your own value there (a phone, a name), it is yours: renaming the item no longer touches it. If you want the title visible in the grid, give the board a Title column or put a free-text column first.

Reserved aliases are refused

An exposed column cannot be aliased status, title, priority, dueDate or another row field - create_app_endpoint answers 400 and names the clash. Pick quoteStatus, deliveryStatus. Duplicate aliases and aliases that are not valid identifiers are refused the same way.

Without an alias, the key is the column name

An exposed column with no alias is keyed by the column name on reads and writes alike, whether or not the row has a cell yet (the raw column id is still accepted on write). Rename the column and the key changes with it, so set an alias when client code depends on the key.

Endpoints are live immediately

New endpoints added after publish_app work at once. There is no republish step, and reaching for one is usually a sign the real problem is elsewhere.

Column type names are not the obvious ones

A single-select is dropdown, not select. A person field is people, not person.

@tasklite/mcp 0.14.3 (release notes) · registry entry net.tasklite/mcp · hosted server mcp.tasklite.net/mcp · REST reference at api.tasklite.net/docs