Developers
Build a plugin
A plugin is a web page of yours that Tylode shows inside the app, in an isolated frame. It talks to the Tylode API through the app, with only the permissions the workspace grants it, so you can build dashboards, forms or small tools that live next to your pages and boards without handling tokens.
How plugins work
- It's a web page. A single HTML page served over
httpsfrom a public server. Keep the HTML under 2 MB, so Tylode can check its code (see Code changes and the integrity check). - It runs in a sandboxed iframe. The frame allows scripts and forms and nothing else. It doesn't share Tylode's origin, so it can't read Tylode's cookies or storage, or call the API directly.
- It talks to Tylode with
postMessage. Tylode tells the plugin which workspace it's in, forwards its API requests after checking its permissions, and pushes live events when things change. - It acts as the person viewing it. Requests use the session of whoever has the plugin open, so each person sees only what they can see in the app. No token and no Pro plan are needed, and requests count towards that person's rate limit.
- It's installed per workspace. Admins install it and choose its permissions; everyone in the workspace can use it.
- Each person accepts it. Because it works with the session of whoever opens it, the first time someone opens it they see its permissions and click Accept and open; if the permissions change, they're asked again.
Plugins use the same permissions (scopes) as OAuth apps, with two limits: Tylode always sets the workspace_id of the workspace where the plugin is installed (in the path and in the body), and a plugin can't use the personal content of whoever opens it: My drive (/storage), Studio designs (/presentations) or the personal calendar (personal=true).
The postMessage protocol
There are four message types. All of them are plain objects with a type field.
| Message | Direction | When |
|---|---|---|
tylode:init | Tylode → plugin | Every time the frame finishes loading. |
tylode:api-request | Plugin → Tylode | Whenever the plugin wants to call the API. |
tylode:api-response | Tylode → plugin | The answer to one request. |
tylode:event | Tylode → plugin | Something changed in the workspace. |
tylode:init
{
type: "tylode:init",
workspaceId: "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37",
scopes: ["pages:read", "pages:write"]
}workspaceId: the workspace where the plugin is installed. Use it asworkspace_idin your requests.scopes: the permissions the workspace granted. Use them to hide features the plugin can't use.
Tylode sends it when the frame's load event fires, so register your message listener in a script that runs during page load, not after a delay.
tylode:api-request
window.parent.postMessage(
{
type: "tylode:api-request",
requestId: "k2x9f7", // any unique string you choose
method: "POST", // "GET", "POST" or "PATCH"
path: "/pages", // relative to /api/v1, starting with /
body: { workspace_id: "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37", title: "Notes" }
},
"*"
);| Field | Required | Description |
|---|---|---|
type | Yes | "tylode:api-request". |
requestId | Yes | A string you generate. The response carries the same value, so you can match them when several requests are in flight. |
method | Yes | GET, POST or PATCH. |
path | Yes | The API path relative to /api/v1, with its query string: /pages?workspace_id=…, /boards/BOARD_ID/cards. |
body | No | For POST and PATCH, an object. Tylode sends it as JSON. |
Before forwarding a request, Tylode checks the permission it needs: GET requires the resource's :read scope, and POST or PATCH its :write scope (for example, GET /pages?… needs pages:read, POST /boards/:id/cards needs boards:write). Routes that no scope covers are always refused: /workspaces (you already get the workspace ID in tylode:init) and any write to /search. Databases use databases:read and databases:write, like everything else. The bridge doesn't accept DELETE, so a plugin can't delete anything even if the workspace grants it a :delete scope. All endpoints are listed in the endpoint index.
tylode:api-response
When the request reached the API:
{
type: "tylode:api-response",
requestId: "k2x9f7",
status: 201,
data: {
data: {
id: "2b9d6f1e-8a4c-4f3b-b7e2-51c0d9a8e6f4",
workspace_id: "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37",
parent_page_id: null,
title: "Notes",
icon: null,
position: 3
}
}
}statusis the API's HTTP status.datais the API's whole JSON body. So on success the result is indata.data, and when the API returns an error, its message is indata.error.
When Tylode refuses the request because the plugin lacks the permission, or the route isn't available to plugins:
{ type: "tylode:api-response", requestId: "k2x9f7", status: 403, error: "…" }If Tylode can't reach the API at all, you get status: 500 with an error in the same shape.
tylode:event
When something changes in the workspace, Tylode pushes it to the plugin so you don't need to poll:
{
type: "tylode:event",
resource: "pages",
eventType: "UPDATE",
record: { id: "2b9d6f1e-8a4c-4f3b-b7e2-51c0d9a8e6f4", title: "Notes (final)", ... }
}resource | Needs | Fires when |
|---|---|---|
pages | pages:read | A page is created, changed or deleted. |
boards | boards:read | A board is created, changed or deleted. |
calendar_events | calendar:read | A calendar event is created, changed or deleted. |
whiteboards | whiteboards:read | A whiteboard is created, changed or deleted. |
eventTypeisINSERT,UPDATEorDELETE.recordis the item as stored. It can include more fields than the API returns and, for deletions, may be empty. Treat events as a signal to reload through the API rather than as data to trust field by field.- You only get events for this workspace, for resources whose read permission the plugin has, and for items the viewer can see.
- Changes inside those items (cards on a board, items on a whiteboard) don't produce events. Reload the board or whiteboard when its own event arrives, or when the user asks.
A complete example
This plugin lists the workspace's pages, lets you create one, and refreshes itself when pages change anywhere. It's based on the official "Hello Pages" example from the catalog. Install it with pages:read and pages:write.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Quick pages</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #fafafa; color: #18181b; }
h1 { font-size: 15px; margin: 0 0 12px; }
form { display: flex; gap: 6px; margin-bottom: 12px; }
input { flex: 1; padding: 6px 8px; border: 1px solid #d4d4d8; border-radius: 6px; font: inherit; }
button { padding: 6px 10px; border: 0; border-radius: 6px; background: #18181b; color: #fff; font: inherit; cursor: pointer; }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 6px 8px; border-radius: 6px; font-size: 13px; }
li:nth-child(odd) { background: #f0f0f1; }
.error { color: #dc2626; font-size: 13px; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<h1>Quick pages</h1>
<form id="new-page" hidden>
<input id="title" placeholder="New page title" required />
<button type="submit">Create</button>
</form>
<p id="error" class="error" hidden></p>
<ul id="pages"></ul>
<script>
// Filled in when Tylode sends tylode:init.
let workspaceId = null;
let scopes = [];
// Requests waiting for an answer, by requestId.
const pending = new Map();
// Calls the Tylode API through the host. Resolves with the API's `data`,
// rejects with the error message.
function api(method, path, body) {
const requestId = Math.random().toString(36).slice(2) + Date.now().toString(36);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(requestId);
reject(new Error("Tylode didn't answer in time"));
}, 15000);
pending.set(requestId, (msg) => {
clearTimeout(timer);
if (msg.status >= 200 && msg.status < 300) {
resolve(msg.data.data); // msg.data is the API's JSON body
} else {
reject(new Error(msg.error || (msg.data && msg.data.error) || "Error " + msg.status));
}
});
window.parent.postMessage({ type: "tylode:api-request", requestId, method, path, body }, "*");
});
}
window.addEventListener("message", (event) => {
// Only accept messages from Tylode, the page that embeds this plugin.
if (event.source !== window.parent) return;
const msg = event.data;
if (!msg || typeof msg.type !== "string") return;
if (msg.type === "tylode:init") {
workspaceId = msg.workspaceId;
scopes = msg.scopes;
// Only offer to create pages if the workspace granted pages:write.
document.getElementById("new-page").hidden = !scopes.includes("pages:write");
loadPages();
} else if (msg.type === "tylode:api-response") {
const settle = pending.get(msg.requestId);
if (settle) {
pending.delete(msg.requestId);
settle(msg);
}
} else if (msg.type === "tylode:event" && msg.resource === "pages") {
// A page was created, renamed or deleted somewhere: reload the list.
loadPages();
}
});
async function loadPages() {
try {
const pages = await api("GET", `/pages?workspace_id=${workspaceId}`);
document.getElementById("pages").replaceChildren(
...pages.map((page) => {
const li = document.createElement("li");
// textContent, not innerHTML: titles are user content.
li.textContent = `${page.icon || "📄"} ${page.title || "Untitled"}`;
return li;
}),
);
showError(null);
} catch (err) {
showError(err.message);
}
}
document.getElementById("new-page").addEventListener("submit", async (event) => {
event.preventDefault();
const input = document.getElementById("title");
try {
await api("POST", "/pages", { workspace_id: workspaceId, title: input.value });
input.value = "";
loadPages(); // the tylode:event for the new page also triggers a reload
} catch (err) {
showError(err.message);
}
});
function showError(message) {
const el = document.getElementById("error");
el.hidden = !message;
el.textContent = message || "";
}
</script>
</body>
</html>The api() helper is the part to reuse: it gives every request an ID, keeps a promise for it and settles it when the matching tylode:api-response arrives. The timeout stops a promise from waiting forever if the frame is reloaded mid-request.
What the sandbox allows
The frame has sandbox="allow-scripts allow-forms". In practice:
- Works: your JavaScript and CSS, forms,
fetchto your own server (it will see the request coming from originnull, so allow that in its CORS settings), images and scripts from other domains. - Doesn't work: Tylode's cookies and storage, and also
localStorage,sessionStorage, IndexedDB and cookies for your own page; pop-ups and new windows;alert(),confirm()andprompt(); navigating the Tylode window.
To remember settings, save them on your own server or, if they belong to the workspace, in a Tylode page through the API.
Your server must also allow the page to be framed by Tylode: don't send X-Frame-Options: DENY or SAMEORIGIN, and if you use a Content-Security-Policy with frame-ancestors, include https://app.tylode.com.
Install your plugin
You need to be an admin of the workspace to install plugins. Other members can use the installed ones.
- Open Settings → Workspace → Plugins (go there).
- Click Install plugin.
- Fill in Name and Plugin URL (loaded in an isolated iframe), which must start with
https://. - Under Permissions, tick only what the plugin needs. Each one is shown with its description, such as "Read your pages".
- Click Install.
The plugin appears as a card showing how many permissions it has. Click it to open it full page. To change its permissions later, uninstall it (trash icon, Uninstall, click again to confirm) and install it again.
While developing, serve your page through an HTTPS tunnel (such as ngrok or Cloudflare Tunnel) and install the tunnel's URL in a test workspace.
Code changes and the integrity check
When a plugin is installed, Tylode saves a fingerprint of its HTML. Every time someone opens the plugin, Tylode fetches the page again and compares:
- If it's the same, nothing is shown.
- If it has changed, a banner warns: "This plugin's code has changed since it was installed." The address is the same, but the code isn't what was reviewed when the permissions were granted. Someone who can manage plugins reviews it and clicks Trust the current code to save the new fingerprint, or Not now to dismiss it for the moment.
- If Tylode can't fetch the page (your server is down, it answers with an error, or the HTML is over 2 MB), the banner says it couldn't check the plugin's code instead of warning about a change.
So when you ship an update, admins of every workspace that installed your plugin will see the warning until they trust the new code. Tell them in advance. Only the HTML document is fingerprinted: scripts and styles loaded from other files aren't. If you want the check to cover all your code, put it inline in the HTML.
Pin it as a side panel
Any member can pin one plugin as a side panel that stays open while they move around Tylode:
- Open Settings → Workspace → Plugins and hover over the plugin's card.
- Click the pin icon (Pin as side panel: it stays open while you browse).
Drag the panel's edge to change its width, collapse it when you need room, or unpin it from the panel itself or with Remove from side panel on the card. The pinned plugin is personal: each person chooses their own.
If you expect people to pin your plugin, design it to work in a narrow column.
Get into the catalog
The Catalog at the top of Plugins lists plugins reviewed by Tylode. Choosing one with Use this one fills in the install form with the permissions its author recommends, and the admin confirms them before installing.
To propose your plugin for the catalog, write to support@tylode.com with its URL, what it does and the permissions it needs.
Related pages
- Plugins in the app: installing and using plugins, for everyone.
- OAuth 2.0 apps: the same scopes, for apps outside Tylode.
- Developer platform: every endpoint a plugin can call.