Build apps for Design Studio

Apps are built and reviewed in a developer portal that runs on your own self-hosted server. Download the server bundle, turn the portal on, and use these docs to make your first app.

How apps work

An app is a small web page from outside Design Studio. It runs beside the canvas in a sandbox, and everything it does goes through a checkpoint.

  1. Your appA sandboxed frame with no network, cookies or storage
  2. The editorChecks each request against what the person approved
  3. The serverChecks the install and your manifest, then adds keys
  4. Outside servicesOnly the hosts in your manifest, over https

What an app can do

  • Add a picture to the design, or replace the selected one.
  • See whether a picture is selected.
  • Reach the services listed in its manifest, through the server.
  • Save a small object for each person, up to 64 KB.
  • Open links on hosts it lists, in the person’s browser.
  • Use each person’s own account with a service, once they connect it.

It can’t read the design’s contents, add text, or see who the person is.

Two ways to use a service

A shared keyEach person’s account
Who signs inYou, onceEach person, with OAuth
Whose dataThe same for everyoneOnly that person’s
Good forStock photos and public dataNotion, Drive and anything private
Read moreUsing a keyConnecting accounts

Never use a shared key for private data. Everyone who installs your app could see whatever that key can reach.

Turn on the portal

The portal is part of your server, and it’s off until you set it up. If you don’t have a server yet, start with the Self-Hosted Guide. The portal needs server version 0.1.69 or newer.

  1. Pick an address for the portal, such as developers.example.com, and point its DNS at your server.
  2. Add these settings to .env next to docker-compose.yml.
.env
# A second address for the portal. Point its DNS at this server first.
DEVELOPER_URL=https://developers.example.com
# These people invite developers and review versions.
ADMIN_EMAILS=you@example.com
# Encrypts app keys and connected accounts. Don't change it later.
# Create one with: openssl rand -base64 32
APP_KEYS_SECRET=
  1. Restart the server. The bundle’s Caddy gets the new address its HTTPS certificate.
bash
docker compose --profile https up -d
  1. Open your DEVELOPER_URL and sign in with an email from ADMIN_EMAILS.
  2. Invite anyone else who will build apps. They need a verified email on your server.

On Kubernetes or behind your own proxy, set the same three values on the server and route the portal’s hostname to it.

The apps you approve stay on your server. People on it also see apps from the Design Studio marketplace. To limit what they see, set APPS_ALLOWED to none or to a list of app ids. MARKETPLACE_URL=off stops reading the marketplace.

Quickstart

Build an app that adds a placeholder picture to a design. You need access to your server’s portal, and no build tools.

1. Create the files

Put these four files in one folder.

manifest.json
{
  "id": "placeholder-pictures",
  "name": "Placeholder Pictures",
  "version": "1.0.0",
  "developer": "Your name",
  "description": "Add a placeholder picture to your design.",
  "category": "graphics",
  "entry": "index.html",
  "permissions": ["design:addImage"],
  "network": [{ "host": "picsum.photos" }, { "host": "fastly.picsum.photos" }],
  "links": [],
  "secrets": []
}
index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Placeholder Pictures</title>
    <link rel="stylesheet" href="app.css" />
  </head>
  <body>
    <button id="add" type="button">Add a picture</button>
    <p id="status" role="status"></p>
    <script src="/app-sdk/v1.js"></script>
    <script src="app.js"></script>
  </body>
</html>
app.css
body {
  margin: 0;
  padding: 14px;
  font: 13px/1.4 system-ui, sans-serif;
  background: #fff;
  color: #1a1f1c;
}
:root[data-theme='dark'] body {
  background: #1b1e1b;
  color: #edf0ec;
}
button {
  width: 100%;
  height: 34px;
}
app.js
const status = document.getElementById('status')

document.getElementById('add').addEventListener('click', async () => {
  status.textContent = 'Adding…'
  try {
    const res = await studio.network.fetch({ url: 'https://picsum.photos/800/600.jpg' })
    if (!res.dataUrl) throw new Error('That was not a picture.')
    await studio.design.addImage({ dataUrl: res.dataUrl })
    status.textContent = ''
  } catch (err) {
    status.textContent = err.message
  }
})

/app-sdk/v1.js gives your page window.studio. Scripts must be in their own files, because the sandbox refuses inline ones.

2. Zip them

The manifest must be at the top of the zip, not inside a folder.

bash
zip app.zip manifest.json index.html app.css app.js

3. Upload it

  1. Open your portal and sign in.
  2. Create the app id placeholder-pictures. It must match the manifest.
  3. Choose Upload zip. If something is wrong, the portal says what.

4. Try it

Choose Try it, open any design and find the app in the side panel. Only you can see this test version. Press the button, and a picture lands on the page.

To change it, upload the zip again. A draft is replaced as long as the version number stays the same.

5. Submit it

When it works, choose Submit for review. An admin on your server reviews it, as described in Reviewing and publishing.

The manifest

manifest.json sits at the top of your zip. It describes your app and is the full list of what it can do.

FieldMeaning
idLowercase letters, digits and hyphens, starting with a letter, 2 to 40 characters. Must match the app id in the portal.
nameUp to 60 characters.
versionLike 1.2.3. An update needs a higher number.
developerWho it is by, up to 80 characters.
descriptionWhat it does, up to 500 characters.
whatsNewOptional. What changed in this version, up to 500 characters. People read it before they choose to update.
categoryOptional. One of photos, graphics, ai, text, data, publish, productivity, other.
entryThe HTML file to open.
iconOptional. A file in the package.
screenshotsOptional. Up to 6 pictures for the marketplace page.
website, support, privacyOptional https links for the marketplace page.
permissionsWhat it may do in a design.
networkThe only hosts it can reach.
linksHosts it may open in the person’s browser.
secretsNames of the keys you save in the portal.
oauthOptional. Lets each person connect their own account.

Permissions

PermissionWhat people are told
design:addImageAdd pictures to your design
design:replaceImageReplace the picture you have selected
design:readSelectionSee whether a picture is selected

An unknown permission makes the package invalid. Ask only for what you use: an update that asks for more has to be approved by each person before they get it.

Network rules

FieldMeaning
hostThe exact host name. No wildcards.
headersAdded by the server, for this host alone. Allowed: Authorization, Accept, Content-Type, X-Api-Key, and any name ending in -Version.
perHourCaps requests to this host. With a shared key the cap is across your whole app; with an account, it is per person.
cacheSecondsShares a GET’s answer between people, up to a day. Not allowed with a person’s own account.

What the zip can hold

HTML, JS, CSS, JSON, PNG, JPEG, WebP, SVG and WOFF2 files, up to 32 MB and 300 files.

The SDK

Load /app-sdk/v1.js as a plain script. Every call returns a promise, and a refused call rejects with an Error that has a code.

Design

CallNeeds
studio.design.addImage({ dataUrl })design:addImage
studio.design.replaceImage({ dataUrl })design:replaceImage
studio.design.getSelection()design:readSelection

dataUrl must be a PNG, JPEG or WebP data URL of at most 20 MB. getSelection resolves { type: 'image' | 'other' | 'none' }.

Network

javascript
const res = await studio.network.fetch({
  url: 'https://api.example.com/search',
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query })
})
// res: { status, contentType, text, dataUrl }

Only GET and POST, and only Accept and Content-Type headers from your code. Text and JSON arrive in text, and pictures in dataUrl, ready for addImage.

Storage, links and theme

CallDoes
studio.storage.get()This person’s saved object for your app.
studio.storage.set(object)Replaces it, up to 64 KB.
studio.links.open(url)Opens a host in your links, in the browser.
studio.onTheme((theme) => …)Tells you light or dark. <html data-theme> is also set.

Accounts

CallDoes
studio.account.status()Resolves { connected }.
studio.account.connect()Opens the provider’s sign-in. Call it from a click. Resolves { connected } when they finish, or after 5 minutes.
studio.account.disconnect()Forgets this person’s token on this server.

Error codes

CodeWhat to do
NOT_ALLOWEDAdd the permission or link host to your manifest.
HOST_NOT_ALLOWEDAdd the host to network. A redirect to an unlisted host also causes this.
NOT_CONNECTEDShow your connect button.
NOT_CONFIGUREDA key is missing. Save it in the portal.
NOT_INSTALLEDThe person removed the app, or hasn’t approved this version.
READ_ONLYThe design is view-only. Tell the person.
NO_SELECTIONAsk them to select a picture first.
RATE_LIMITEDToo many requests. Slow down and try again.
BUSYYour perHour limit was reached. Try again later.
BLOCKEDThe response is too large, or a type apps can’t receive.
PROVIDER_ERRORThe service couldn’t be reached.
INVALIDThe call’s input is wrong, such as a picture in the wrong format.

Using a key

If a service needs an API key that doesn’t belong to any one person, you save it in the portal and the server adds it to your requests. Your code never sees it.

manifest.json
{
  "network": [
    {
      "host": "api.pexels.com",
      "headers": { "Authorization": "{{secret:PEXELS_API_KEY}}" },
      "perHour": 150,
      "cacheSeconds": 600
    },
    { "host": "images.pexels.com" }
  ],
  "secrets": ["PEXELS_API_KEY"]
}
  1. List the key’s name in secrets, and use it in a header as {{secret:NAME}}.
  2. Upload your zip. A field for each key appears in the portal.
  3. Paste the key and save it. It is encrypted and can’t be read back.

Your code calls the service without the key. The server adds it for that host alone, and removes it from any response that echoes it.

A key’s limit is shared by everyone using your app. Set perHour a little below it, and use cacheSeconds so people asking the same thing share one answer.

For an app from the Design Studio marketplace, such as Pexels, you supply the key yourself in .env, as APP_SECRET_<APP ID>_<NAME>.

Connecting accounts

To show people their own data, each person connects their own account with OAuth. The server keeps their token and adds it to your requests.

1. Declare it

manifest.json
{
  "oauth": {
    "authorizeUrl": "https://api.notion.com/v1/oauth/authorize",
    "tokenUrl": "https://api.notion.com/v1/oauth/token",
    "params": { "owner": "user" },
    "clientAuth": "basic",
    "tokenFormat": "json"
  },
  "network": [
    {
      "host": "api.notion.com",
      "headers": {
        "Authorization": "Bearer {{account:token}}",
        "Notion-Version": "2022-06-28"
      },
      "perHour": 500
    }
  ],
  "secrets": ["OAUTH_CLIENT_ID", "OAUTH_CLIENT_SECRET"]
}
FieldMeaning
authorizeUrl, tokenUrlThe provider’s addresses, over https.
scopesOptional. What to ask the provider for.
paramsOptional. Extra parameters for the sign-in page.
clientAuthbody (default) or basic: how the server identifies your app to the provider. Check its docs.
tokenFormatform (default) or json: how it sends that request.

A header using {{account:token}} carries the person’s token. That host can’t use cacheSeconds, because the cache is shared.

2. Register with the provider

  1. Create an OAuth app or connection in the provider’s developer settings.
  2. Upload your zip. The portal shows the redirect address to give the provider.
  3. Save the provider’s client id and secret as OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET.

3. Use it in your app

app.js
const { connected } = await studio.account.status()
if (!connected) showConnectButton()

// From a click, so the browser allows the new window.
button.addEventListener('click', async () => {
  const { connected } = await studio.account.connect()
  if (connected) showPages()
})

try {
  const res = await studio.network.fetch({ url: 'https://api.notion.com/v1/users/me' })
  // A 401 means they revoked access at the provider: ask them to connect again.
} catch (err) {
  if (err.code === 'NOT_CONNECTED') showConnectButton()
}

How it is kept safe

  • Tokens are encrypted, and kept per person and per app.
  • A token about to expire is refreshed. If that is refused, the person connects again.
  • The browser must be signed in to Design Studio as the person who asked, so a link sent to someone else can’t attach their account to the sender’s login.
  • Design Studio shows a Disconnect button under your app, so people can always start again, even if your app shows an error.
  • Disconnecting or removing your app deletes the token from the server. Revoking it at the provider is up to the person, so handle a 401 by asking them to connect again.

The sandbox

Your app is someone else’s code to the people using it, so the browser treats it as a stranger. Three separate checks mean one mistake doesn’t open everything.

Not availableDo this instead
Inline scriptsPut scripts in their own files.
Scripts or styles from other sitesInclude them in your zip.
fetchstudio.network.fetch
Pictures from other sitesFetch them and use the dataUrl as the image’s src.
Cookies and localStoragestudio.storage
Form submissionHandle Enter or a button click yourself.
Popups and new tabsstudio.links.open
Nested framesBuild the feature into your page.

The three checks

  1. The browser. Your files are served with a sandbox policy, so the app can’t read the session or the editor’s page, and has no network of its own.
  2. The editor. It only accepts messages from your frame, and checks each one against what the person approved. View-only designs refuse changes.
  3. The server. It checks again that the person installed and approved this version. It reaches only listed hosts, over https on the default port, and refuses private addresses.

Limits

WhatLimit
Requests600 per person, per app, per hour. Pictures count.
Request body64 KB, and 2,048 characters for the address
Response12 MB. JSON, text and pictures only.
Pictures added20 MB each, and 10 changes every 10 seconds
Saved data64 KB per person
Opening linksOne every 2 seconds
Connecting an accountTwice a minute

Reviewing and publishing

Every version is reviewed by an admin on your server before it reaches anyone. An approved version never changes.

  1. Draft. Upload it, as often as you need. Your zip is checked at once.
  2. Try. A test version runs in the same sandbox, visible only to you, and to reviewers once submitted.
  3. Waiting for review. A submitted version is locked.
  4. Approved or changes requested. A reviewer’s note says what to change. Upload it again and resubmit.

What to check when you review

  • It does what its description says, and it works.
  • It asks only for what it uses. On an update, you see what it newly asks for.
  • A shared key doesn’t expose private data.

Updates

An update needs a higher version number and another review. Once approved, it is offered as Update app, with your whatsNew text. Each person stays on their version until they choose to update, and sees what the update newly asks for. New installs get the latest version.

Reports and removal

Anyone can report an app from its marketplace page. An admin can remove an app for everyone at once, including people who installed it, and the developer sees the reason in the portal.