Awesomate docs v0.27.0

Tutorial

Build a customer portal

A portal for a plumbing business, step by step. Customers sign in, see their own jobs, message the team and accept a quote; staff see every job; both see each other typing and can talk to the business's agent.

You'll build the portal for Brightwater Plumbing, a made-up business. When you're done:

  • customers sign in with an emailed link, see only their own jobs, message the team about a job, and accept a quote in one tap;
  • staff see every job and reply to any customer;
  • everyone sees new messages and changes as they happen, and who else is looking at a job or typing;
  • customers can talk by voice to the business's agent, which knows their jobs.

The page is one HTML file and one JavaScript file, with no build step. The rules that keep customers apart live in the account's database, not in this code.

You need: a Pro or Embedded account, Claude Code with the Awesomate MCP connected, and a way to serve a folder locally (we use npx serve).

1. The data

The portal needs two kinds: jobs, each for one customer, and messages, each on one job. Ask Claude Code:

Define two kinds. A job: title (text, required), status (a choice of open, quoted, booked or done) and quote (money), linked to a customer contact. A message: body (long text, required), linked to a job (required). Make all of those readable by AI.

Or define them from a script with the server client and the account's token:

import { createClient } from '@awesomate/sdk';

const db = createClient({ token: process.env.AWESOMATE_TOKEN! });

await db.defineKind({
  key: 'job', label: 'Job', label_plural: 'Jobs',
  attributes: [
    { key: 'title', type: 'text', required: true, readable_by_ai: true },
    { key: 'status', type: 'choice', choices: ['open', 'quoted', 'booked', 'done'], readable_by_ai: true },
    { key: 'quote', type: 'money', readable_by_ai: true },
  ],
  links: [{ key: 'customer', to: 'contact', label: 'is for' }],
});

await db.defineKind({
  key: 'message', label: 'Message', label_plural: 'Messages',
  attributes: [{ key: 'body', type: 'long_text', required: true, readable_by_ai: true }],
  links: [{ key: 'job', to: 'job', required: true }],
});

2. Who sees what

Customers sign in with the role member, your team with staff. Tell Claude the rules:

Set access on job: members read jobs whose customer is them, staff read and write every job. On message: members read and write messages on their own jobs, staff read and write every message.

Claude sets these with awesomate_crm_kinds (set_access):

{
  "job": { "read": { "member": "linked:customer", "staff": "all" }, "write": { "staff": "all" } },
  "message": {
    "read": { "member": "linked:job.customer", "staff": "all" },
    "write": { "member": "linked:job.customer", "staff": "all" }
  }
}

Customers can't write jobs at all, so they can't edit a price. They can still accept a quote, through a recipe, in step 7. How access works explains the rules.

3. The app and its people

Make an app called "Brightwater portal" that runs on http://localhost:5173, invite only. Add me as staff.

Claude uses awesomate_crm_apps and gives you a publishable key (pk_...). Now a customer to test with. Use an email address you can read: many providers deliver you+sam@yourdomain to your own inbox.

Add a contact Sam Possum with the email you+sam@yourdomain, add them to the Brightwater portal as a member, and add two jobs for Sam: "Leaking tap", status quoted, quote $380; and "Hot water service", status open.

Sam is linked to the Sam Possum contact because the email addresses match, so linked:customer finds Sam's jobs.

4. The page

Save this as index.html:

<!doctype html>
<html lang="en-AU">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Brightwater Plumbing</title>
<script src="https://hub.awesomate.ai/widget/v1/agent-widget.js" defer></script>

<section id="signedOut">
  <h1>Sign in to see your jobs</h1>
  <form id="signInForm">
    <input id="email" type="email" placeholder="you@example.com" required>
    <button>Email me a link</button>
  </form>
  <p id="note"></p>
</section>

<section id="signedIn" hidden>
  <p><span id="who"></span> <button id="signOut">Sign out</button></p>
  <div id="talk"></div>
  <h2>Jobs</h2>
  <ul id="jobs"></ul>

  <div id="job" hidden>
    <h2 id="jobTitle"></h2>
    <p><span id="jobStatus"></span> · <span id="jobQuote"></span></p>
    <button id="accept" hidden>Accept the quote</button>
    <p id="jobNote"></p>
    <div id="messages"></div>
    <p id="here"></p>
    <form id="messageForm">
      <textarea id="messageBody" placeholder="Write a message"></textarea>
      <button>Send</button>
    </form>
  </div>
</section>

<script type="module" src="./app.js"></script>
</html>

The rest of this tutorial builds app.js, one piece at a time. Each piece goes after the last. The whole file is also here to download.

5. Sign in

Create the client with your key, and keep a little state: who is signed in, the jobs list, and the job that's open.

import { createAppClient, AwesomateError } from 'https://cdn.jsdelivr.net/npm/@awesomate/sdk@0.27.0/+esm';

const app = createAppClient({ publishableKey: 'pk_your_key_here' });
const $ = (id) => document.getElementById(id);
const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD', maximumFractionDigits: 0 });

let me = null;       // the signed-in person
let stopJobs = null; // stops the jobs list
let open = null;     // the open job: { job, stopMessages, room }

signedInAs shows the page for whoever is signed in. onChange calls it whenever someone signs in or out, including when they come back from the emailed link (the client picks the link up by itself). Someone already signed in on this device is shown at the very end of the file, in step 11.

function signedInAs(user) {
  me = user;
  $('signedOut').hidden = !!user;
  $('signedIn').hidden = !user;
  closeJob();
  stopJobs?.();
  stopJobs = null;
  if (!user) return;
  $('who').textContent = `${user.email} (${user.role === 'member' ? 'customer' : 'staff'})`;
  showJobs();
}
app.auth.onChange(signedInAs);
app.auth.onSignInError((err) => { $('note').textContent = err.message; });

$('signInForm').addEventListener('submit', async (e) => {
  e.preventDefault();
  const r = await app.auth.signInWithLink($('email').value, { redirectTo: location.origin + location.pathname });
  $('note').textContent = r.message;
});
$('signOut').addEventListener('click', () => app.auth.signOut());

6. Jobs, as they change

live() gives you the whole list at first, then again whenever a job in it changes. The same line serves both roles: the database returns Sam's jobs to Sam and every job to staff.

function showJobs() {
  stopJobs = app.live('job', { orderBy: ['created_at', 'desc'] }, (jobs) => {
    $('jobs').replaceChildren(...jobs.map((job) => {
      const li = document.createElement('li');
      li.textContent = `${job.title} · ${job.status}${job.quote ? ` · ${money.format(job.quote)}` : ''}`;
      li.addEventListener('click', () => showJob(job));
      return li;
    }));
    // Keep the open job's details current too.
    const fresh = open && jobs.find((j) => j.id === open.job.id);
    if (fresh) renderJob(fresh);
  });
}

7. A job's conversation

Opening a job starts a second live list, its messages, and joins the job's room so others can see you there (step 9). Sending a message is a write(); it appears in everyone's list by itself.

function showJob(job) {
  closeJob();
  open = { job, stopMessages: null, room: null };
  $('job').hidden = false;
  renderJob(job);
  open.stopMessages = app.live('message', { where: { job_id: job.id }, orderBy: ['created_at', 'asc'] }, (messages) => {
    $('messages').replaceChildren(...messages.map((m) => {
      const p = document.createElement('p');
      p.className = m.created_by === me.id ? 'mine' : 'theirs';
      p.textContent = m.body;
      return p;
    }));
  });
  open.room = app.here(job.id, renderHere, () => renderHere([]));
}

function renderJob(job) {
  open.job = job;
  $('jobTitle').textContent = job.title;
  $('jobStatus').textContent = job.status;
  $('jobQuote').textContent = job.quote ? money.format(job.quote) : 'No quote yet';
  $('accept').hidden = !(me.role === 'member' && job.status === 'quoted');
}

function closeJob() {
  if (!open) return;
  open.stopMessages?.();
  open.room?.leave();
  open = null;
  $('job').hidden = true;
  $('jobNote').textContent = '';
}

$('messageForm').addEventListener('submit', async (e) => {
  e.preventDefault();
  const body = $('messageBody').value.trim();
  if (!body || !open) return;
  await app.write('message', { body }, { links: { job: open.job.id } });
  open.room?.typing(false);
  $('messageBody').value = '';
});

A customer's message must be on one of their own jobs: the database checks the write against their rule, so the job link can't point anywhere else.

8. Accept the quote

Customers can't write jobs, but they should be able to accept a quote. A write recipe opened to their role lets them do exactly that. Ask Claude:

Save a recipe called accept_quote, with one parameter, job. It sets that job's status to booked and adds a message on it saying "I accept the quote. Please go ahead and book it in." Then let members of the Brightwater portal run it.

Or save it yourself, then ask Claude to open it to member (details):

await db.saveRecipe({
  key: 'accept_quote',
  label: 'Accept the quote',
  params: [{ name: 'job', type: 'uuid', required: true }],
  steps: [
    { op: 'write_record', kind: 'job', id: { $param: 'job' }, data: { status: 'booked' } },
    { op: 'write_record', kind: 'message', data: { body: 'I accept the quote. Please go ahead and book it in.' }, links: { job: { $param: 'job' } } },
  ],
});

The button calls it. Both live lists update by themselves: the job reads booked, the button hides, and the message appears for Sam and for staff.

$('accept').addEventListener('click', async () => {
  try {
    await app.call('accept_quote', { job: open.job.id });
  } catch (err) {
    $('jobNote').textContent = err instanceof AwesomateError && err.code === 'not_found'
      ? 'That quote is no longer open to accept.'
      : 'That did not go through. Try again in a moment.';
  }
});

9. Who's here and typing

Step 7 joined each job's room. renderHere shows everyone else in it, and the message box says when you're typing.

const nameOf = (p) => p.name ?? (p.role === 'member' ? 'The customer' : 'The Brightwater team');

function renderHere(people) {
  const words = [];
  for (const p of people) {
    if (p.assistant) words.push(`${p.name} is writing…`);
    else if (p.typing) words.push(`${nameOf(p)} is typing…`);
  }
  const looking = people.filter((p) => !p.assistant && !p.typing);
  if (looking.length) words.push(`${looking.map(nameOf).join(', ')} ${looking.length === 1 ? 'is' : 'are'} looking at this job`);
  $('here').textContent = words.join(' · ');
}

$('messageBody').addEventListener('input', () => open?.room?.typing(true));
$('messageBody').addEventListener('blur', () => open?.room?.typing(false));

10. Talk by voice (optional)

If the account has an agent with In your app switched on (Support Plus and above), customers can talk to it. On the agent's page, add http://localhost:5173 under Where your app runs; on the app's page, pick the agent under Talk by voice. Then:

let stopTalk = null;
async function talkFor(user) {
  stopTalk?.();
  stopTalk = null;
  if (!user || user.role !== 'member' || !window.AwesomateAgent) return;
  const agentId = await app.voiceAgent();
  if (agentId) {
    stopTalk = await window.AwesomateAgent.mount({ agentId, session: () => app.voiceSession(), target: '#talk', label: 'Talk to us' });
  }
}
app.auth.onChange(talkFor);

The agent greets Sam by name and is told about the jobs Sam can see, nothing else. See Talk by voice.

11. Start

onChange hears about changes. Last of all, show whoever is already signed in on this device, so a returning customer goes straight to their jobs:

const already = await app.auth.user();
signedInAs(already);
talkFor(already);

If you skipped voice, leave out the talkFor line.

12. Try it

npx serve -l 5173 .

Open http://localhost:5173 in two browsers (or a normal and a private window). Sign in as yourself in one and as Sam in the other.

  • Sam sees two jobs; you see every job.
  • Open "Leaking tap" in both. Each window shows the other person looking, and typing as they type.
  • Send messages both ways: they appear at once on both sides.
  • As Sam, tap Accept the quote. The job turns booked in both windows, and the message lands in the conversation.

13. Put it online

Copy the two files to any web host, then add the address to the app (and to the agent, for voice):

Add https://portal.brightwater.example to the Brightwater portal's addresses.

The publishable key in app.js is safe to publish. Nothing in the page can see past the database's rules.

Where next