Skip to content

Custom apps ​

A custom app is a screen you build yourself and run inside the CoCoCo console. It reads and writes platform data through the same GraphQL API the console uses, and it can call server-side Lua functions for logic that should not run in the browser.

An app is made of three parts:

PartLanguageRunsRequired
TemplateVue 3 template (HTML)in the browseryes
ScriptJavaScriptin the browseryes
Server APILuaon the CoCoCo serverno

Each app also has a name, a handle (a URL-safe slug that is unique in your tenant: it starts with a letter and contains only letters, digits and hyphens), an optional icon and a kind.

App kinds ​

The kind decides where the app appears (CustomAppKind).

KindWhere it appears
PAGEA link in the Apps section of the console sidebar, opening at /#/apps/<handle>
DASHBOARDA tab on the console home page
KIOSKFull-screen, for kiosk user accounts only. See Kiosk mode
JOB_VIEWReserved for a future embedded job view. It is not displayed anywhere yet.

How an app runs ​

The console renders your app in an <iframe> with sandbox="allow-scripts" and nothing else. The frame has no access to the console's origin, cookies or storage, and it holds no API token. Every call to the platform goes through the host API described below, which relays it to the console.

Because the sandbox grants only allow-scripts, the browser also blocks form submission, pop-ups and new windows, and alert() / confirm() / prompt() dialogs. Handle user input with click and key handlers instead of <form> submission, and build confirmations into the page.

The platform loads these into every app, so you do not import anything:

  • Vue 3 (global build), available as the global Vue
  • Tailwind CSS and DaisyUI for styling

These are served by your CoCoCo tenant itself, so apps work without internet access from the browser. The DaisyUI colour variables (base-100, primary, error and so on) are mapped to the console theme and follow it when the user switches between light and dark mode. The raw console theme colours are also available as CSS variables named --host-<name>, for example --host-primary or --host-border.

The frame resizes itself to the height of your content.

Your first app ​

  1. In the console, open Custom Apps (/#/custom-apps) and create a new app.

  2. Enter a name and a handle, and choose the kind PAGE.

  3. Replace the template with:

    html
    <div class="p-6 space-y-4">
      <h1 class="text-2xl font-bold">{{ title }}</h1>
      <p class="text-base-content/70">Clicked {{ count }} times</p>
      <button class="btn btn-primary" @click="increment">Click me</button>
    </div>
  4. Replace the script with:

    js
    const title = ref('Hello from a custom app')
    const count = ref(0)
    
    function increment() {
      count.value++
    }
    
    const setupReturn = { title, count, increment }
  5. Save. The app appears in the Apps section of the sidebar for every user who can see it (see Who sees an app).

The script contract ​

The platform creates and mounts the Vue app for you. Your script is inserted into the body of that app's setup() function, so:

  • Do not call createApp() or mount() yourself.
  • Assign everything the template needs to a variable named setupReturn. Only what is in setupReturn is visible to the template. If you do not define it, the template gets an empty object.
  • ref, reactive, computed, watch, onMounted and onUnmounted are already in scope. Anything else from Vue is reached through the global Vue, for example Vue.nextTick.
  • setup() is not async, so you cannot use top-level await or import statements. Start asynchronous work from onMounted or from event handlers.

Calling the platform ​

Three functions are available as globals in the script:

FunctionReturns
graphql(query, variables?)A promise for the data of a GraphQL query
graphqlMutation(mutation, variables?)A promise for the data of a GraphQL mutation
rpc(method, ...args)A promise for the return value of a server API function

All three reject with an Error when the call fails, and each call times out after 30 seconds. rpc() rejects straight away if the app has no server API.

GraphQL calls run as the signed-in user. The app can read and change exactly what that user's IAM permissions allow, and nothing more. For the queries and mutations you can use, see the API overview and the schema reference.

This example loads the ten most recent jobs:

graphql
query RecentJobs($first: Int, $sort: [JobSortInput!]) {
  listJobs(first: $first, sort: $sort) {
    edges {
      node {
        id
        name
        status
        dueAt
      }
    }
  }
}
js
const jobs = ref([])
const loading = ref(false)
const errorMessage = ref('')

const RECENT_JOBS = `
  query RecentJobs($first: Int, $sort: [JobSortInput!]) {
    listJobs(first: $first, sort: $sort) {
      edges { node { id name status dueAt } }
    }
  }`

async function loadJobs() {
  loading.value = true
  errorMessage.value = ''
  try {
    const data = await graphql(RECENT_JOBS, {
      first: 10,
      sort: [{ field: 'CREATED_AT', direction: 'DESC' }],
    })
    jobs.value = data.listJobs.edges.map((edge) => edge.node)
  } catch (error) {
    errorMessage.value = error.message
  } finally {
    loading.value = false
  }
}

onMounted(loadJobs)

const setupReturn = { jobs, loading, errorMessage, loadJobs }
html
<div class="p-6">
  <div class="flex items-center justify-between mb-4">
    <h2 class="text-xl font-bold">Recent jobs</h2>
    <button class="btn btn-sm" :disabled="loading" @click="loadJobs">Refresh</button>
  </div>
  <div v-if="errorMessage" class="alert alert-error">{{ errorMessage }}</div>
  <table v-else class="table">
    <thead><tr><th>Job</th><th>Status</th><th>Due</th></tr></thead>
    <tbody>
      <tr v-for="job in jobs" :key="job.id">
        <td>{{ job.name }}</td>
        <td><span class="badge">{{ job.status }}</span></td>
        <td>{{ job.dueAt || '-' }}</td>
      </tr>
    </tbody>
  </table>
</div>

The host API has no subscription channel. To show changing data, reload it on a timer or after user actions.

App context ​

The platform sets window.APP_CONTEXT before your script runs:

js
window.APP_CONTEXT = {
  mode: 'customApp',
  appInfo: {
    id: 'cap_...',       // the app's ID
    name: 'Recent jobs',
    handle: 'recent-jobs',
    kind: 'PAGE',        // PAGE | DASHBOARD | KIOSK | JOB_VIEW
  },
  hasServerApi: true,    // whether the app has a server API
}

That is the whole context. It does not include the signed-in user, a record the app was opened from, or an API token.

Server-side logic ​

Put code that must not run in the browser, or that the browser cannot do, in the app's server API: Lua functions that you export and call with rpc(). See Server API.

Versions and publishing ​

An app's code lives in versions: immutable, numbered snapshots of the template, script and server API (CustomAppVersionState). An app serves exactly one version at a time, its published version. A new app has no version, and so nothing to show, until one is published.

In the console, saving the app's code creates a new version and publishes it at once, so the change is live for users straight away. The editor's version history lists earlier versions. From there you can preview one, or restore it, which publishes that version again.

To manage apps from your own tooling, use the GraphQL API in three steps:

  1. createCustomApp creates the app's settings (name, handle, kind, icon).
  2. createCustomAppVersion stores a snapshot of the code.
  3. publishCustomApp makes that version the one the app serves.
graphql
mutation CreateApp($input: CreateCustomAppInput!) {
  createCustomApp(input: $input) {
    customApp { id handle }
    errors { path message }
  }
}

mutation CreateVersion($input: CreateCustomAppVersionInput!) {
  createCustomAppVersion(input: $input) {
    version { id version }
    errors { path message }
  }
}

mutation Publish($id: CustomAppID!, $versionId: CustomAppVersionID!) {
  publishCustomApp(id: $id, versionId: $versionId) {
    customApp { id publishedVersion publishedVersionId }
    errors { path message }
  }
}

The version input takes the code as strings:

json
{
  "input": {
    "customAppId": "cap_...",
    "description": "Add refresh button",
    "config": {
      "template": "<div class=\"p-6\">...</div>",
      "script": "const setupReturn = {}",
      "serverApi": "function exports.ping() return 'pong' end"
    }
  }
}

These mutations need the customapp:write permission.

Who sees an app ​

Which users see which apps depends on bindings:

  • User bindings attach an app to one user (attachCustomAppUser). In the console, use the Custom App Access card on a user's page.
  • Team bindings attach an app to a team, so every member sees it (attachCustomAppTeam). In the console, use the Custom App Access card on a team's page.

Visibility is worked out like this:

  • Users with the customapp:write permission see every app.
  • Other users see the apps bound to them directly or through one of their teams.
  • A user with no bindings at all sees every app or none, depending on a tenant-wide setting (permissive or restrictive).
  • Kiosk accounts see only the apps bound to them directly; team bindings are ignored for them. See Kiosk mode.

Bindings control which apps a user sees. They do not grant data access: what an app can read or change is always decided by the user's IAM permissions.

Connect machines, automate workflows, and build custom apps for print production.