Custom apps (hosted sites)
A custom app is a frontend your workspace’s admins build and deploy
themselves, often with an AI’s help. Tessule hosts it at its own address,
https://<site id>.tessule.dev, and handles the sign-in, the hosting and the
API. Only signed-in members of the workspace can open it, and it acts as
whoever is using it, with that member’s own permissions.
A workspace has at most one custom app.
Opening a custom app
Section titled “Opening a custom app”Open the address your admin gave you. If you are not signed in to that app, you are sent to Tessule to sign in and asked to allow the app to act as you in the workspace. Then you are taken back to the page you asked for.
You may see one of these pages instead of the app:
- There is no site at this address. No custom app lives at this address. It may have been removed, or the link may be mistyped. Check the link with whoever shared it, or ask your workspace admin for the right address.
- This site is under construction. The app exists but nothing has been deployed to it yet. If you are waiting to use it, ask your workspace admin when it will be ready. If you are building it, see Deploying a site.
You stay signed in to the app while you use it. After two hours without using it, or a day after you signed in, you are signed out. Opening it again signs you straight back in, without asking again, as long as you are still signed in to Tessule and allowed the app within the last 30 days.
An app built from the starter doesn’t lose your work when you’re signed out. It shows Your session ended — Sign in again and waits. Choosing Sign in again opens Tessule’s sign-in in a small window, which usually closes by itself straight away. The app then carries on where you were, in every tab you have it open in. If your browser blocks the window, the app signs you in in the same tab instead.
To sign out of the app, use the app’s own sign-out, or go to /_auth/logout on
the app’s address. Tessule then shows a page saying you are signed out. The
next time you open the app, Tessule asks you to allow it again. Signing out of
Tessule signs you out of the app too.
Creating a site
Section titled “Creating a site”Workspace admins create the site in Workspace settings → Custom app with Create custom app. The tab then shows the site’s address. Members can sign in to that address straight away and see the under-construction page until something is deployed.
Deploying a site
Section titled “Deploying a site”A site is a static single-page app: HTML, JavaScript, CSS and assets, with an
index.html at the top. Any path without a file extension serves
index.html, so the app’s own router handles its routes. The app calls the
workspace API on its own origin under /_api/…; the browser sends the
member’s sign-in with each call, so no token ever sits in page script.
Deploy a build with the Tessule CLI, using a credential for the workspace:
export TESSULE_TOKEN=<a credential for the workspace>tessule pull # write the workspace's API types to src/tessule-api.tsnpm run build # build the app into dist/tessule deploy dist # upload the build and make it livetessule deploy uploads every file, then makes the deployment live and prints
the site’s address. With --no-activate it leaves the deployment ready
instead, and an admin makes it live from Workspace settings → Custom app
with Make live.
Every deployment is kept for a while, and the same tab lists them with who uploaded each one. To roll back, choose Roll back to this on an earlier deployment. Switching is immediate, and members never see a half-uploaded site.
Building a branded deck
Section titled “Building a branded deck”Tessule does not write PowerPoint files itself: a firm’s slide layout is the firm’s own, and a custom app is where it lives. Everything the deck needs is already an operation the app calls as the signed-in member, so the deck holds exactly what that member may see:
| Input | Operation |
|---|---|
| Branding: display name, colours, logo key, chart palette, font | GET /workspace/settings |
| The charts and how each is drawn | GET /charts, then GET /charts/{chartId} for the config |
| A chart’s rows | POST /tables/{tableId}/aggregate for a chart drawn from a table’s records (the response is row-scoped for the caller); POST /query for one backed by a saved query or custom SQL |
| A data appendix | POST /query/exports with format: xlsx, or buildXlsx from @taskexchange/api-spec for rows the page already holds |
A deck is then a loop: fetch the branding once, fetch each chart’s rows, draw
the chart with the app’s own chart component (the starter’s, or any library),
and hand each picture and the palette to a client-side deck library. The
sketch below uses pptxgenjs, which runs in the browser and takes PNG data
URLs and native chart data alike; it is the app’s dependency, not Tessule’s.
import PptxGenJS from 'pptxgenjs';
const { branding } = await api.GET('/workspace/settings');const { data: charts } = await api.GET('/charts');
const deck = new PptxGenJS();deck.defineLayout({ name: 'WIDE', width: 13.33, height: 7.5 });deck.layout = 'WIDE';
for (const chart of charts.slice(0, 5)) { const rows = await rowsFor(chart); // one of the two operations above const png = await drawChartAsPngDataUrl(chart, rows, branding?.chartPalette); const slide = deck.addSlide(); slide.addText(chart.name, { x: 0.5, y: 0.3, fontFace: branding?.fontFamily ?? 'Calibri', color: (branding?.primaryColor ?? '#1f2937').slice(1) }); slide.addImage({ data: png, x: 0.5, y: 1, w: 12.3, h: 6 });}await deck.writeFile({ fileName: 'market-update.pptx' });The same loop can take the PNG the Charts page downloads: the picture a member saves from a card is drawn the same way, so a deck assembled by hand and one built by the app agree.
When a member’s session ends
Section titled “When a member’s session ends”A session ends after two hours unused, or a day after sign-in. By default,
@tessule/client then sends the browser to sign in and back to the page it
was on, so anything unsaved on the page is lost. To keep the page instead, create the client
with onUnauthorized: 'hold'. The starter app does this.
import { createClient, TessuleClientError } from '@tessule/client';
const api = createClient<paths>({ onUnauthorized: 'hold', onSessionExpired: () => showBanner(), // "Your session ended — Sign in again" onSessionResumed: () => hideBanner(), // signed in again, in this tab or another});
// In the banner button's click handler: browsers only open popups from a click.api.resumeSession().catch((e) => { if (e instanceof TessuleClientError && e.kind === 'popup-blocked') api.signIn();});A request that finds the session ended waits. It doesn’t fail, and the page doesn’t change. Once the member has signed in again in the popup, the request is sent again and answers normally. Writes are retried too: the API refused the first attempt before acting on it.
If sign-in is cancelled, fails or takes more than five minutes, the waiting
requests reject with a TessuleClientError. Its kind says why:
sign-in-cancelled, sign-in-failed, sign-in-timeout, or session-abandoned
after api.abandonSession(). api.sessionState() is 'expired' until the
member is back.
Delete custom app in the same tab takes the site offline at once. Its address then shows the no-site page.