Skip to content
← Help center

Build your own app on top of Tulina

DevelopersUpdated

Tulina is a backend you already have: tables, pages, projects, search, file storage, and agents that work through your connectors. Everything the Tulina app does goes through a REST API, and your own front can use the same one.

So a client portal, an internal tool or a customer-facing app can be just a front: Tulina stores the data, runs the work, and stays the console where your team manages it all.

Who this is for. A developer building that front. You need a Tulina account with access to what the front will use.

What you can build on

Building blockWhat it gives your frontStart with
AccountWho is calling, and in which workspaceGET /api/me
TablesStructured data: rows, search, filters, history/api/datastores/…/rows
PagesMarkdown content inside projects: docs, notes, a knowledge basePOST /api/me/docs
ProjectsThe containers that hold pages, files and tablesPOST /api/me/projects
SearchOne query across pages, processes, tables and rowsGET /api/me/search
FilesUploads, images, and bulk importsPOST /api/me/upload-url
AgentsWork done with your connectors: enrich, email, update a CRMPOST /api/hooks/…

The API reference lists every endpoint, grouped the same way.

The one rule: call Tulina from your server

Visitor's browser
      ↓
Your server        ← holds the token
      ↓
Tulina API

Two reasons, and both are hard limits rather than advice:

  • A token acts as you. Whoever holds it can do everything your account can. Put it in browser code and every visitor has it.
  • Browsers on your domain are refused. The API only answers browser requests coming from the Tulina app itself. A fetch from your site's pages fails before it reaches your data.

So your server keeps the token, and exposes only the few routes your front needs. Your visitors never become Tulina users: your server decides what each of them may see and do.

No server? You may not need one

"Server" just means code that runs somewhere other than the visitor's browser. Two ways to get that without running a machine:

  • A no-code tool. n8n, Make and Zapier each have an HTTP request step: store the token in the tool, and let a form submission add a row to your Tulina table.
  • A serverless function. Vercel, Netlify or Cloudflare run a single file on demand, with the token in an environment variable. The example further down is one: deployed on Vercel, that file is the server.

Calls from a server, or from n8n, Make or Zapier, work from anywhere — there is nothing to set up on our side. Need to call Tulina straight from your website's pages instead? Write to tech@tulina.ai with your domain and what the front needs, and we will find the safe way to do it.

Set up

Create a token. In Tulina, open Settings → Personal → API tokens and click New token. It is shown once: put it straight into your server's environment variables, never into your code or your repository.

A token has the access of the account that created it, and it does not expire on its own. Create it from an account whose access matches what the front needs, and revoke it from the same screen the moment it leaks or the project ends.

Find your ids. Tulina's addresses carry them:

app.tulina.ai/org/42/tables/317
app.tulina.ai/org/42/projects/12

42 is your workspace, 317 a table, 12 a project. Use numbers rather than names: a rename does not break a number.

Make a first call. GET /api/me says who the token is and which workspace the call ran in — the call to make first, and a good health check.

curl https://mcp.tulina.ai/api/me \
  -H "Authorization: Bearer $TULINA_TOKEN" \
  -H "X-Oto-Org: 42"

Four conventions

  • Two headers on every call. Authorization: Bearer … and X-Oto-Org: <workspace>. Without the second, the call runs in your home workspace — fine until you belong to two.
  • Ids, not names. As above.
  • Some paths take the action in the body. Projects, pages and agents each have one path, and the body's op says what to do: {"op": "list"}, {"op": "get", …}, {"op": "create", …}. The reference lists every op.
  • One error shape. {"error": "<code>", "detail": "<sentence>"}. Branch on error, which is stable; log detail, which says what to fix.

Recipes

Show and edit data

Tables are where most fronts start: a list of leads, orders, tickets, bookings.

# A page of rows: add offset= for the next one, q= to search, order_by= to sort
curl "https://mcp.tulina.ai/api/datastores/317/rows?limit=50" \
  -H "Authorization: Bearer $TULINA_TOKEN" -H "X-Oto-Org: 42"

# Add a row: the body is the row, one key per column
curl -X POST "https://mcp.tulina.ai/api/datastores/317/rows" \
  -H "Authorization: Bearer $TULINA_TOKEN" -H "X-Oto-Org: 42" \
  -H "Content-Type: application/json" \
  -d '{"name": "Ada Lovelace", "email": "ada@example.com"}'

Reading returns {"rows": [ … ], "total": …, "offset": …, "limit": …}, and every row carries its _id. To change a row, PATCH …/rows/<_id> with only the columns that change. To write many, POST …/rows/batch with {"rows": [ … ], "key": "email"}: rows whose email exists are updated, the others created.

Publish content

Pages are markdown, organised in projects: help articles, a client's documents, a knowledge base.

# The page index of project 12: titles and ids, not the bodies
curl -X POST https://mcp.tulina.ai/api/me/docs \
  -H "Authorization: Bearer $TULINA_TOKEN" -H "X-Oto-Org: 42" \
  -H "Content-Type: application/json" \
  -d '{"op": "list", "project_id": 12}'

Then {"op": "get", "doc_id": …} returns one page with its body, and {"op": "create", "project_id": 12, "title": "…", "body_md": "…"} writes a new one.

One call searches everything the token can read — pages, processes, tables, and the rows inside them:

curl "https://mcp.tulina.ai/api/me/search?q=invoice+acme&limit=10" \
  -H "Authorization: Bearer $TULINA_TOKEN" -H "X-Oto-Org: 42"

The answer holds hits, plus total and truncated so you can tell "10 results" from "10 of 300". Add kinds=page to search only pages; the reference lists the other kinds.

Let an agent do the work

Your front should not reimplement what Tulina's agents already do with your connectors: enrich a contact, draft and send an email, update the CRM. Hand the job to an agent instead.

  1. In Tulina, go to Agents → New agent → On an event, and pick the process it follows and the tools it may use.
  2. On the agent's page, Connect a sender shows its Address and a Bearer token. Set what it receives to Pass it to the agent.
  3. Your server posts the event:
curl -X POST "<the agent's address>" \
  -H "Authorization: Bearer $AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@example.com", "company": "Acme"}'

The agent runs in the background. Have its process write the outcome somewhere your front reads — a row in a table, a page — and show it from there.

Upload files

Files go straight to Tulina, not through your server's memory. Ask for a single-use link, then send the bytes to it:

curl -X POST https://mcp.tulina.ai/api/me/upload-url \
  -H "Authorization: Bearer $TULINA_TOKEN" -H "X-Oto-Org: 42" \
  -H "Content-Type: application/json" \
  -d '{"target": "project_file", "project_id": 12, "filename": "contract.pdf"}'

The answer holds a url: PUT the file to it, with no Authorization header. The same link takes a doc, an image (which gets a public address), or a CSV or NDJSON file of rows for a table — the way to load thousands of rows at once.

A minimal server

Every recipe follows the same pattern on your side: a route that calls Tulina with the token, and passes on only what the front needs. Here it is for a front that lists leads and adds new ones, as a Next.js route handler — any server language works the same way.

// app/api/leads/route.ts — runs on your server. The token never reaches the browser.
const TABLE = "https://mcp.tulina.ai/api/datastores/317/rows";
const headers = {
  Authorization: `Bearer ${process.env.TULINA_TOKEN}`,
  "X-Oto-Org": "42",
  "Content-Type": "application/json",
};

export async function GET() {
  const res = await fetch(`${TABLE}?limit=50`, { headers });
  const { rows } = await res.json();
  return Response.json(rows);
}

export async function POST(request: Request) {
  // Pick the fields you accept: a visitor must not be able to write any column.
  const { name, email } = await request.json();
  const res = await fetch(TABLE, {
    method: "POST",
    headers,
    body: JSON.stringify({ name, email }),
  });
  return Response.json(await res.json(), { status: res.status });
}

Your pages then call /api/leads on your own domain. Nothing about Tulina reaches the browser.

When something fails

errorWhat it means
missing_bearerThe Authorization header is missing.
invalid_api_tokenThe token is wrong, or it was revoked.
revision_conflictA row changed since the _revision you sent with ?expected_revision=. Read it again, then retry.

Going further

The API reference has every endpoint, parameter and response. Its Ask Claude button opens Claude with the reference already in the prompt: describe the app you want, and it writes the calls with you.

Still stuck? Talk to us.

Experience Tulina for 7 days.

How many teammates will be using Tulina?

By continuing, you agree to our Terms of Use and Privacy policy.