# Appmint documentation > One backend for commerce, CRM, operations and payments — and the apps that run on it. --- # Create your Appmint organisation and make Studio Manager feel familiar > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-welcome.html # Create your Appmint organisation and make Studio Manager feel familiar ![The successful Appmint signup welcome](../learner-review-records/assets/welcome-local-20260919/03-welcome-created.png) *Your organisation has been created. The next job is to understand its tools, save your business identity and know where to return.* **Who:** business owners and developers new to Appmint · **Time:** 30–40 minutes · **Level:** beginner, with a developer orientation at the end · **Products:** Appmint signup, Studio Manager and Vibe Studio · **Checked:** fresh local signup and Studio Manager 0.6.2, 19 September 2026; Vibe public sign-in inspected read-only. Earlier production Vibe captures are labelled below. ## What you will have at the end You will have your own organisation and sign-in details, a business name saved on its profile, the address of its first site, and a practical map of the tools you will use next. You will distinguish Studio Manager, Build Studio and Vibe Studio; know how Appmint Mobile and AppEngine fit; and have used the tour and task guides without turning on services you do not yet need. ## What you need Choose an email you control, a unique password and an available name for your organisation. Appmint apps and this course can be used without buying a plan. The public site also advertises optional paid platform tiers for additional capacity and services; those labels do not make purchasing a plan a step in this course. The fresh walkthrough completed signup, profile, site selection and return sign-in on the free plan without entering payment details. The fictional business is **Cedar & Form**, an interior-design studio. Our fresh local training account belongs to **Jordan Morgan**, with organisation ID `cedar-local-mu8892bk` and initial site `cedsite`. Those deliberately distinct names make an important point: your organisation ID, your business display name and a website’s name are different values. Choose your own available name. You do not have to copy our ID, add “workshop”, or use your exact legal business name in the address field. Never use the example login email as your own. **Local rehearsal:** the fresh captures use the marketing app at `localhost:4050`, Studio at `localhost:3100` and the local API. The repaired welcome page uses its configured Studio address: **Open Studio Manager** and **Sign in at** both target `localhost:3100` here. Vibe remains a separate production link. A local account is not a production Vibe account. The public instructions below retain the normal public addresses. ## The story Jordan wants Cedar & Form’s enquiries, consultations and projects to live together. The first useful result is not a dashboard full of invented activity. It is a business identity Jordan can return to, a site to build on, and a clear route from “I need a website” to the tool that makes it. ## The route ```mermaid flowchart LR A[appmint.io → Start free] --> B[Email → account details] B --> C[Welcome: tools and sign-in details] C --> D[Studio Manager → tour and setup] D --> E[Business profile → save and reload] E --> F[Site address → help → return sign-in] ``` ## Part 1 — Create the company account ### 1. Start from the public website Open [appmint.io](https://appmint.io/) and select **Start free** in the upper-right navigation. The screen is headed **Create your account** and begins with **Email**. ![Earlier production email signup entry](assets/appmint-welcome-verified/04-signup-email.png) Enter the email you intend to use for Appmint and select **Continue with Email**. This walkthrough follows email signup. **Continue with Google** and **Continue with GitHub** are separate choices; their complete authentication flows are not demonstrated here. You should reach **Finish your account**, with the address, name and password fields together. ![The second signup screen with its fields empty](assets/onboarding/02-account-fields-empty.png) *Earlier signup close-up of the same form. The information belongs in these fields, before account creation.* > **Existing email behaviour.** The 17 September production recheck with an existing email still opened **Finish your account**. Do not expect signup to redirect you automatically to sign-in. If your purpose is to return to a company you already created, deliberately choose **Sign In** instead of creating it again. ### 2. Choose the organisation’s address name In **site-name**, enter only your chosen name, such as `cedar-and-form`, without the `.appmint.app` suffix. The interface already supplies the suffix. Use at least three characters, beginning with a letter, and use lowercase letters, numbers and hyphens. Move to the next field and wait for the availability result. Continue when the green line says your chosen address **is available**. ![Completed local account fields with the available address](../learner-review-records/assets/welcome-local-20260919/02-signup-details.png) If the name is taken or reserved, choose another available name. `Cedar & Form` is suitable as the business display name later, but spaces and `&` do not belong in this address field. A company can keep its familiar public name while using a different available organisation ID. ### 3. Enter your personal name and password Enter your first name in **First name** and surname in **Last name**. These identify you, the account owner. Do not put the company name in the surname field. Enter a unique password of at least eight characters in **Password**, then the same value in **Confirm password**. Save it in your password manager. If the form reports **Passwords do not match**, correct the confirmation; if the password is too short, replace it in both fields. Review the available address and your personal name, then select **Create account** once. Allow the request to finish. Our completed signup went directly to the welcome page; it did not present an email-confirmation step. You have entered the email, organisation address name, owner’s name and password. You have not yet entered the business display name, logo, location or team permissions. ### 4. Read and retain the sign-in details On the welcome page, find **YOUR SIGN-IN DETAILS**. Keep these four items together: | Row | What to retain | Why it matters | | --- | --- | --- | | Sign in at | The displayed Studio Manager address | Your return entry point | | Organization ID | The exact ID you chose | Identifies the company at sign-in | | Email | The account email | Identifies your user | | Password | The password in your password manager | Completes password sign-in | Use **Copy** beside the address and ID. The password row offers **Show** and **Copy**, and says **Save it now — not shown again**. Keep it concealed while screen sharing or recording. ![Welcome page with the configured local Studio address](../application-fixes/assets/welcome-support/03-welcome-local-target.png) *After the browser recovery, this link check restored the existing learner’s non-secret welcome session data; it did not create another account. The original signup capture remains in the execution record.* The welcome page uses the phrase **Workspace ready**. In this course, your **organisation** is the company account. **Workspace** also names a specific collaboration feature in Studio Manager; it is not a different company account you need to create. **Try it.** Write down your organisation ID and business display name in separate fields in your password manager. Leave the business display name as the name you want customers to recognise. **Check yourself.** Can you sign in using `Cedar & Form` wherever Appmint asks for Site Name? **Only if that were the actual identifier—which it is not here.** Use the organisation ID from the welcome page, without the suffix. ## Part 2 — Understand what you have before choosing a tool ### 5. Start with the welcome page’s two main entries **Run your business → Open Studio Manager** opens the management console. Use it for customer records, sales, content, operations, organisation settings and team access. This is where the course continues. **Building pages or apps? Open Vibe Studio** opens the AI-assisted building environment. It is useful when you want to describe a page or application and work with its generated project. Opening Vibe is not required before using Studio Manager. The third name, **Build Studio**, appears inside Studio Manager. It is the visual page builder. A lesson using Build Studio will tell you to open its sidebar menu; a Vibe lesson will take you to the separate Vibe application. ### 6. Read the six capability cards as a business map Scroll below the sign-in details to **What your workspace already does**. ![The six capability cards on the post-signup welcome page](../learner-review-records/assets/welcome-local-20260919/03-welcome-created.png) *Fresh welcome-page capture. The cards explain capabilities; they do not confirm that every service has been configured.* | Welcome card | A concrete Cedar & Form use | Where that work continues | | --- | --- | --- | | Customers and CRM | Keep a homeowner’s enquiry and project history | Studio Manager’s CRM area | | Selling and payments | Invoice a consultation or track an order | Storefront and payment setup | | Talking to customers | Respond to an enquiry and follow up | Customer inbox, chat and communications | | Your counter and floor | Connect venue hardware for in-person service | BusinessMade and Device Hub setup | | Finding new business | Gather prospects for a sales follow-up | LeadSearch and CRM | | Pages and apps | Build the studio’s website or a client tool | Build Studio or Vibe Studio | For example, the website is where an enquiry starts, CRM is where the team follows it up, and an invoice records the requested payment. They connect around a business workflow, but each still needs its own configuration and records. ### 7. Place the other Appmint components | Component | What you use it for | First setup to expect | | --- | --- | --- | | Studio Manager | Administer the business and operate its modules | Organisation identity and access | | Build Studio | Visually create and edit pages inside Studio Manager | Select the site, create a page, save it | | Vibe Studio | Build a page or application with an AI assistant | Same production login, then a project | | Appmint Mobile | Work with CRM, chat, softphone, POS and event tools on a phone or tablet | Install, sign in to your company, receive the relevant access | | Appmint Chat | Let customers converse with your team or assistant | Configure the site’s chat and agent access | | BusinessMade | Run venue and business operations such as POS, staff, time, payroll and books | Its own BusinessMade onboarding course | | Device Hub | Connect a business location to printers and other supported devices | Download/install the agent, connect the hub, configure devices | | EventOxygen | Event attendance, tickets and event interactions | Install the appropriate event app and connect to its event context | | LeadSearch | Capture prospects for CRM follow-up | Its browser extension and CRM connection | | AppEngine | Provide the backend APIs and data for the platform and custom clients | Authentication, organisation context and the required API access | Installing a mobile app does not assign an employee a business phone number. A Device Hub card does not install the agent. A chat feature label does not place a working chat widget on your website. The later courses take those workflows from setup to their actual checks. ### 8. Locate the learning material before leaving the welcome page Continue to **Learn**. **Documentation** opens the written guides and **Support** opens the support entry. The embedded walkthrough introduces Vibe. Its video did not load in the fresh local capture, but the documentation and support links were visible. Watching it is not required for the Studio steps below. ![Welcome page’s Learn section](../learner-review-records/assets/welcome-local-20260919/03-welcome-created.png) Now return to **Run your business** and select **Open Studio Manager**. You do not need to create another account for it. ## Part 3 — Learn the interface and save the business identity ### 9. Acknowledge the interface tour On the fresh local first visit, Studio Manager opened the **Getting around** tour. The first card is **Welcome — let me show you the whole thing**, **1 of 44**. ![The 44-stop interface tour on the fresh local first visit](../learner-review-records/assets/welcome-local-20260919/05-tour-first-visit.png) Use **Next** to advance, **Back** to revisit a card, **Skip section** to move beyond a chapter, or **End** to return to your task. Take the tour now if useful; the [illustrated tour reference](appmint-studio-tour.md) covers its chapters. You can reopen it through **Support → Interface tour** or **Show me around**. For this setup exercise, select **End** after inspecting the controls. Ending the tour does not delete your company or undo configuration. ### 10. Read the Setup Wizard without treating every row as mandatory The fresh local sign-in opened Home’s **Welcome back** page. Open **App Root → Setup Wizard** for **New here? Start with your website** and **Let’s get you set up**. Some builds also offer **Finish setting up** on Home. ![Local Setup Wizard](../learner-review-records/assets/welcome-local-20260919/06-setup-wizard.png) **Your website** leads to building pages and using your domain. **Business basics** contains profile, locations and team access. Later sections cover payments, phone, email, social, shipping and AI. Choose the setup related to your first workflow. For Cedar & Form that is the business profile, followed by a website. You do not have to connect every listed social network or service to begin. > **Replay is different from reset.** **Support → How do I…? → Replay the welcome** describes a replay affecting you. The Setup Wizard’s **Reset onboarding** is a broader reset with a confirmation for the organisation. Leave that reset alone when all you want is to reread guidance. ### 11. Learn the sidebar and toolbar If the sidebar shows icons only, select **Expand sidebar** at its top. In the captured **Tree menu** layout, the chevron beside a group expands its children; the group’s name opens the group overview. In **Flyout menu** layout, the chevron opens a floating submenu. Both expose the module labels used in these courses. The main groups on this account include **App Root**, **Build Studio**, **DAM**, **Storefront**, **CRM**, **AI, IVR, Automation**, **Events**, **Logistics**, **Finance**, **Community**, **Database**, **Configuration** and **Account**. The toolbar is available across modules: **Phone**, **Chat**, **AI Agent**, **What’s this?**, **Support** and **Report a Bug**. The sidebar footer has **Videos**, **Docs**, **Discord** and your email. Your company may show additional menus as its configuration and access change. **App Root** is the home and organisation-level starting area. **Account → Profile** is the company profile. The email entry at the bottom opens your user profile; it is not the company profile or the sign-out menu assumed by the old topic. ### 12. Open the organisation profile through Account Expand **Account** using its chevron, then select **Profile**. ![Account expanded, with its Profile entry](../learner-review-records/assets/welcome-local-20260919/19-private-profile-readback.png) The heading is **Account Profile**. Stay on the **Profile** tab. In **Organization Name**, enter `Cedar & Form`, or your own business display name. ![Organisation profile with the saved business identity](../learner-review-records/assets/welcome-local-20260919/08-profile-after-reload.png) The hint below the field says this name appears on public forms, emails and documents. Under **Organization Identity**, read the organisation ID and account email. The ID remains the one from signup; saving the display name does not rename it. The logo area offers **Select** for an existing file and **Upload** for a new one. You may leave it empty for this first exercise. Address, phone and location details are not fields in this organisation profile. **Configuration → Business Locations** is the separate operating-location area. > **Use the submenu route.** The **Edit Profile** shortcut on Account’s overview has previously opened a different user-profile page with **User ID is required**. **Account → Profile** reaches the company form shown here. ### 13. Select interests, then save Scroll to **Organization Details → Interests**. For the Cedar & Form story, select: - **Website, blog, etc** - **CRM, Sales, Leads & Ticket Support** - **Booking, Event, Reservation** - **Live Chat & AI Chatbot** Selected pills are filled blue. These describe your intended use; choosing Live Chat here does not provision a website chat widget. ![The four selected interests and Save Changes control](../learner-review-records/assets/welcome-local-20260919/07-profile-ready.png) Select **Save Changes**. Wait until the button reads **No Changes**. Reload the page and check both the name and the selected interests. ![Cedar and Form retained after reload](../learner-review-records/assets/welcome-local-20260919/08-profile-after-reload.png) The fresh profile saved and reloaded without the older capture’s toolbar-presence error. The name and four blue interest pills also survived sign-in in a new private browser context. ### 14. Find the site’s actual address Select the **App Root** group name. Scroll to **Active Site**. If **Nothing is loaded** appears, press **Choose a site**, then select the site created at signup. Our only site was **cedsite**. This selects a website within the company. Find **View public site** beneath its name. ![Active Site with its website link and configuration controls](../learner-review-records/assets/welcome-local-20260919/10-active-site.png) Use that supplied link. In this training organisation it points to `https://cedsite-cedar-local-mu8892bk.site.appmint.app`. Your own link will use your own site and organisation; do not construct it by copying our names. **Site Config** edits the website’s settings. **My Account** chooses the customer-account layout and sections. **Site Features** lists capabilities such as Blog, Storefront, Live Chat, Support Tickets and Reservations. The feature list is configuration, not a to-do list you must turn on in full. The card’s **Published** badge is not a check that your new page is visible. The website course saves a real page and opens it as a signed-out visitor. At this point you have identified the site and link; you have not built the Cedar & Form homepage yet. **Try it.** Return to **Account → Profile** without using the browser Back button. Confirm the name, then use **App Root** to find the site again. **Check yourself.** Did saving the display name change `cedar-local-mu8892bk` to `cedar-and-form`? **No.** The organisation ID stays the same. The display name tells people which business they are dealing with. ## Part 4 — Get help and return with confidence ### 15. Use a short guide for a specific task Open **Support → How do I…?**. The drawer groups tasks under Website, Selling, Customers, Messaging, Money & Plan and Setup. Its search field accepts the task you are trying to accomplish. ![Task guides in How do I](../learner-review-records/assets/welcome-local-20260919/12-task-guides.png) Select **Invite my team** under Setup. The short guide highlights the relevant interface controls. Select **End** after reading it; opening this guide does not send an invitation. ![The short team-invitation guide](../learner-review-records/assets/welcome-local-20260919/13-invite-guide.png) These short guides are separate from the 44-stop interface tour. Use one to reach the task at hand without replaying the entire product introduction. For an unfamiliar control, the toolbar’s **What’s this?** is the contextual-help entry. ### 16. Find the written documentation Select **Support → Documentation**. In this rehearsal it opened [Appmint documentation](https://docs.appmint.io/) in a new tab. ![Documentation opened from Studio Manager](../learner-review-records/assets/welcome-local-20260919/14-documentation.png) Use **Getting started** for platform concepts, **Products** for the application you are working in, **Developers** for APIs and integration details, and **Tutorials** for complete tasks. If you are connecting a custom client, begin with Developers; if you are configuring the mobile app, begin with Products. ### 17. Ask the team for help with a support ticket Open **Support → Submit Ticket**. ![The actual support ticket form](../learner-review-records/assets/welcome-local-20260919/15-support-ticket-cancelled.png) In **Subject**, state the task and obstacle, such as `Help connecting Cedar & Form’s website domain`. Keep **Priority** at the appropriate level. In **Description**, include your organisation ID, the screen, what you want to achieve, the action you tried and the result. Use **Attachments** for a screenshot if it helps; exclude passwords, API keys and customer secrets. For this rehearsal, select **Cancel**. When you have a real request ready, **Submit Ticket** sends it to the team. Select **Support → Live Chat** to open its separate support panel. For this rehearsal, inspect the panel and close it with **×** without starting or sending a conversation. If it reports **Live chat is unavailable**, close and retry later, or use **Submit Ticket** for a real request. The toolbar’s separate **Chat** control belongs to your business’s customer conversations. ![Support panel opens in Studio](../application-fixes/assets/welcome-support/01-live-chat-open.png) *Local verification uses Cedar’s own chat configuration, clearly labelled as a fixture. This proves the repaired entry opens the widget; it is not an Appmint-team conversation or a delivery test.* ### 18. Report a defect with enough information to reproduce it Select **Report a Bug** in the top toolbar. This is separate from the Support menu. ![Bug report form](../learner-review-records/assets/welcome-local-20260919/16-bug-report-cancelled.png) Use **Bug Title** for the exact problem, for example `A button does not respond on the screen I am using`. In **Description**, give the screen, steps, expected result and actual result. Include the organisation ID and any exact error text. Attach a relevant screenshot when useful. Cancel the training report; submit only the report you intend the team to receive. If you need a capability Appmint does not have, tell the team through **Support → Submit Ticket**, or **Support → Live Chat**. **We’re ready to build it for you at no extra cost.** Describe who needs it, the work they need to finish and the desired result. An example of your current process helps the team discuss requirements and delivery. ### 19. Practise returning without the welcome page Open a fresh private browser window and visit [Studio Manager](https://studio.appmint.io/login). Enter the email from signup and select **Continue with Password**. ![Earlier production email step on return sign-in](assets/appmint-welcome-verified/06-login-email.png) If **Site Name** is shown, enter your organisation ID without `.appmint.app`. On the fresh local run, the email identified one organisation and the password step displayed **Signing in to cedar-local-mu8892bk** automatically. Check that identity, enter your password and select **Sign In**. ![Company ID and masked password](../learner-review-records/assets/welcome-local-20260919/18-private-return-signin.png) You should return to Studio Manager. Our fresh local account opened Home. Navigate to **Account → Profile** and check the business name you saved. You now know how to return even after closing the welcome tab. A browser with remembered accounts can show an account chooser first. Select your company’s entry, or use the alternative sign-in route to enter its ID. If the company requires an additional verification challenge, complete the challenge displayed for your account; the password-only rehearsal shown here did not exercise that branch. ## Part 5 — PRO orientation: Vibe, team access and custom clients ### 20. Understand the separate Vibe entry [Vibe Studio](https://vibe-studio.appmint.io/login) is the separate production building environment. This section is an orientation, not a required application-building step. If you created your account in production, its sign-in uses that production identity. A local tutorial account belongs to the local backend; do not submit it to production Vibe. The fresh local walkthrough inspected only Vibe’s public login page. The following screenshots preserve the earlier production rehearsal: the same production email, organisation ID and password were used there. ![Vibe sign-in with the same organisation identity](assets/appmint-welcome-verified/23-vibe-signin.png) In that earlier production rehearsal, **Sign In** opened a Start screen headed **Build your ideas with Vibe**. ![Vibe Start after authentication](assets/appmint-welcome-verified/24-vibe-start.png) That captured Start screen contains **Describe your idea…** and **Build**. Below it are template starting points, including SaaS Landing Page, Creative Portfolio, Restaurant & Dining and E-Commerce Store. Its sidebar contains **Start**, **Gallery**, **Dev Environments**, **FAQ** and **Support**. That earlier account showed **No dev environments yet**. That means sign-in worked; it does not mean an application has been generated. Leave **Build** for the Vibe course, which creates and inspects a deliberate project. ### Choose the next setup by the job | Your next job | Continue with | | --- | --- | | Give the business a useful public website | [Build a business website](appmint-build-a-business-website.md) | | Follow an enquiry through sales and project work | [Turn an enquiry into a project](appmint-turn-an-enquiry-into-a-project.md) | | Invite staff with appropriate access | [Roles, groups and permissions](appmint-roles-groups-and-permissions.md) | | Work on a phone or tablet | [Appmint Mobile welcome](appmint-mobile-welcome-and-daily-work.md) | | Give employees a mobile softphone | [Employee phones and mobile CRM](appmint-mobile-employee-phones-and-crm.md) | | Build a custom API client | [Connected web or mobile client](appengine-build-a-connected-web-or-mobile-client.md) | Team access uses invitations and groups. The company profile is not where you grant permissions. A custom client needs the AppEngine authentication and organisation context appropriate to its users; do not put your staff credentials into a customer-facing website. ## If something goes wrong | Symptom | Check | Next action | | --- | --- | --- | | Create account remains disabled | Address availability and required fields | Choose a valid available name, complete names and matching passwords | | An existing email still enters signup | Whether you meant to register or return | Deliberately use Sign In for the company you already created | | Sidebar shows only icons | Expand control at its top | Expand it to read the group labels | | Clicking Account shows an overview, not the profile | Group name versus chevron | Expand Account and select Profile | | Profile shortcut says User ID is required | Which profile route you used | Use Account → Profile for the company form | | Name is saved but a status toast appears | Reloaded profile and selected interests | Confirm the profile separately from the toolbar-presence error | | Live Chat reports unavailable | The message in the support panel | Close and retry later, or use Submit Ticket for a real request | | Wrong company appears after sign-in | Organisation ID used in Site Name | Return to sign-in with your company’s exact ID | | Active Site says Nothing is loaded | Whether the signup site is listed | Press Choose a site and select it. If the chooser is empty, give support the organisation ID and screenshot. | | You only want the tour again | Replay versus organisation reset | Use Interface tour or How do I; leave Reset onboarding alone | ## What happened behind the scenes
For developers: why the distinctions matter Signup UI: `appmint.io/src/components/signup/Register.tsx`. Fresh local email signup proceeded through availability, account creation and welcome without an email-confirmation challenge. The existing-email note preserves an earlier production observation; this local run created a new owner. Company profile: `websitemint/packages/ui/src/account/org-profile-form.tsx`. It saves business name, logo and interests separately from the signup ID. Navigation: `packages/ui/src/ui/sidebars/links.ts` and `left-sidebar.tsx`. Tree and flyout modes place the expansion control differently; the footer email opens user profile. Support: `packages/ui/src/ui/header/support-menu.tsx`. Short guides and the personal replay are in `components/onboarding/help-center.tsx`; broad setup reset is in `components/setup-wizard/getting-started.tsx`. Do not assume two controls named around onboarding have the same scope.
## Where next Continue with [Build the Cedar & Form website](appmint-build-a-business-website.md). Keep this account and site: the next course gives the website a useful page and checks what a visitor actually sees. For filming, use the [companion-video production guide](production/appmint-welcome.md).
Capture evidence and limits Fresh local browser walkthrough, 19 September 2026: actual marketing signup at `localhost:4050`, Studio Manager 0.6.2 at `localhost:3100`, and local AppEngine. The learner created `cedar-local-mu8892bk` through the visible signup form with its own reserved local test email, Jordan Morgan identity and a new password. Signup opened welcome directly; no email challenge occurred. This establishes local account creation and password sign-in, not external inbox ownership or email verification. Completed: copy sign-in address/organisation ID; read six capability cards and Learn links; tour Next/Back/End; Setup Wizard and sidebar; save Cedar & Form and four interests, then reload; choose the provisioned `cedsite` and inspect its supplied address; navigate back to profile and site; open/end Invite my team guide; open documentation; fill/cancel support and bug forms; open the repaired Live Chat panel without sending, and verify its unavailable/retry state using a controlled local failure. A second empty browser context signed in and read back the saved name and four interests. No payment details or paid plan were required. The welcome Studio address and Open link now honor the environment configuration; a fresh browser check clicked through from local marketing to local Studio. The check restored non-secret welcome data after the browser crash without repeating signup. Vibe remains a separate production address. Vibe's public login was inspected without credentials; its authenticated screenshots above are explicitly historical production evidence, not a fresh local pass. The earlier production account was `learnmu4zs1td`, with its original capture ledger in `assets/appmint-welcome-verified/evidence.json`. No service activation, invitation, support ticket, bug report, Vibe build or production authentication was submitted in the fresh run. The site address was inspected; a signed-out visitor page is the website course's separate task. No OAuth, MFA, email delivery or public-site publication was tested. [Fresh execution record and evidence](../learner-review-records/appmint-welcome.md).
--- # Build a business website people can explore and contact > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-build-a-business-website.html # Build a business website people can explore and contact ![The finished Cedar & Form homepage, opened without a Studio login.](../learner-review-records/assets/website-local-20260919/20-local-visitor-home.png) Cedar & Form now has a front door: a clear promise, a visual identity, services a visitor can understand, and a way to start a conversation. In this course you build that homepage, add a Services page, connect the navigation and check the result outside the editor. **For:** business owners, content teams and developers learning Build Studio. **Allow:** 60–90 minutes, with an optional domain session. **Product:** Appmint Studio Manager → Build Studio. **Build:** local Studio Manager 0.6.2, rehearsed 19 September 2026. **Example:** Jordan Morgan's Cedar & Form business; organisation `cedar-local-mu8892bk`, site `cedsite`. Use your own account and supplied site address; those identifiers belong to the illustrated training business. ## What you will have at the end - A homepage with a headline you edited visually and a contact link pointing to your business. - A matching Services page with working outward and return navigation. - Saved pages you can reopen, a chosen homepage, and page search-description fields. - A visitor check at desktop and phone widths, plus a practical domain-connection handoff. The contact button in this exercise opens an email program. A website form, CRM lead and reservation are separate capabilities, introduced in the [enquiry course](appmint-turn-an-enquiry-into-a-project.md). Keeping that distinction clear helps you choose the right next step for your business. ## What you need Complete [Appmint signup and first setup](appmint-welcome.md). You should be able to open Studio Manager and find your **Active Site** on **App Root**'s Home screen. Download these two lesson files and open them in a plain-text editor: | File | Purpose | | --- | --- | | [Cedar & Form homepage starter](assets/first-page/cedar-and-form.html) | A complete layout with a room illustration, services, process and contact section | | [Matching Services page](assets/first-page/cedar-services.html) | A second complete page with navigation back to the homepage | You will copy the file's HTML text, not the formatted text displayed when a browser opens it. No coding is needed for the visual headline change. The small link edits later teach the relationship between a visible button and its destination. Choose the real email address your visitors should contact. The lesson uses `hello@cedarandform.example`, a fictional example that cannot receive your enquiries. Appmint and its apps are free; this exercise has no upgrade step. ## The story A homeowner has two minutes between meetings. They want to know whether Cedar & Form handles a single room, what happens after they enquire, and how to contact the studio. A page full of features would make them work too hard. Our page answers those questions in order: promise, services, process, invitation. The room illustration is embedded in the starter. You do not need an image account or a separate upload to reproduce this design. Later, replace it with work you have permission to show, keeping the same purpose: help the visitor picture the result. ## The route ```mermaid flowchart LR A[Make the homepage yours] --> B[Save and reopen] B --> C[Add Services and link both pages] C --> D[Choose the homepage] D --> E[Check as a visitor] E --> F[Connect your domain when ready] ``` ## Part 1 — Make the first page yours ### 1. Open a new page in the right site In Studio Manager's left sidebar, expand **Build Studio** using its chevron, then choose **New Web Page**. Read the site selector in the top toolbar before you add content. Our example says `cedsite`; yours should show the site created for your company. The page selector initially contains a generated `new_page…` name. This is a new page, not an already saved business page. ![New Web Page opens the editing surface with site and page selectors above it.](../learner-review-records/assets/website-local-20260919/02-empty-editor.png) Think of the site as the whole website and the page as one address within it. You will create two pages in the same site, not two separate sites. ### 2. Load the homepage starter Choose **HTML** at the bottom of the editor. Click inside the code editor, select all with **Cmd+A** on Mac or **Ctrl+A** on Windows, and replace the current contents with the entire homepage starter. Include its opening `` and closing ``. Press the HTML panel's **Save** button. Its tooltip is **Save HTML document to canvas**. The page appears on the canvas, and the application reports **Page applied to canvas**. ![The complete starter applied to Build Studio's canvas.](../learner-review-records/assets/website-local-20260919/03-html-applied.png) > **Watch for:** replacing the whole HTML document replaces the page's content. Use this step on the new page. If you see an extra `>` at the bottom, inspect the text after ``, remove the extra character and apply the document again. ### 3. Understand the editing surface before changing it Close the HTML panel with the small **X** at the left of its header. Clicking **HTML** again is not the close action. Click the large headline once. Its outline and floating toolbar show which element you selected. The page tree lists the same structure: a section contains text, links and the illustration; a heading is one child within that section. ![A selected headline has an outline and its own editing controls.](../learner-review-records/assets/website-local-20260919/04-heading-selected.png) | Area | What you use it for | | --- | --- | | Site and page selectors | Confirm what you are editing | | Canvas | Select and change visible content | | Floating element toolbar | Work on the selected heading, link or container | | Page tree | Find a parent section or a nested element | | HTML panel | Inspect or replace the document's markup | | Top toolbar | Change preview width, save the page record and close the page | If the tree expands into many `path`, `rect` and `ellipse` entries, you have expanded the illustration's SVG shapes. Collapse that branch to return to the useful page sections. Select a parent section when you mean the whole block; select its heading when you mean only the words. ### 4. Edit the main promise on the canvas Double-click the headline and replace its text with: > Design a home that works for your life Press **Tab** to leave text editing. The new headline should remain visible on the canvas. ![The headline changed through the canvas text editor.](../learner-review-records/assets/website-local-20260919/05-headline-edited.png) This headline says what the visitor gains. For your own business, try a similarly concrete promise: “Fresh lunch, ready when your team is” or “Book a repair without losing your afternoon.” Avoid using a slogan that tells a first-time visitor nothing about the service. ### 5. Give the contact link the correct destination Reopen **HTML** and press **Sync**, whose tooltip is **Sync from canvas**. This brings your visual headline edit into the code editor before you change a link. Find `hello@example.com` and replace it with your business's enquiry address. Keep `mailto:` in front of the address and preserve any `?subject=` text after it. For the fictional business the link becomes: ```html mailto:hello@cedarandform.example?subject=My%20interior%20design%20project ``` Press the HTML panel's **Save**, then close that panel. Your edited headline should still be present. ![Sync copies the current canvas into the HTML editor before a link change.](../learner-review-records/assets/website-local-20260919/06-synced-contact.png) > **Watch for:** applying an older copy of the HTML can undo a visual edit. Use **Sync** first. A `mailto:` link opens the visitor's configured email app; it does not submit a form or automatically create a CRM lead. There are two saves in this workflow. The HTML panel's **Save** applies code to the canvas. The top toolbar's **Save** persists the page record. You need the latter before you check the public website. ### 6. Check the narrow layout Use the smartphone icon with the **Phone View** tooltip in the top toolbar, then scroll through the whole page. In the local 0.6.2 check, the iframe measured 448 pixels wide (446 pixels of content); the headline and illustration stacked into one column. Check the headline, navigation, illustration, service cards and contact button. Return to **Desktop View** when finished. ![Phone View showing the narrow, stacked canvas in the local editor.](../learner-review-records/assets/website-local-20260919/07-phone-preview.png) Look for meaning as well as fit. Can a visitor understand the first screen? Are all three service choices readable? Can they reach the contact action without something covering it? The public-page check later catches differences that the editor alone cannot reveal. ### 7. Save the page record Press **Save** in the top toolbar. For a new page, the **Page** form opens. On **Information**, enter these values: | Field | Example | Why it matters | | --- | --- | --- | | **Site** | Your existing site; `cedsite` in the captures | Keeps both pages in the same website | | **Name** | `cedar-and-form` | A stable identifier for the page | | **Slug** | `cedar-and-form` | The page's address segment | | **Title** | `Cedar & Form — Interior Design` | A readable page-record title | Press the form's **Save**. Wait for **Data inserted** and the page selector to show `cedar-and-form`. ![The first save collects the page's identity and address fields.](../learner-review-records/assets/website-local-20260919/09-page-information.png) > **Watch for:** keep **Name** and **Slug** identical for this exercise. That avoids disagreement between shortcuts that open the name and public routing that uses the slug. Changing the title later is not a request to change the address. ### 8. Leave and reopen the canvas Choose **Close Page** in the toolbar. Click the **Build Studio** group name to open its overview, select **All Pages**, and find your page card. Hover the card and use its pencil **Edit** control. ![All Pages provides page cards with an Edit control.](../learner-review-records/assets/website-local-20260919/11-all-pages.png) Wait for the page name and canvas content to load. Read the headline again. This is the saved result, after leaving and returning. ![The saved page reopened as a visual canvas.](../learner-review-records/assets/website-local-20260919/12-reopened-headline.png) **Saved Pages** is also useful, but in this build it opens a **Data Table View — Page** table. Editing a row can open the page information form. For the visual canvas, use **Build Studio → All Pages → Edit**. Clicking only the card title does not open it. > **Watch for:** a bookmarked editor URL did not reliably reload the saved page during this walkthrough; it could open a new blank page. Reopen through the page card and verify the page selector before making changes. **Try it:** change one service-card heading to wording that suits your business, save with the top toolbar and reopen through **All Pages → Edit**. Confirm that specific change remains. **Check yourself:** you pressed **Save HTML document to canvas** and the page looks right. Is the public page necessarily updated? **Answer:** save the page with the top toolbar, then check it as a visitor. ## Part 2 — Turn a page into a small website ### 1. Add the matching Services page Open **Build Studio → New Web Page** again. Check the same site, open **HTML**, and paste the full [Services starter](assets/first-page/cedar-services.html). Apply it with the HTML panel's **Save**, then close the panel. Replace the fictional contact email with your own as you did on the homepage. Press the top toolbar **Save** and use: | Field | Value | | --- | --- | | **Name** | `services` | | **Slug** | `services` | | **Title** | `Services — Cedar & Form` | ![Saving the second page in the same site.](../learner-review-records/assets/website-local-20260919/15-services-save.png) After saving, find both page cards in **All Pages**. The second page is a complete design with its own introduction, service cards and contact invitation, rather than an unexplained heading on an empty canvas. ### 2. Connect the homepage navigation Reopen `cedar-and-form` through its **Edit** control. Open **HTML → Sync**. Find the navigation link whose text is **Services** and the **Explore our services** link. In each of those links, change the destination from `#services` to `/services`: ```html Services Explore our services ``` Keep the other attributes already present on your elements. Apply the HTML, close the panel and press the top toolbar **Save**. ![The homepage saved after its Services links were updated.](../learner-review-records/assets/website-local-20260919/17-home-navigation-saved.png) `#services` means a section within the current page. `/services` means the Services page in this website. Leave **Our approach** pointing to its section; not every navigation item needs a separate page. The Services starter already provides **Home** and **Return to the studio homepage** links pointing to `/`. Next, tell the site which page that root address should serve. ### 3. Choose the homepage Open **App Root**'s Home screen and find **Active Site**. Choose **Site Config**, open **Pages**, then choose `cedar-and-form` in **Home page**. Press **Save changes**. Reopen **Site Config** and wait for the page choices to load. The selected value should still be `cedar-and-form`. ![Home Page set to cedar-and-form and read back after saving.](../learner-review-records/assets/website-local-20260919/19-homepage-readback.png) If Active Site says **Nothing is loaded**, choose **Choose a site** and select your site before opening these controls. A fresh sign-in or reload may require selecting it again. The site and page names need not be the same. Here, the site is `cedsite`, while its homepage is `cedar-and-form`. A visitor can use the bare site address without knowing that internal page name. > **Watch for:** the selector can briefly show a placeholder while its choices load. Wait before concluding that the setting was lost or saving a second time. ### 4. Open the actual public address On **Active Site**, choose **View public site**. Copy that supplied address into a private browser window. Do not copy the Studio editor URL. Your site has its own supplied address. Read the page's headline to confirm you opened the right content. The screenshots below show the local rehearsal's anonymous renderer reading this learner's saved pages; its machine-local address is not a publishing address for your business. ![The homepage as an anonymous visitor sees it, outside Studio Manager.](../learner-review-records/assets/website-local-20260919/20-local-visitor-home.png) On the site used for this course, saving the page made it publicly available; Build Studio did not require a separate **Publish** button. Treat saved edits as changes visitors may see. A **Published** badge on a dashboard is less useful than opening the actual page and checking its content. ### 5. Follow the whole visitor journey In the private window, click **Services**. The new page should say **Design support for the way you live**. Click **Home** to return, then try **Explore our services** from the homepage and the return link at the Services page's foot. ![The distinct Services page reached from the homepage navigation.](../learner-review-records/assets/website-local-20260919/21-local-services.png) Scroll to the contact section. Inspect its destination and confirm it contains your enquiry email. If you click a `mailto:` button, your browser may ask to open an email program. Review its recipient and subject; you do not need to send a message to test the destination. ![The return navigation takes the visitor back to the homepage.](../learner-review-records/assets/website-local-20260919/22-local-return-home.png) ### 6. Repeat at phone width Open the public address on your phone, or use your desktop browser's responsive mode. Read from top to bottom and follow the same links. Try a narrow width as well as a large phone. ![The actual public page at a 430-pixel browser width; this is mobile web, not Appmint Mobile.](../learner-review-records/assets/website-local-20260919/23-home-430.png) Both pages were checked at 430 and 320 pixels wide and had no horizontal overflow. Navigation was followed again at each width. Your own text or images can change that result, so repeat this check after substantial content edits. **Try it:** ask someone unfamiliar with your business to find a service and reach the contact action. Watch where they hesitate; rewrite the confusing label before adding another section. **Check yourself:** the page opens, but the heading is wrong. Has the navigation passed? **Answer:** no. Confirm the destination page's content, not just that something appeared. ## Part 3 — Search details, recovery and your own domain ### 1. Add a useful page description Open the homepage's **Page Config** through the right-hand toolbar's **Show Page Config** control. On **Information**, find **Description** and enter: `Interior design studio for homes that work — consultations in Chicago.` ![Description belongs on the Page Information tab.](../learner-review-records/assets/website-local-20260919/29-description-readback.png) On **SEO**, set **Keywords** to `interior design, room refresh, Chicago`. Leave **Robots** at the existing default for this public training page. Press the top toolbar **Save** to persist these page changes. Close and reopen the page through **All Pages → Edit**, then revisit **Page Config** and both fields. The values should still be present. ![The SEO tab contains Keywords and Robots; Description is on Information.](../learner-review-records/assets/website-local-20260919/30-keywords-readback.png) Use a description that accurately summarizes the page. Keywords do not guarantee search ranking, and setting a description does not guarantee a search engine will display that exact text. The hosted page combines the Page record’s **Title** with the site’s **Title**. After loading, the local example read `Cedar & Form — Interior Design | cedsite`. The description and keywords above also appeared in the anonymous page's metadata. If the site title is still its technical name, give it your business name in **Site Config → Identity → Title**; avoid repeating the full page title in both fields. Keep imported HTML and saved page/site titles consistent, and wait for the public page to finish loading before reading the browser title. ### 2. Find earlier versions before you need them From **Show Page Config**, select **History**. Saved versions appear with their version number, timestamp and **View … at this point** control. Use **View** to inspect the version you are considering before a recovery. This opens that snapshot on the canvas. To leave it, use **Close Page**, then **All Pages → Edit** to reopen the current saved page; do not press Save while you are only inspecting an old snapshot. History opened from a snapshot can show **Nothing here**. Reopen the current page before selecting a different version. ![The saved homepage's History contains four earlier versions.](../learner-review-records/assets/website-local-20260919/31-history-list.png) The circular-arrow **Restore** control asks for a second click within five seconds. Restore only the version you intend to recover. After a recovery, reopen the page and check the public result, including links and metadata; an older design may also contain older contact details. This course's rehearsal opened History and its version view. It did not restore an older version over the finished website. The optional restore exercise belongs on a disposable training page: save two visibly different headlines and compare them before restoring. It is not required to finish this website. ### 3. Prepare the domain you want visitors to use Your supplied Appmint address already works. A custom domain gives visitors an address such as `www.yourbusiness.com`; you need control of that domain's DNS records to connect it. On **App Root → Active Site**, find the domain field with example text **e.g., mystunningwebsite.com**. Enter the full hostname you want, such as `www.yourbusiness.com`, without `https://` or a page path. ![The domain-entry field, illustrated with a fictional hostname. No domain was connected for this capture.](../learner-review-records/assets/website-local-20260919/36-domain-entry-visible.png) For your real domain, press **Connect** and follow the site's **Show DNS Settings** instructions. Use the hostname and target shown for your own site, not the training address in this course. The illustrated fictional `.example` domain cannot be registered or used to complete this connection. A CNAME record maps a hostname such as `www` to another hostname. At your DNS provider, create or edit that hostname's record using the target supplied by Appmint. For example, Cloudflare's route is the domain's **DNS → Records → Add record**: choose the record type, fill its name and target, and save. If the hostname already has a record, inspect and edit the existing record rather than creating a conflicting second one. [Cloudflare's DNS record instructions](https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/). Keep the DNS provider's tab and Appmint's settings side by side. Check spelling, the exact hostname (`www` and the bare domain are different), and the destination. Preserve unrelated mail records. After DNS resolves, open your new **HTTPS** address in a private window and test both pages. If the browser reports a certificate problem, the connection is not ready to share yet; use your working supplied address while you investigate. The domain exercise here stops at the genuine domain-entry screen because the training business has no controlled custom domain. The [production guide](production/appmint-build-a-business-website.md) lists the DNS and HTTPS shots needed to film a completed real-domain connection. Do not substitute an unrelated site's success screen. ### 4. Choose the next business capability A beautiful page starts the journey; the next capability depends on how your customers buy: | Visitor need | Next course | Result to check | | --- | --- | --- | | Ask a question now | [Customer conversations](appmint-run-customer-conversations.md) | A visitor message reaches the right inbox | | Request a consultation | [Enquiry and reservations](appmint-turn-an-enquiry-into-a-project.md) | A saved enquiry and a booked slot | | Return to private project information | [Client dashboard](appmint-create-a-client-dashboard.md) | A signed-in customer sees their own records | | Browse and buy products | [Custom products online](appmint-sell-custom-products-online.md) | Product, cart and order connect correctly | | Show business data in the page | [Operations dashboard](appmint-build-an-operations-dashboard.md) | A bound view shows the intended records | A form submission is not automatically a sales lead. A `mailto:` link is not a form. A public Services page is not a private client portal. Build and test each handoff explicitly as you add it. **Try it:** write down one visitor action you want next and the record you expect it to create. For Cedar & Form, choose **Request a consultation → CRM lead**; a reservation is a second result after a time is booked. Continue with the enquiry course to build that handoff. For your business, choose the matching next course rather than adding every feature at once. **Check yourself:** you entered a domain in a text field. Is the domain connected? **Answer:** the DNS connection and HTTPS visitor check must also succeed. ## If something goes wrong | Symptom | Check first | Next action | | --- | --- | --- | | HTML remains visible and covers other controls | The code panel is still open | Use its small left-hand X | | A visual edit disappears after applying HTML | Code was not synced from the canvas | Sync, repeat the edit and save both layers | | New Page opens instead of the saved design | You reopened an editor URL | Build Studio → All Pages → the card's Edit control | | Saved Pages shows a table | This opens the table view | Use All Pages for the visual editor | | Homepage setting briefly looks empty | Page choices are still loading | Wait, then read the selected value | | Services scrolls within the homepage | Link is still `#services` | Change that link's destination to `/services` | | Public page has old content | The page record has not been saved, or the wrong page is open | Check the selector, save, reload the public page | | Contact action opens the wrong email | Starter placeholder remains | Replace the email in each contact link, then save | | Browser title is repeated or changes while loading | Page Title, Site Title and imported HTML title differ | Use a short Site Title and descriptive Page Title; inspect after loading finishes | | Domain instructions show a placeholder or an unexpected target | Site/domain settings may be stale | Reopen settings and verify your site's supplied hostname before changing DNS | ## What happened behind the scenes
Records, source references and verification details The two Page records belong to site `cedsite`; the site's `homePage` is `cedar-and-form`. Page HTML is saved with the page, and the public site in this exercise serves saved content. The HTML panel's apply action and the record save are distinct operations. Source references under `/Users/imzee/projects/`: - `websitemint/packages/ui/src/components/build-studio/build-studio-store.tsx`: opening by stored record identity, save and new-record form. - `websitemint/packages/ui/src/components/build-studio/build-studio-code-view.tsx`: panel close, Sync and HTML apply controls. - `websitemint/packages/ui/src/components/welcome-screens/welcome-build-studio.tsx`: All Pages and card Edit. - `websitemint/packages/ui/src/components/data-view/base-form/base-history.tsx`: history view and double-click restore confirmation. - `websitemint/packages/ui/src/components/welcome-screens/site-domain-connect.tsx` and `dns-instructions-popup.tsx`: domain entry and DNS guidance. The source includes static fallback DNS values; this course does not present those as verified current infrastructure. - `sdk/src/models/site.ts`: homepage choice. - `appengine/src/site/site.service.ts`: site/page routing and the published-pages-only branch. The [local capture ledger](../learner-review-records/assets/website-local-20260919/manifest.json), [public metadata readback](../learner-review-records/assets/website-local-20260919/final-public-metadata.json) and [learner review](../learner-review-records/appmint-business-website.md) record the 19 September rehearsal. The learner's own signup organisation and site were used for page creation, visual edit, Sync/apply, both saves, reopen, Services creation, reciprocal navigation, homepage save/readback, metadata, History View and anonymous desktop/mobile checks. A separate reviewer followed the visitor journey. The local renderer used actual base-app source and the learner's own saved pages; this is not a production-hosting test. Domain entry was illustrated without Connect or DNS changes. History View was inspected without restoring. No enquiry form was configured and no enquiry email sent. Capture automation entered the initial starter through editor keyboard input; later HTML updates used the real Monaco editor input model, followed by the application's visible apply/save controls and anonymous readback. No server-side page records were fabricated. The graphics are lesson HTML/SVG assets, not generated screenshots of an imagined interface.
## Where next Continue with [Maya Bennett's enquiry and consultation](appmint-turn-an-enquiry-into-a-project.md). For filming, use the [companion-video guide](production/appmint-build-a-business-website.md), which pairs scenes with these real stills and distinguishes completed actions from remaining domain footage. --- # Turn an enquiry into a tracked lead and a booked consultation > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-turn-an-enquiry-into-a-project.html # Turn an enquiry into a tracked lead and a booked consultation ![Maya Bennett's consultation saved on the reservation board with its reference and time.](../application-fixes/assets/local-crm/19-maya-booking-reloaded.png) Maya Bennett wants help redesigning her kitchen. She has an approximate budget, a preferred start month and a referral from a previous client. This course gives that conversation a place to live, books the consultation and shows how to handle an enquiry that arrives as a booking first. **For:** business owners and staff who answer enquiries. **Allow:** 60–90 minutes. **Level:** beginner, with an import and handoff section for experienced users. **Product:** Appmint Studio Manager → CRM → Leads and Reservations. **Build:** local Studio 0.6.2 and AppEngine 0.132.0, checked 19 September 2026. **Example company:** Cedar & Form; use your own company account. Historical production captures are identified in the evidence note. ## What you will have at the end - A sales pipeline called **Cedar & Form — Design Enquiries**. - Maya's $4,800 enquiry with saved context, and a second enquiry from Daniel Reyes. - A reusable 30-minute consultation service with weekday availability in the business timezone. - Two saved bookings, with Maya's confirmation status recorded separately from her sales progress. - A practical routine for importing a booking, checking duplicates and completing the resulting lead. This course ends with a useful sales-and-booking system. It does not create a project automatically. Project kickoff is a separate handoff in the [workflow course](appmint-automate-a-business-handoff.md). ## What you need Complete [Appmint signup and first setup](appmint-welcome.md). A website and business phone number are not prerequisites for practising the CRM screens. If you already built the [Cedar & Form website](appmint-build-a-business-website.md), this is where you organise the enquiries it generates. For this first rehearsal, use an isolated practice organisation with no existing lead pipeline, so **Create Your First Pipeline** is available. Do not clear an existing business pipeline to reproduce the empty screenshot. **Before reservation work:** saving a booking or changing its status can attempt automatic notifications, even for a zero-price practice service. Use an operator-provided practice setup that captures email locally and has no SMS-capable sending number. Establish that boundary before pressing **Confirm Reservation**; an absent organisation-specific provider can still allow a shared-provider fallback. A fictional email or phone number alone does not disable delivery. This local rehearsal used its own organisation’s verified loopback SMTP catcher. If those controls are unavailable, obtain an isolated practice setup before saving bookings. Use fictional details while learning: | Prospect | Email | Phone | Illustrative opportunity | | --- | --- | --- | --- | | Maya Bennett | `maya.bennett@example.invalid` | `+12025550147` | Kitchen design, $4,800, referral | | Daniel Reyes | `d.reyes@example.invalid` | `+12025550148` | Whole-home design, $12,500, website enquiry | Choose a future weekday for your own bookings. The screenshots use **Monday, 21 September 2026**; copying a past date will not reproduce the available slots. ## The story Jordan needs to answer three different questions: “What opportunity are we following?”, “When are we meeting?”, and “What does the next colleague need to know?” A lead answers the first question, a reservation answers the second, and notes or activity answer the third. They are related business facts, stored in different records. The deal value is a possible design engagement, not an invoice or payment. The consultation in this exercise costs zero so we can practise scheduling without a payment step. ## The route ```mermaid flowchart LR A[Referral: Maya] --> B[Lead and saved context] B --> C[Book consultation] D[Booking first: Daniel] --> E[Search for existing lead] E --> F[Import and review] C --> G[Confirm appointment] F --> H[Track sales progress] G --> H ``` ## Part 1 — Give the enquiry a place to live ### 1. Open Leads and understand its views Expand **CRM** in the left sidebar, then choose **Leads**. The **Lead Command Center** has **Pipeline**, **Analytics**, **Lead Manager** and **Research & Enrich** views. Start with **Pipeline**. ![The first pipeline entry on a new organisation's Leads screen.](../application-fixes/assets/local-crm/01-leads-empty.png) Use **Pipeline** to see progress across stages. Use **Lead Manager** to find and inspect individual records, especially when a board filter hides a lead. The header count and the currently visible board are not necessarily showing the same subset. ### 2. Create the business pipeline Press **Create Your First Pipeline**, then **Create New Pipeline** in the management drawer. Fill: | Field | Value | | --- | --- | | **Pipeline Name** | `Cedar & Form — Design Enquiries` | | **Description** | `Consultation requests for residential design` | | **Workflow Template** | **Default Sales Pipeline** | | **Pipeline Color** | Choose a colour you will recognise | Press **Create Pipeline**, then close the **Pipelines** drawer using its X. ![Pipeline creation with the business name and Default Sales Pipeline template.](../application-fixes/assets/local-crm/02-pipeline-fields.png) The template supplies **New**, **Contacted**, **Qualified**, **Proposal**, **Won**, **Lost** and **Follow Up Later**. These names describe the opportunity's progress. **Proposal** is a stage; selecting it does not generate a proposal document. In **Show Pipelines**, enable **Cedar & Form — Design Enquiries**. You can hide **Unassigned Leads** to focus on the new business pipeline. Read the pipeline heading above the columns. ### 3. Enter Maya's enquiry Choose **Add Lead**. In **Add New Lead**, enter Maya's first name, last name, email and phone. Set **Lead Source** to **Referral** and **Deal Value** to `4800`. Choose your business **Pipeline** and its **New** stage where those controls are offered. Press **Create Lead** once. ![Maya's lead form with identity, contact and opportunity details.](../application-fixes/assets/local-crm/03-maya-create.png) The first name, last name and email are required in this form. **Company** can remain empty for a homeowner; do not invent a company just to fill every box. > **Watch for:** choose the intended **Pipeline** and **Stage** explicitly, then verify the saved record after reloading. Older builds discarded some create-form fields; the local mapping fix preserves the selected pipeline, stage, title and notes. The Edit exercise below also shows how to complete an existing enquiry. ### 4. Find the saved record before creating another one If the board remains empty, check **Show Pipelines** first. If it still shows no Maya card, choose **Lead Manager** and search for `maya.bennett@example.invalid`. ![The saved lead found in Lead Manager, despite being absent from the selected board.](assets/appmint-crm/05-lead-manager.png) An older build saved Maya with **Default Pipeline** and **Unknown** stage despite the form selection. That defect was reproduced and repaired locally. If you encounter an existing record in that state, use Edit below; do not create another lead. Open Maya's card, press **Edit**, explicitly choose **Cedar & Form — Design Enquiries** in **Pipeline**, then **New** in **Stage**. Choosing the pipeline first loads its stage choices. Do this even if the pipeline field already appears to display the name. ### 5. Save useful context through Edit In that same **Edit Lead** form, add: | Field | Value | | --- | --- | | **Title/Position** | `Homeowner` | | **Notes** | `Prefers evenings for calls. Referred by the Okonkwo project. Kitchen design budget $4,800; November start.` | Press **Update Lead**, wait for the save to finish, then reload. Return to **Pipeline**, enable your business pipeline under **Show Pipelines**, and find Maya in **New**. Reopen her lead, choose **Edit**, and read **Notes**: confirm the $4,800 kitchen budget, November start, referral and evening-call preference all remain. If anything is missing, correct it, save once, reload and check again before continuing. ![Explicit pipeline and stage selection, with context entered through Edit.](../application-fixes/assets/local-crm/05-maya-edit.png) ![Maya’s saved Homeowner title and complete notes read back through Edit.](../application-fixes/assets/local-crm/07-title-fixed-readback.png) The notes and **Homeowner** title both survive the reload on the repaired local build. Reopen Edit to check both fields before continuing. There is no owner selector in this **Edit Lead** form. An **Unassigned** label on the card means you have not established an owner through the assignment tools; entering the lead does not assign it to you automatically. ### 6. Record the conversation as an activity From the **Pipeline** board, open Maya's card. This drawer has **Overview**, **Activity**, **History**, **Notes** and **Enrichment** tabs. Choose **Activity**, leave its type as **Note**, and enter: `Referred by the Okonkwo project. Kitchen redesign, design-phase budget about $4,800, wants to start in November.` Press **Add Activity**. Allow the save to finish, close the drawer and reopen Maya's card. Return to **Activity** and read the entry. ![The saved activity visible after reopening Maya's pipeline drawer.](../application-fixes/assets/local-crm/08-maya-activity.png) > **Watch for:** the open drawer can keep an older snapshot after a save. Closing and reopening after the write completes refreshes it. If you reopen immediately and see nothing, wait and reopen again before adding a duplicate activity. The **Lead Manager** card opens a different detail view. For this activity exercise, return to the **Pipeline** card and use its singular **Activity** tab. The pipeline drawer's **Email**, **Call** and **Schedule** shortcuts did not provide a usable action in the reviewed build; use the Reservations screen below to schedule. **Try it:** add one concise next-action note, such as “At the consultation, confirm room measurements and desired start date.” Reopen the card and find it. **Check yourself:** is the $4,800 now revenue? **Answer:** it is the opportunity value. No invoice, payment or completed sale has been created. ## Part 2 — Make the consultation bookable ### 1. Open Reservations and see its prerequisite Choose **CRM → Reservations**. Its top tabs are **Reservations**, **Reservation Definitions**, **Service Points** and **Calendar**. Press **New Reservation**. When no definition exists, the first wizard step says **No services available — Please create a reservation definition first**. If an earlier course already created a service, it appears here instead. Close the drawer; create the distinct definition below without deleting existing services. ![The booking wizard explains that a service definition is needed first.](assets/appmint-crm/09-no-services.png) A definition describes what can be booked and when. A reservation is one customer's booking against that definition. Creating the definition once saves you re-entering hours and duration for every customer. ### 2. Create the definition's identity Open **Reservation Definitions → New Definition**. Set: | Field | Value | Meaning | | --- | --- | --- | | **Name** | `design-consultation-review` | Stable identifier for this definition | | **Type** | **Service** | A scheduled service | | **Status** | **Active** | Makes the definition available for booking | | **Title** | `Design consultation — 30 minutes` | Readable label for the definition | ![The definition's identity, type and active status.](../application-fixes/assets/local-crm/11-definition-identity.png) Leave **Business Location** and **Hosts** unset for this first exercise. Those are relationships to configured business records, not places to type arbitrary names. We use **Venue** later for an explanatory meeting-location label. ### 3. Add the actual service Expand **Services**, then use its **Add item** plus control. Enter: | Service field | Value | | --- | --- | | **Name** | `Design consultation` | | **Duration** | `30` | | **Price** | `0` | | **Break After** | `0` | ![The service within the definition: thirty minutes, no charge and no turnaround break.](../application-fixes/assets/local-crm/12-definition-service.png) Duration controls the appointment length. Break After reserves turnaround time after an appointment; for this simple practice it is zero. The definition's name and the service's name have different jobs. Select the saved service in the booking wizard instead of retyping a near-match. ### 4. Set days, hours and capacity Open **Work Days** and check that **Monday** through **Friday** are selected. The compact field can show one day and a count of `5`; expand it to see the full selection. ![Work Days expanded, showing the five selected weekdays.](../application-fixes/assets/local-crm/13-definition-weekdays.png) Under **Office Hours**, set or confirm **Timezone** as `America/Chicago`, **Start Time** as `09:00` and **End Time** as `17:00`. In the top-level **Spots** field, enter `1`. In **Venue**, enter `Cedar & Form consultation`. ![Office hours and timezone read back from the saved definition.](../application-fixes/assets/local-crm/15-definition-settings-readback.png) Timezone should describe the business's schedule, even when the person entering it is travelling. The selected timezone appears as a chip; empty search text inside the picker does not mean no timezone is selected. **Spots** belongs to the definition, outside Office Hours. With one spot, a booked appointment should remove that time from the next visitor's choices. ### 5. Save and read it back Press **Save** and wait for completion. Reload the screen, open **Reservation Definitions** and find `design-consultation-review`. Its card should show the saved service and hours. ![The active service definition after a full reload.](../application-fixes/assets/local-crm/14-definition-reloaded.png) > **Watch for:** the definition list can remain stale after a save. Reload before pressing Save again or creating a replacement. The saved definition is what matters, not an immediately unchanged list count. **Try it:** open the saved definition with its pencil Edit control and check the hours, timezone and capacity. Cancel without changing them. **Check yourself:** changing **Party Size** on a customer's reservation is the same as increasing definition **Spots**. **Answer:** no. They describe different things: that customer's party and the definition's slot capacity. ## Part 3 — Book, confirm and check availability ### 1. Choose the service and a future date Return to the **Reservations** tab, press **New Reservation**, and select **Design consultation**. Choose a future working day. The captured booking uses **21 September 2026**. Select the slot starting **10:00 AM** and ending **10:30 AM**, with `America/Chicago` beside it. ![Available half-hour appointments for the chosen weekday.](../application-fixes/assets/local-crm/16-maya-slots.png) A closed weekend or a past date can produce no slots even when the service is correctly configured. Read the day and timezone before changing the definition. ### 2. Enter the customer details On **Details**, enter Maya's full name, email, phone and **Party Size** `1`. In **Special Requests**, enter: `Fictional training booking. Kitchen redesign; design-phase budget $4,800. Existing sales lead: Maya Bennett.` ![Customer and booking context entered before review.](../application-fixes/assets/local-crm/17-maya-details.png) If **Search Existing Customer** finds the person, select them and still inspect the resulting fields. A saved lead and a customer are different records; the booking flow can create or find the customer by email. Reuse the same email to keep the person's identity consistent. ### 3. Review before saving Press **Review Booking**. Read the service, date, start and end time, customer contact details, party size and special notes. Correct mistakes by returning to the relevant step. ![The final review screen puts the appointment and customer together.](../application-fixes/assets/local-crm/18-maya-review.png) Check the notification-isolated practice prerequisite above before committing this booking. Press **Confirm Reservation** once, and wait while it says **Confirming…**. After completion, reload the Reservations screen and find Maya's card. The fresh local reference is `c96e4548`; your new booking will receive its own reference. ![The saved booking initially appears in New, with its reference and appointment time.](../application-fixes/assets/local-crm/19-maya-booking-reloaded.png) > **Watch for:** **Confirm Reservation** saves the booking; it did not set the business status to **Confirmed** in this build. Read the board column rather than inferring status from the button's wording. ### 4. Record the customer's confirmation Suppose Maya has now confirmed the appointment. On her reservation card, use **CHANGE STATUS → Confirmed**. Wait for the update, reload and find her under **Confirmed**. ![Maya's appointment in Confirmed after the explicit status change and reload.](../application-fixes/assets/local-crm/20-maya-confirmed.png) This changes the booking's status. It does not move her sales lead to Contacted or Won. Keep those decisions separate. A **Text messages are not going out** notice appeared in this training company because it had no SMS-capable number assigned. The booking still saved. Check the saved reservation before retrying, and do not assume a confirmation message was delivered. Phone/SMS configuration belongs in the [employee phones and mobile CRM course](appmint-mobile-employee-phones-and-crm.md). ### 5. Check capacity with Daniel's booking Start another **New Reservation**, select the same service and date, and look for 10:00. In the walkthrough it was no longer offered after Maya's booking. Select **11:00–11:30 AM** instead. ![The later slot list after Maya's occupied time was removed.](../application-fixes/assets/local-crm/21-capacity-after-maya.png) Enter Daniel's details from the example table. Add the note `Fictional training booking. Whole-home consultation; enquiry arrived as a booking before a lead existed.` Review and confirm, wait for completion, then reload. ![Two appointments: Maya Confirmed and Daniel New.](../application-fixes/assets/local-crm/23-two-bookings-reloaded.png) **Try it:** find both bookings by name and check their times. Read the references and verify that Daniel's booking did not replace Maya's. **Check yourself:** an SMS warning appeared. Should you create the booking again? **Answer:** first check the Reservations board and reference. A notification failure can coexist with a successfully saved appointment. ## Part 4 — Bring booking-first enquiries into sales ### 1. Search for Daniel before importing Return to **CRM → Leads → Lead Manager**. Search for `d.reyes@example.invalid`, allowing time for the search to complete. Daniel's booking exists, but on the first run the lead search should be empty. If his email already has a lead, do not import it again: open and complete that existing record. Use a different fictional identity only when deliberately starting a separate rehearsal. ![Searching for Daniel before importing his reservation.](../application-fixes/assets/local-crm/24-daniel-search-before-import.png) Clear the search afterwards. A leftover search can make other saved leads appear to disappear and can change the visible count. ### 2. Select only the booking that needs a lead Choose **Import Leads → From Reservations**. Wait for the reservations to load, then search `Daniel`. Match the email and your chosen booking date/time, not just the name. Select his row and press **Import (1)**. ![Reservation import filtered to Daniel.](../application-fixes/assets/local-crm/27-import-exclusion-fixed.png) ![Only Daniel selected for import.](../application-fixes/assets/local-crm/28-daniel-only-selected.png) Leave Maya out: you already created her lead. **Exclude existing leads** now checks saved lead emails and omits Maya on the repaired local build. Still search by email before importing. **Only confirmed** can hide a new booking such as Daniel’s; leave it off for this exercise. If verification cannot load, the dialog shows an error and **Retry** rather than inviting an unchecked import. ### 3. Inspect what the import actually created After import completes, reload and return to **Lead Manager**. Open Daniel's record. ![Daniel's imported lead after reload, before completing its sales context.](../application-fixes/assets/local-crm/29-daniel-imported.png) After the fresh local import, Daniel had **Default Pipeline**, **Unknown** stage, **Other** source, value `$0` and an `imported-contact` tag. The repaired import also preserves the booking note, date/time and party size. Import brings the person and booking context across; you still need to complete the opportunity. Press **Edit**. Explicitly select the business **Pipeline**, then **New**. Set **Lead Source** to **Website**, **Deal Value** to `12500`, and **Notes** to: `Booking: Design consultation, 21 September 2026, 11:00–11:30 America/Chicago. Whole-home project. Illustrative design budget $12,500.` Use your actual chosen date and time. Press **Update Lead**, wait, reload and reopen the business pipeline. Open Daniel’s lead again and inspect **Notes**: the consultation date/time and timezone, whole-home context and $12,500 budget should still match what you entered. Correct and recheck a missing value before moving his sales stage. ![Completing the imported lead's pipeline, value, source and booking context.](../application-fixes/assets/local-crm/30-daniel-followup-fields.png) ![Final local board: both enquiries total $17,300; Maya is Contacted and Daniel is New.](../application-fixes/assets/local-crm/33-final-pipeline.png) ### 4. Move an opportunity when something changes When you have contacted Maya, move her card from **New** to **Contacted**. Keep the card's name visible while dragging, and verify the destination afterwards. If the board is crowded, open the card's **Edit** form and change **Stage** explicitly instead. Reload and check Maya's card and stage. The final rehearsal state is Maya in **Contacted**, Daniel in **New**. ![Final board state: Maya in Contacted, Daniel in New.](../application-fixes/assets/local-crm/33-final-pipeline.png) The drawer's **History** tab showed a **Stage History** heading but no entries on this build, including after Maya's persisted move. Record the reason and next action in **Activity** rather than relying on that display as your only working record. **Try it:** write a useful next action for Daniel: what you need to ask at 11:00, and what decision that answer will support. Save and reopen it. **Check yourself:** Daniel now has a lead. Has a project been created? **Answer:** no. You have a booking, customer identity and sales opportunity; project kickoff requires a separate action or configured workflow. ## Part 5 — Extend the workflow deliberately Use **Qualified** when you have established fit, scope and a plausible budget. Use **Proposal** when you are preparing or discussing an offer. Move to **Won** or **Lost** when the business outcome is known. Check both the card's stage and any status-based metrics; a column move and a converted-status action are different operations in this product. The **Convert** action in Lead Manager is not an automatic customer/project/order generator. Do not use it as a substitute for creating the records your business needs. The [automation course](appmint-automate-a-business-handoff.md) covers an explicit agreement-to-kickoff handoff. For agreements, the communications tools include signed-document workflows. Prepare the actual document and intended signer, then follow that workflow's preview and delivery checks. This rehearsal did not send an agreement, reminder or cancellation. A stage named Proposal alone sends nothing. For public acquisition, connect the [website](appmint-build-a-business-website.md), [live-chat inbox](appmint-run-customer-conversations.md) and [campaign workflow](appmint-run-a-customer-campaign.md). Always decide which record should result: form submission, ticket, reservation or lead. Test each handoff with a fictional customer before relying on it for new enquiries. **Research & Enrich**, **AI Prospector** and **LinkedIn** provide additional prospecting entry points. Review sourced details before treating them as a customer's stated requirements. This course establishes the manual workflow so you can recognise missing or incorrect data in an automated one. ## If something goes wrong | Symptom | Check first | Next action | | --- | --- | --- | | Total count shows a lead, but board is empty | Pipeline visibility and actual assignment | Show the pipeline; find the record in Lead Manager and explicitly edit Pipeline/Stage | | Stage selector contains no stages | Pipeline state has not been selected | Choose the intended Pipeline, then its Stage | | Notes missing on an older build | Reopen the saved record | Edit → Notes → Update Lead; reload; the local create-field mapping has been repaired | | Activity seems missing immediately after saving | Write still pending or drawer stale | Wait, close and reopen before adding it again | | Title missing on an older build | Check the stored title through Edit | The local title-field mapping is repaired; reload the updated build and inspect the saved record | | No services offered | No active definition with a service | Create and reload the definition before booking | | Definition list looks unchanged after Save | Stale list | Reload before creating a duplicate | | No slots | Closed day, past date, timezone or capacity | Check the definition and select a future working day | | Confirm button saved into New | Creation and confirmation status are separate | Use CHANGE STATUS when the customer confirms | | SMS failure notice | No configured sending number or delivery problem | Keep the saved booking; configure and test messaging separately | | Existing-lead verification cannot load | The dialog’s load error | Retry, search by email and select only the needed booking | | Imported lead is Default/Unknown with no budget | Import does not complete sales context | Edit pipeline, stage, source, value and notes | | History is blank after a move | History display did not show an entry | Verify the saved stage and add a working activity note | ## What happened behind the scenes
Source and rehearsal evidence The fresh local learner rehearsal on19September2026 used Studio0.6.2 and AppEngine0.132.0, controlled organisation `ck-local-mu83iwh3`. Required Parts1–4 and all Try it exercises were completed through the actual UI, with API responses and full reload/readbacks. Final state: one Cedar & Form pipeline; Maya4800Referral/Contacted with Homeowner title, full notes and two activities; Daniel12500Website/New with booking context and one next-action activity; total17300. Maya booking`c96e4548` is Confirmed at10:00, Daniel`c96e4581` is New at11:00 on21September2026. Maya’s occupied slot was absent from the subsequent wizard. Existing Lina booking from the connected-client course was preserved. The local review reproduced and repaired create-form/pipeline identity/title mappings, reservation-import exclusion, and dropped import notes. A separate fresh pipeline/lead verified creation of a deliberately selected pipeline and Contacted stage with title/notes intact after reload; those two temporary regression records were then removed. Seven focused source regressions pass. Daniel was imported exactly once after an empty email search. Actual import verification failure showed an error and disabled Import; Retry loaded the real list with Maya excluded. [Completed local report](../application-fixes/crm-local-walkthrough.md), [actual response ledger](../application-fixes/assets/local-crm/api-results.json), [final readbacks and regression cleanup](../application-fixes/assets/local-crm/final-api-readback.json), [local mail envelope summary](../application-fixes/assets/local-crm/mail-capture-summary.json). Three automatic booking/status emails reached the organisation’s loopback catcher. SMS attempts were rejected because no sending number was configured. Raw mail and credentials remain private. No payment, project, external message delivery, agreement or won sale was created. The optional Part5 discussion adds no required delivery or project-creation step. Images linked under `application-fixes/assets/local-crm` are fresh local captures. Remaining `assets/appmint-crm` images illustrate the earlier production0.6.1 rehearsal and its historical failure/empty states; they are not the fresh local result. In particular, the saved-lead search and no-services images describe conditional recovery/prerequisite states. History still displayed its heading without entries after Maya’s actual persisted stage update; the course uses saved activities for working context. Source under `/Users/imzee/projects/`: - `websitemint/packages/ui/src/components/crm/leads/components/lead-form.tsx`: create/edit fields and missing owner selector. - `websitemint/packages/ui/src/components/crm/leads/store/lead-pipeline-store.ts`: create payload, update and stage operations. - `websitemint/packages/ui/src/components/crm/leads/components/lead-detail.tsx`: pipeline detail/activity; `modern-lead-detail.tsx`: different Lead Manager detail. - `websitemint/packages/ui/src/components/crm/leads/reservation-import-dialog.tsx`: reservation import, default pipeline/stage, filters and mapping. - `websitemint/packages/ui/src/components/crm/events/modern-reservations.tsx`: reservation board/status controls. - `appengine/src/crm/leads.service.ts` and `leads.controller.ts`: lead lifecycle and pipeline APIs. The fresh local observations above apply to the repaired local build; no production deployment is claimed. [Historical production capture ledger](assets/appmint-crm/evidence.json). The earlier drag capture moved Maya despite intending Daniel; the fresh local run instead used the course’s explicit Edit→Stage alternative and verified its readback.
## Where next [Answer enquiries with live chat](appmint-run-customer-conversations.md), [give a customer a private dashboard](appmint-create-a-client-dashboard.md), or [prepare employee access](appmint-roles-groups-and-permissions.md). The [companion-video guide](production/appmint-turn-an-enquiry-into-a-project.md) provides the sequence, shot list and continuity checks for this course. --- # Answer website visitors and keep their next step moving > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-run-customer-conversations.html # Answer website visitors and keep their next step moving ![Priya receives Jordan’s answer on the Cedar & Form website.](../learner-review-records/assets/chat-local-20260919/11-visitor-received-reply.png) *The result: a visitor asks about a small apartment and receives a human reply without leaving the website.* **Who:** business owners and the people answering enquiries. **Time:** 60–75 minutes, including the optional external-site exercise. **Level:** beginner, with advanced configuration afterwards. **Product:** Appmint Studio Manager and the website chat widget. **Checked:** local Studio 0.6.2 and widget 0.1.5, 19 September 2026, with actual separate agent and visitor sessions. **Example:** Cedar & Form, agent Jordan Morgan, visitor Priya Nair. ## What you will have at the end Your website will have a chat launcher, a greeting written for your business and an agent who can receive and answer an enquiry. You will recognise a waiting chat, end an active conversation and preserve unfinished work as a ticket. You will also configure an away message and understand why an agent going offline is different from setting the widget to Offline. You will test both ways to preserve a request: the visitor submits the away form and you find its automatic ticket, or an agent creates a follow-up after a live conversation. The two examples are separate practice records. In day-to-day work, search for an existing request before creating another ticket for it. ## What you need - An isolated practice site that is not advertised to real visitors. Complete staffing and the two-browser rehearsal there before enabling chat on an existing customer-facing site. - A Studio Manager account and the public site from [Build a business website](appmint-build-a-business-website.md). The example site is `cedsite`; your site will have its own name. - Two browser sessions: your normal browser for the agent and a private window for the visitor. Do not sign the visitor into Studio. - A fictional visitor for rehearsal: `priya.nair@cedarform.test`. For your own delivery tests, use an address you control. - A working contact route on the site for times when chat is unavailable. Check the destination of your contact link before advertising it in an away message. For a later transfer exercise, first add another colleague through [Roles, groups and permissions](appmint-roles-groups-and-permissions.md). You can complete the conversation and ticket exercises alone. ## The story Priya likes Cedar & Form’s approach but has a practical question: does the studio work on small apartments? Jordan answers that question, asks which room matters most and learns that she wants to keep her sofa while improving storage. That context should survive the chat window. A useful ticket gives the next colleague the customer’s objective and the next action, rather than simply saying “follow up”. ## The route ```mermaid flowchart LR A[Enable the site widget] --> B[Visitor asks a question] B --> C[Agent picks the queue entry] C --> D[Reply and confirm delivery] D --> E[End the chat] D --> F[Record unfinished work as a ticket] A --> G[Configure a separate away experience] ``` ## Part 1 — Put a human conversation on your site ### 1. Find the feature for the correct site In the sidebar, open **App Root**. Find **Active Site**, then **Site Features**. Read the site name before changing a feature: this setup connects a widget to that website. Locate **Live Chat** and select its **Set up** button. If it already says **Configure**, inspect the existing setup instead of creating another widget. ![Live Chat in the active site’s feature list.](../learner-review-records/assets/chat-local-20260919/06-chat-configured-reload.png) *The saved feature shows Configure after setup. Customer-facing chat is separate from the Support menu used to contact Appmint.* ### 2. Name the widget The drawer is **Set up Live Chat**. Its four steps are **Widget**, **AI replies**, **App user** and **Finish**. In **The chat widget**, keep the suggested name or choose a recognisable one. The example uses `cedsite-chat`, derived from the site name. If the organisation already has another widget, **Use an existing widget** appears first. To create a separate widget for this practice site, choose **Create a new one** in that selector, then enter the name. Do not reuse an unrelated support widget just because it is offered. Select **Next**. A useful widget name helps you find the correct configuration when you later edit its greeting or availability. ![Widget name in the setup wizard.](../learner-review-records/assets/chat-local-20260919/01-widget-name.png) ### 3. Choose who answers At **Should the chat answer on its own?**, select **Agents only**, then **Next**. This exercise establishes a conversation with a person before adding any automated answers. ![Agents only selected for the website chat.](../learner-review-records/assets/chat-local-20260919/02-agents-only.png) **You should see:** the **App user** step. Choosing Agents only does not staff the desk for you; somebody still needs to watch the queue and answer. ### 4. Keep the app user distinct from an employee The **App user** lets the website widget identify itself. It is not Jordan’s employee account. If **Use an existing app user** is shown, select **Create a new one** for this exercise; keep it separate from an app credential used for a different integration. Keep the example name `cedsite-chat`, or your corresponding site-based name, then select **Next**. ![App user name and explanation.](../learner-review-records/assets/chat-local-20260919/03-app-user.png) At **Ready to switch on**, read the summary. Nothing has been created by these choices until you select **Finish**. ![Final review before the wizard creates the configuration.](../learner-review-records/assets/chat-local-20260919/04-ready.png) ### 5. Finish and handle the one-time secret Select **Finish** once and wait for **Live chat is on**. When a new app user is created, the result includes **App secret — shown once**. Save that secret privately if you need it for server-to-server work. It does not belong in website HTML, screenshots, support messages or a video. The widget uses the app user name; it does not need you to expose that secret. Select **Done** when you have finished reviewing the result. ![Completed setup with the one-time secret removed before capture.](../learner-review-records/assets/chat-local-20260919/05-setup-secret-masked.png) *The secret was redacted before this screenshot was taken. The identifiers in the embed example are different from that secret.* ### 6. Reload and check the saved setup Reload Studio and return to **App Root → Active Site → Site Features**. **Live Chat** should now have **Configure** in place of **Set up**. In this example the feature counter became **1 of 12 on**. ![Live Chat remains configured after reload.](../learner-review-records/assets/chat-local-20260919/06-chat-configured-reload.png) Open **View public site** in the visitor’s private window. Look for the chat launcher at the lower right. An Appmint-hosted page uses the site configuration; you do not paste the external embed snippet into that page as a second widget. ![Public website with the chat entry available.](../learner-review-records/assets/chat-local-20260919/07-visitor-widget.png) **Try it:** close the visitor window, open another private window and find the launcher again. **Check yourself:** what connects this chat to the website? The saved site feature links the chat configuration and app user. Your employee login is the identity answering at the desk. ## Part 2 — Receive, answer and close an enquiry ### 1. Check the agent connection In Studio’s top toolbar, open **Chat**. If the menu offers **Connect chat**, select it. If it already offers **Go offline**, you are connected; do not disconnect just to repeat a setup step. The menu also contains **Open chat** and **Open conversations**. For this exercise, use sidebar **CRM → Chat** to open the full-page desk. **Open chat** can open a floating window over your current page, which is less convenient when learning the layout. The desk has **Agent Desk**, **Activity**, **Logs** and **Broadcast** tabs. Within Agent Desk, **Queue** is where you look for waiting support chats; **Chats** gives access to conversations; **Customers** lists customer entries. Start with **Queue**. **Check the connection and the delivery.** Wait for the Chat menu to show **Online**, then use the two-browser exercise below. The sent message in your desk and the received message in the visitor’s browser are two separate checks. ### 2. Enter as Priya In the visitor window, open the launcher. On the welcome panel, select the opener **Hi! How can I help you today?**. You will reach **Start chatting**, with **Enter your email to begin**. Enter `priya.nair@cedarform.test` in **Email**, then select **Start Chat**. This configuration asks for an email; it does not ask for a separate name here. ![Email entry before starting a visitor chat.](../learner-review-records/assets/chat-local-20260919/08-visitor-email.png) In **Type something...**, enter: > Do you handle small apartments? Press **Enter** to send. Leave this window open while you return to the agent desk. ![Priya’s question in the website widget.](../learner-review-records/assets/chat-local-20260919/09-visitor-question.png) ### 3. Pick the waiting chat At the agent desk, open **Queue**. A waiting entry should appear. Select **Pick next** to accept the next visitor. ![A waiting visitor in the support queue.](../learner-review-records/assets/chat-local-20260919/10-agent-queue.png) The support thread opens with the visitor’s message and the controls **Transfer** and **End chat**. In this rehearsal the customer appeared by email address, not as the friendly name Priya Nair. Use that address to distinguish the visitor from other entries. ### 4. Answer the actual question In **Type a reply... (Enter to send)**, enter: > Yes — we can help with small apartments. Which room would you like to start with? Select **Send**. Now switch to the visitor window and read the reply there. It should appear under Jordan Morgan’s name. ![The visitor receives Jordan’s reply.](../learner-review-records/assets/chat-local-20260919/11-visitor-received-reply.png) Reply as Priya: > The living room. I would like to keep the sofa and improve storage. Return to the agent desk and read that message. This completes a two-way test: a visitor question reached the agent, an answer reached the visitor, and the follow-up returned to the desk. ![Priya adds the room and storage requirements.](../learner-review-records/assets/chat-local-20260919/12-agent-followup.png) **Why this reply works:** it answers “can you help?” before asking for the information needed to decide a next step. Avoid requesting the whole project brief as the first response. ### 5. Keep the conversation through a reload Reload Studio while the visitor keeps their window open. Wait for the Chat connection to return to **Online**, then open **CRM → Chat** if you are looking at another management screen. Your assigned conversation returns in the active customer area with its saved messages, **Transfer** and **End chat**. If you have several assigned chats, select the appropriate customer tab by its email. Read the last question before replying; you should not need to ask the visitor to begin again. ![The assigned support thread and controls return after a real Studio reload.](../learner-review-records/assets/chat-local-20260919/28-restored-desk-with-reply.png) *This separate repetition used “Can we keep the existing sofa when planning storage?” After the reload, Jordan’s answer reached the same visitor.* There is also a historical conversation viewer under **Chats**. Selecting an older saved conversation opens its message history; its composer is different from the active support desk. Use the active assigned customer tab to continue a current support conversation. Use historical conversations to look up what was discussed. ![A previous conversation remains available in the saved-message viewer.](../learner-review-records/assets/chat-local-20260919/24-saved-conversation-readback.png) **Try it:** after the reload, send one useful answer and read it in the visitor window. The repeated test answered: “Yes. We will keep your sofa and plan storage around the way you use the room.” ### 6. Check whether a colleague is available before transferring Select **Transfer**. The drawer lists other available agents and includes **Transfer note (optional)**. In a company with only Jordan online, it showed **No other agents are online**. Select **Cancel** in that case and continue helping the customer yourself. ![Transfer drawer with no other agent online.](../learner-review-records/assets/chat-local-20260919/20-transfer-after-copper-toggle.png) When you have a second colleague ready, use a note that carries the work forward: “Small living room; keep existing sofa; improve storage; arrange initial consultation.” A useful transfer rehearsal needs that colleague’s separate session and a check that they receive the same context. Do not assume that seeing a name in a selector means the handoff is complete. ### 7. End deliberately In the visitor window, ask whether an initial consultation is the next step. Read that question at the desk, then send a closing answer that confirms the agreed action: > Yes. We will use a consultation to agree the room priorities and next steps. When the conversation is finished, select **End chat**. The agent sees **Chat closed**. Check the visitor window: it should say **This chat has ended.** and offer **Start New Chat**. ![The ended state on the visitor side.](../learner-review-records/assets/chat-local-20260919/29-single-end-notice.png) *This capture ends the separate sofa-and-storage reload rehearsal shown above; it demonstrates the same End chat result.* The visitor should see one ended notice and **Start New Chat**. Select **Start New Chat** when you want to rehearse another enquiry; it starts a separate conversation. Closing a chat does not itself create the follow-up ticket described in Part 4. **Try it:** rehearse one concise answer and one follow-up question in your second browser, or ask a colleague to act as the visitor. Read each message in the other browser before moving on. **Check yourself:** which screen confirms the customer received your answer? The visitor’s thread. Your own sent bubble confirms what you sent, not what the customer currently sees. ## Part 3 — Make the welcome useful and the away state honest ### 1. Select the widget you already created Open **CRM → Chat Designer**. It can initially show **Untitled**, which represents a new configuration. Open that selector and choose **cedsite-chat**, or the name you gave your own site widget. Check the selected name before editing. ![The existing configuration is selected before changes.](../learner-review-records/assets/chat-local-20260919/15-designer-selected.png) The left side contains the settings. The right side is a preview with sample pricing dialogue; it is not Priya’s conversation. The sections include **Name**, **Header Content**, **Status**, **Chat Openers**, **Offline** and **Availability**. Select a section’s heading or chevron to expand its fields. Keep the recognisable technical **Name** for finding this widget. Put customer-facing business wording in **Header Content**; do not use an internal identifier as your welcome message. ### 2. Write the header and the first question Expand **Header Content** and wait for its rich-text editor to load. Click inside the editor and enter: > Cedar & Form — Tell us what you would like to change. We will reply when a team member is available. Expand **Chat Openers**. Replace the existing opener in **Type your message here** with: > Tell us about the room you would like to change. Select the top **Save** and wait for the save confirmation. Reload the designer, select the same widget again, and check the opener and header. The example kept its widget/app-user name **cedsite-chat**. ![The saved opener and business header after reopening the widget.](../learner-review-records/assets/chat-local-20260919/31-greeting-reloaded.png) Open the public website in a fresh private visitor session and open the launcher. Read the actual welcome panel. Both pieces of text should appear there. ![The actual website shows the business wording and new opener.](../learner-review-records/assets/chat-local-20260919/32-public-custom-greeting.png) The header sets expectations; the opener gives the visitor an easy first action. Avoid promising 24/7 human staffing unless that is what you provide. Saving a greeting does not change staffing or a schedule. ### 3. Separate the agent connection from the widget’s mode In Studio’s toolbar, select **Chat → Go offline**. If you use several staff tabs or devices, check those too before treating the company as unstaffed. Open a fresh visitor session while the widget is still **Online**. Select the new opener. In this rehearsal the visitor could still reach **Start chatting** even though Jordan had disconnected. Going offline in the staff toolbar is not the same action as switching the website widget to an away form. ![Agent disconnected; the Online widget still offers Start Chat.](../learner-review-records/assets/chat-local-20260919/33-agent-offline-widget-online.png) Return to the saved widget in **Chat Designer**. Open the **Status** selector and choose **Offline**. Expand the separate **Offline** section below it. Keep **Show Form** on and enter this **Message**: > We are away from the desk. Tell us about your room and the best way to reach you; we will reply when the team returns. Select **Save**. Reload the designer, choose the same saved widget, expand **Offline**, and confirm that **Show Form** and your message remain saved. ![Offline mode with the away form and message configured.](../learner-review-records/assets/chat-local-20260919/34-offline-settings.png) ### 4. Send an away request and find it at the staff desk Open the website in a fresh private visitor session. Open the launcher and choose the opener. You should see **We are offline**, your message and the contact form. ![The actual away form before submission.](../learner-review-records/assets/chat-local-20260919/35-offline-visitor-form.png) Enter these practice values: | Field | Enter | Purpose | | --- | --- | --- | | Name | `Priya Nair` | Who the team will help | | Email | `priya.nair@cedarform.test` | Contact identity for this fictional request | | Phone number | Leave blank for this exercise | Optional additional contact route | | Message | `Please help plan storage for my small living room. I would like a consultation next week. Fictional tutorial request.` | Room, objective and requested next step | Select **Submit** once. Wait for **Your message has been sent**. In this test the input form disappeared after success, reducing the chance of submitting the same request again. ![The visitor receives a successful saved-request confirmation.](../learner-review-records/assets/chat-local-20260919/41-offline-submitted-success.png) Now switch to Studio and open **CRM → Tickets**. If you land on **New Ticket**, use the back arrow beside that heading to reach the list. Reload the list and find **Chat request from Priya Nair**. Open it and read the email and description. ![The automatic away request appears in the staff list.](../learner-review-records/assets/chat-local-20260919/42-offline-ticket-in-staff-list.png) ![The reopened automatic ticket contains the visitor’s actual request.](../learner-review-records/assets/chat-local-20260919/43-offline-ticket-reopened.png) The recorded reference was **IBQRTCKL**, with **New**, **Medium** and **Chat**. Yours will have a different reference. This ticket was created by the visitor’s form submission. Finding its matching message in the staff view is the handoff check; a success banner by itself is not the entire test. No received email or transcript is claimed by this check. If Submit shows an error, keep the message and investigate before promising receipt. Do not repeatedly press Submit: first search the staff list in case the save succeeded but the response was interrupted. ### 5. Rehearse an away message without a form Some businesses prefer to direct visitors to another contact route. In the saved widget, keep **Status → Offline**, switch **Show Form** off and replace **Message** with: > Our chat desk is away. Use Start a conversation on this website to email us about your project. Select **Save**. Use a new private visitor session, open the launcher and select the opener. Check that your message is visible and the input fields are absent. ![The form is absent when Show Form is switched off.](../learner-review-records/assets/chat-local-20260919/44-away-without-form.png) On the Cedar & Form page, **Let’s talk ↗** takes you to the contact section. Inspect **Start a conversation ↗** and confirm its email destination is your own monitored enquiry address. A `mailto:` action may open your email application; check the recipient and subject without sending a test message unnecessarily. The private fictional site uses `hello@cedarandform.example`. That is an illustrative destination, not a receiving inbox. Replace it with your business address before offering this fallback to real visitors. The walkthrough checked the contact anchor and destination; it did not establish email delivery. For the final practice configuration, return to the widget, switch **Show Form** back on and restore the tested “We are away from the desk…” message. Set **Status → Online**, select **Save**, then reconnect the responsible agent using **Chat → Connect chat**. The away form is now prepared for the next deliberate switch to Offline. ![Final saved configuration: Online, with the tested away form ready.](../learner-review-records/assets/chat-local-20260919/46-final-online-away-form-ready.png) **Check yourself:** what changes when the agent goes offline, when the widget switches Offline, and when a ticket is saved? They are three separate states. Test each one from the visitor and staff views. ## Part 4 — Turn unfinished work into a ticket ### 1. Create the follow-up with enough context Open **CRM → Tickets**. In this build the entry opened a **New Ticket** form under **Support Tickets**. If you arrive at the ticket list instead, select **New Ticket**. This is a manual staff action after the live conversation. It is separate from the automatic away-request ticket you just tested. For a real enquiry already recorded as a ticket, update that existing record rather than making a duplicate. Enter: | Field | Example | Why it matters | | --- | --- | --- | | Name | `Priya Nair` | Recognisable customer | | Email | `priya.nair@cedarform.test` | Identity to compare with the conversation | | Title | `Plan storage for Priya’s small living room` | Work to do, visible at a glance | | Description | `Fictional tutorial follow-up from the live chat. Priya wants to keep her sofa and improve storage in a small living room. Arrange an initial consultation next week. Jordan recorded this next action after answering the website enquiry.` | Outcome, constraints, next step and origin | | Status | `New` | Work has been recorded, not completed | | Priority | `Medium` | Example priority; use your team’s triage rules | | Channel | `Chat` | Where the enquiry started | Leave the automatically generated **Ticket ID** alone. Do not assign the ticket to a made-up colleague; select a real responsible person when one is available in your company. ![The manual follow-up ticket before saving.](../learner-review-records/assets/chat-local-20260919/37-manual-ticket-fields.png) ### 2. Save once, then find the record Select **Save**. After **Ticket saved**, the form resets. Use the back arrow beside **New Ticket** to open the list rather than pressing Save again on the cleared form. The rehearsal created ticket `SH78WHBS`. Your ticket will have its own reference. Find it by its title or customer, reload and open it again. ![The saved ticket in the list.](../learner-review-records/assets/chat-local-20260919/38-manual-ticket-list.png) ![The reopened ticket retains its title, customer and context.](../learner-review-records/assets/chat-local-20260919/39-manual-ticket-reopened.png) The detail screen includes **Details**, **Messages**, **Notes** and **Resolve**. Recording a ticket does not mean you have sent the customer an email or resolved the request. Keep it New until somebody starts the work; use the actual next action to decide when it is complete. **Try it:** ask another person to read only the title and description. They should be able to say which room, what stays, what needs improving and what happens next. **Check yourself:** was this ticket created automatically by the widget? No. A staff member saved it, then checked the persisted record. That distinction matters when reviewing missed enquiries. ## Part 5 — Extend the channel deliberately **External websites.** In **Chat Designer**, the **Embed Code** area offers **HTML/JavaScript**, **React Component**, **Select App Credential** and **Copy Code**. Select the widget’s app credential, not an employee’s login. The generated code uses an organisation ID, configuration ID and app user name; the app secret stays private. ![The generated embed and app credential selection.](../learner-review-records/assets/chat-local-20260919/25-correct-embed-instructions.png) The **HTML / JavaScript** output is a complete example page. Select **Copy Code**, save the complete example as an `.html` file, and serve it on your test website. If you are adding it to an existing page, put its stylesheet links in that page’s ``, then its chat container and scripts before the closing ``. Do not paste a second complete HTML document inside your existing page. Open that test page in a separate private visitor session. Confirm the launcher and the saved greeting, enter the visitor’s email, and send a question. Keep the agent connected in **CRM → Chat**, pick the queue entry and reply. Read the answer on the external page, then end the chat and check the ended state there. ![An actual standalone embed receives the agent’s reply.](../learner-review-records/assets/chat-local-20260919/47-external-embed-reply.png) This additional rehearsal used Alex Rivers (`alex.rivers@cedarform.test`), who asked: “Can I arrange a single-room consultation through this chat?” Jordan replied: “Yes. Please tell us which room you would like to start with.” The generated example was tested as a separate local website using the same local API and widget build, then closed deliberately. An Appmint-hosted site already configured in Part 1 does not need this second installation. **Email and phone.** Website support chat, business email, telephone calls and customer tickets are separate parts of the service workflow. Configure and test each channel with an address or number you control. The [employee phone and mobile CRM course](appmint-mobile-employee-phones-and-crm.md) covers staff calling; the [custom API course](appengine-connect-a-custom-business-api.md) covers external-service credentials and integrations. Do not interpret a working chat reply as confirmation that email, SMS or calls are configured. **AI assistance.** The setup wizard also offers AI replies. Establish the human desk first, then follow [Build an AI business assistant](appengine-build-an-ai-business-assistant.md) to test knowledge, answers and human escalation. Enabling an assistant is not a substitute for checking what happens when it cannot answer. ## If something goes wrong | Symptom | Check and next action | | --- | --- | | Live Chat still shows Set up | Confirm the correct active site. Complete Finish once; closing the wizard before Finish does not create the setup. | | No launcher | Check the saved site feature and the public page you opened. For an external embed, check its script/stylesheet requests and organisation, configuration and app-user values. Avoid installing a second widget on the same hosted page. | | Queue is empty | The visitor must send a message after entering their email. Confirm agent and visitor are using the same organisation/widget, then inspect Queue. | | Visitor appears as an email | This configuration collected email at entry. Use it to choose the correct thread. | | Reload opens an older conversation | Select the current assigned customer tab in Agent Desk. Historical Chats entries are a separate viewer. Read the last question before replying. | | No other agents are online | Cancel Transfer and keep helping the visitor until a colleague is connected and available. | | Agent is offline but the visitor can start | Agent connection and widget Status are separate. Check other staff tabs/devices, then deliberately configure the intended widget mode. | | Away form is missing | Check the selected widget, Status, Offline → Show Form and the saved values. A successful submission also hides that session’s form. | | Submit shows an error | Keep the visitor’s message; check the staff list before retrying. Do not claim receipt without a saved request. | | Blank New Ticket after Save | The form resets after success. Use the back arrow to the list and find the saved title before creating another record. | | Name changes unexpectedly | Keep a simple technical widget identifier. Use Header Content for business wording and read the saved configuration back. | | External page shows the wrong greeting | Confirm its configuration ID and app credential. Test in a fresh visitor session after saving the intended widget. | ## What happened behind the scenes
For developers: configuration, assignments and guest ticket intake The setup wizard creates a chat configuration and app credential, then links them to the site’s live-chat feature. The human-only setup leaves the AI assistant unset. The public widget uses its app-user name; its one-time secret does not belong in the embed. Queue acceptance assigns a customer conversation to the agent. Reconnection restores server-recorded assignments for that signed organisation/agent and loads their saved messages. Ended conversations are removed from active assignments while their message history remains available. Presence and transfer candidates are scoped to the organisation. The away form uses a signed, organisation-bound guest token and the narrow `POST /crm/tickets/chat-request` intake. That route validates the same-organisation widget and Show Form setting and constructs a New/Medium/Chat ticket from bounded contact/message fields. It does not give an unverified visitor a registered customer’s identity or accept arbitrary ownership, assignment or status fields. A saved ticket ID is required before the widget confirms success. The local walkthrough found and repaired tenant presence leakage, missing guest organisation binding, lost active controls on reload, duplicate lifecycle notices, misleading full-page embed instructions and the guest/general-ticket authentication mismatch. Each repaired path was exercised again. Earlier failure captures remain in the review records; they are not the result taught in this course. Relevant software source paths under the projects directory: - `websitemint/packages/ui/src/components/welcome-screens/buttons/live-chat-setup-wizard.tsx` - `websitemint/packages/ui/src/ui/header/live-chat-control.tsx` - `websitemint/packages/ui/src/components/crm/chat/agent-desk/agent-desk-store.ts` - `websitemint/packages/ui/src/components/crm/chat/modern-chat-designer.tsx` - `chat-client/src/components/chat-socket.tsx`, `chat-store.ts` and `utils/request.ts` - `appengine/src/chat/chat.gateway.ts`, `chat-presence.service.ts` - `appengine/src/users/users.service.ts` - `appengine/src/crm/tickets.controller.ts`, `tickets.service.ts` Closing a chat can request a transcript, but this exercise did not establish transcript or notification-email delivery. Phone, AI and actual colleague transfer require their own acceptance checks.
## Where next Use [the enquiry and booking course](appmint-turn-an-enquiry-into-a-project.md) to turn the customer’s interest into a consultation. Continue to [a client dashboard](appmint-create-a-client-dashboard.md) for ongoing client work, and [roles and permissions](appmint-roles-groups-and-permissions.md) before expanding the support team. **Evidence:** own local Cedar organisation, site `cedsite`, widget/app user `cedsite-chat`; independent visitor sessions. Passed: setup/reload, two-way chat, active-assignment recovery, historical readback, transfer availability with no eligible colleague, deliberate end, branded greeting, agent/widget availability distinction, automatic away ticket **IBQRTCKL** and staff readback, manual ticket **SH78WHBS** and independent reading exercise, no-form fallback, and standalone embed exchange. The companion packet includes real stills and two raw screen recordings. No actual colleague transfer, attachment exchange, AI takeover, phone setup, receiving inbox or transcript delivery is claimed. [Capture and acceptance ledger](../learner-review-records/assets/chat-local-20260919/MEDIA.md) · [Companion-video guide](production/appmint-run-customer-conversations.md). --- # Set up a focused client portal for bookings, files and support > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-create-a-client-dashboard.html # Set up a focused client portal for bookings, files and support ![The saved three-section client portal configuration.](../learner-review-records/assets/portal-local-20260919/05-saved-portal-controls-visible.png) *1 — Three sections selected. 2 — The built-in layout stays in place while you establish the client workflow.* **Who:** business owners setting up customer self-service, with a developer joining for custom layouts and data checks. **Time:** 30 minutes for configuration; allow another session for customer acceptance testing. **Level:** beginner setup, advanced testing afterwards. **Product:** Appmint Studio Manager and your public website. **Checked:** local Studio0.6.2 and AppEngine r10, 21 September 2026. **Example:** Cedar & Form, clients Maya Bennett and Daniel Reyes. **Course status:** complete locally. Settings, saved configuration, public login, reservation readback, private customer upload and reload, customer ticket create/reload, second-customer isolation, owner DAM file readback, owner reply, sign-out redirect, and desktop/mobile chat/sidebar behavior were exercised against the local applications. Evidence is linked from the repair reports in `tutorials-plan/application-fixes/`. ## What you will have at the end You will have a saved client portal menu, a clear customer sign-in entry and a precise way to check the portal against your business records. The first configuration uses **Files**, **Reservations** and **Tickets**. You will know why selecting every available section can expose an unfinished workflow, and how to distinguish a customer account from a Studio staff account. The useful outcome is a client who can find the right appointment, share the right file and raise a request that staff can actually act on. Enabling menu items is the first step toward that outcome, not the whole setup. ## What you need - Your live site from [Build a business website](appmint-build-a-business-website.md). - The customer records and bookings from [Turn an enquiry into a tracked lead and a booked consultation](appmint-turn-an-enquiry-into-a-project.md). - An email inbox you control for the customer sign-in test. The fictional addresses printed in the course are record examples, not mailboxes you can use to receive a magic link. - Two separate private browser sessions for the later customer access checks. - A small, non-sensitive test file. Start with a plain text document called `cedar-form-maya-room-brief.txt` containing “Living room: retain the sofa, improve storage, use warm lighting. Tutorial example.” The existing bookings are Maya’s consultation on **21 September 2026, 10:00–10:30**, and Daniel’s at **11:00–11:30**, both **America/Chicago**. Use future dates for a fresh rehearsal and write down the corresponding references. Daniel already has a record; he should not be treated as an empty account. ## The story Maya has booked an initial consultation. She needs to check the time and send a short room brief. Daniel has booked the following appointment. Cedar & Form wants each client to see their own material without giving either person access to Studio Manager. A portal has two distinct responsibilities: help clients find the right section, and return only the records they are allowed to use. A tidy menu solves the first. Customer authentication and record ownership govern the second. ![Concept diagram showing the shared portal menu and separate customer records.](assets/appmint-portal/portal-record-map.svg) *This is an explanatory graphic, not a screenshot or a completed access test.* ## Part 1 — Configure the client’s menu ### 1. Select the website you mean to configure In Studio Manager, open **App Root** and find **Active Site**. Read the site name before changing anything: portal settings belong to that website. If the card says **Nothing is loaded**, select **Choose a site**, then select your existing website. The local rehearsal used `cedsite`, created in the website course. If another site is active, select **Change Site** first. ![Selecting the existing training website.](../learner-review-records/assets/portal-local-20260919/01-choose-existing-site.png) On the current interface, scroll down through the Active Site card to **My Account — what a signed-in customer sees**. The controls are directly on the page. The older0.6.1 screenshots used a separate Client Portal drawer; do not look for that drawer when your screen has this inline section. ### 2. Keep the built-in layout first Under **Layout page**, leave **Built-in default layout** selected. The other options are saved pages belonging to the selected website. A custom page wraps and styles the account area; selecting a marketing page does not build a new client application or change record ownership. Start with the built-in layout so you can verify bookings, files and support before changing their presentation. ### 3. Choose the sections that serve the first customer tasks Under **Sections**, select **Files**, **Reservations** and **Tickets**. A selected pill has an indigo outline and a check mark. Deselect other sections if they were already enabled. Confirm **3 selected**. | Section | What the customer needs to do | What you must verify | | --- | --- | --- | | Files | Share a room brief | Upload, reload, staff retrieval and customer ownership | | Reservations | Find the consultation | Correct customer, appointment time and status | | Tickets | Ask for help | A usable ticket type/form, successful submission and staff response | **Watch the empty selection:** the helper says **Select none to show everything**. Removing all checks means **All shown**, not a hidden portal. Keep an explicit selection for a focused menu. **Try it:** select **Profile** briefly. The count changes to4. Select it again to remove it and return to3 before saving. This rehearsal changed only the draft selection; Profile was not saved as a fourth section. ### 4. Save and prove the settings persisted Select **Save** once and wait for **Saving…** to finish. Reload Studio. If **Active Site** is empty after reload, choose the same site again; an unloaded site card is not evidence that your portal settings were lost. Scroll back to **My Account**. Confirm the built-in layout, **3 selected**, and check marks on Files, Reservations and Tickets. The unchanged **Save** button is disabled; you do not need to edit something merely to enable it. ![The three portal sections and built-in layout retained after reload and site reselection.](../learner-review-records/assets/portal-local-20260919/05-saved-portal-controls-visible.png) **Check yourself:** does hiding Orders stop a customer typing an Orders address? No. This setting chooses the navigation menu. The server must separately enforce access to each customer's records; that is why the later two-customer checks matter. ## Part 2 — Explain customer access correctly ### 1. Open the account entry on the public website Use **View public site** to get your website’s actual address. In a private window, open that address with `/account` at the end. For a branded site, this might be `https://your-domain.example/account`; use your real website domain, not Studio Manager’s domain. A signed-out visitor is redirected to `/login`. The captured screen contains **Email address**, **Password**, **Sign in**, **Send magic link**, **Forgot password?** and **Create one**. ![The public customer login reached from account.](assets/appmint-portal/04-public-account-entry.png) This is customer access to the website. Staff invitations and Studio permissions belong in [Roles, groups and permissions](appmint-roles-groups-and-permissions.md). Do not invite clients to Studio just to let them check a booking. ### 2. Check whether the customer already exists In your staff window, open **CRM → Customers & Benefits**. The **Contacts** tab shows the customers created by the booking and chat exercises. Find the customer by email, not only the displayed name. ![Maya, Daniel and Priya in the staff customer list.](assets/appmint-portal/07-staff-customers.png) Maya’s booking created a customer record using `maya.bennett@example.com`. That does not mean Maya selected a password at booking time. Account identity and an established sign-in method are separate things. Use the same email for booking and customer access. Creating another account with a slightly different address makes matching records harder; it does not repair the original account. ### 3. Understand the new-customer form On the website’s login screen, select **Create one**. The registration form says **Create an account** and contains: | Field or action | What the customer does | | --- | --- | | Full name | Enters the name the business should use | | Email address | Enters their accessible email address | | Send magic link | Requests the registration/sign-in email | | Sign in | Returns an existing customer to login | ![The actual registration fields; there is no password field.](assets/appmint-portal/05-customer-registration.png) The current form does not ask a new customer to choose or confirm a password. Instructions that say “set a password here” would send them looking for a field that is not present. For a genuinely new customer, use an address they can access and complete the link from that inbox. Keep the message and its link private. This course's controlled Maya and Daniel links were delivered through the local SMTP catcher and both sessions reached the portal. ### 4. Handle the existing-booking customer Submitting Maya’s existing booking email through **Create one** displayed: > An account with this email already exists. Please login instead. ![Existing customer registration result.](assets/appmint-portal/06-existing-customer-registration.png) Return to **Sign in**. A customer who already has a working password can use **Email address**, **Password**, then **Sign in**. A customer without one should use the existing-account **Send magic link** route with their accessible email address. Do not ask the customer to invent another email, register again or use a staff password. If a requested sign-in email does not arrive, staff should investigate delivery for that address before claiming account creation failed. ### 5. Know what the staff security panel does From **Customers & Benefits**, open the customer record and expand **Security & Login**. It includes **Reset Customer Password**, a **Reset Password** action, account lockout details and a two-step verification status panel. ![Customer security controls in the staff record.](assets/appmint-portal/08-customer-security.png) This is an account-recovery area. It is not a routine portal-setup step, and it does not verify that a customer received an email. The reset action was inspected but not executed in this walkthrough. Keep customer identity checks and your support process in place before changing someone’s access. **Try it:** compare the email on a customer record with the email on their reservation. Correct a typo through the appropriate business process before asking them to sign in again. **Check yourself:** why did registration say Maya already exists? Booking had already created her customer identity. The registration form was trying to create that identity again. ## Part 3 — Rehearse the customer tasks before rollout The instructions in this part follow the current customer components in source and were rehearsed with controlled Maya and Daniel accounts. Repeat with inboxes your team controls before inviting customers. ### Read an appointment without changing it After customer sign-in, open **Reservations**. Clear **Search reservations...** and choose **All Reservations** before deciding that a record is missing. A search or status filter can hide an otherwise available booking. Open Maya’s reservation and compare it with the staff record: | Detail | Maya’s expected record | Daniel’s expected record | | --- | --- | --- | | Service | Design consultation | Design consultation | | Date | 21 September 2026 | 21 September 2026 | | Time | 10:00–10:30 | 11:00–11:30 | | Timezone | America/Chicago | America/Chicago | | Staff status at the end of the CRM exercise | Confirmed | New | Compare the actual appointment time, not just its creation date or a familiar customer name. The repaired component displays **Portal design consultation**, **21 September 2026**, **10:00–10:30**, and **America/Chicago** for Maya; evidence is in the portal repair report. The current source includes **Cancel Reservation** and **Reschedule** buttons without action handlers in this account component. Use the business’s confirmed staff-assisted change process until those customer controls are exercised successfully. Do not tell a client an appointment moved because a button was visible. ### Upload one harmless file, then find it again Open **Files**, select **Upload Files**, then **Browse Files**. Choose the small room-brief text file prepared earlier. Read the selected filename before starting the upload. The modal lists selected files and shows progress while sending. After upload, find the filename in the list. Reload the page and find it again using **Search files...**. If the interface reports **Upload failed**, retain the error and use a working file-sharing method your business has approved; do not repeatedly upload the same file and create uncertainty about which copy arrived. Client file storage and staff access are separate checks. A client-visible file is not automatically attached to a ticket or placed on a project. Establish exactly where your team retrieves it before instructing clients to use this as the only delivery route. ### Raise a request that staff can recognise The drawer’s **Tickets** selection maps to **Support Tickets** in the customer navigation. Ticket creation uses configured ticket types and their forms. The exact form fields depend on that configuration; there is no guaranteed universal “one title and one message” form. For the rehearsal, use the request title **Kitchen lighting question** and a description such as: > Before our consultation, I would like to discuss warmer lighting over the kitchen worktop. Can you advise which measurements or photographs to prepare? Tutorial example. Keep the customer email consistent with Maya’s account. After submitting a configured form, record the generated ticket reference and reload the customer’s list. In the staff window, open **CRM → Tickets**, search for the same reference and compare the customer and description. If no ticket type or usable form is available, complete that configuration first. The [customer-conversation course](appmint-run-customer-conversations.md) demonstrates a working staff-created ticket as a fallback. Label it as manual; do not represent it as a ticket submitted by the client. The owner read Maya's ticket in CRM, replied with the room-brief confirmation, and the API returned a persisted message. The portal course checks the saved ticket and staff handoff; email delivery remains subject to the organisation's configured provider. **Try it:** have a colleague follow one task without seeing Studio. Ask them to find the appointment time or upload the test brief, then explain how they know it worked. **Check yourself:** is a success message enough to launch a self-service task? Reload and read back the result, then check the receiving side where relevant. The customer and staff views must agree on the same record. ## Part 4 — Test the boundary between clients The live signed-out test opened `/account/reservations`, `/account/tickets`, `/account/files`, `/account/profile` and `/account/projects`. All five returned the customer **Sign in** page. ![A direct account URL returns the signed-out visitor to login.](assets/appmint-portal/12-anonymous-account-guard.png) That establishes the anonymous entry behaviour. It does not establish what Daniel can access once signed in. For the two-customer rehearsal, use two separate browser profiles, or two different browsers. Two private windows in the same browser may share a private session, so they are not sufficient isolation. Sign Maya into one profile and Daniel into the other. Check each profile’s displayed customer name/email before opening a copied record address; keep the staff Studio session in a third profile. If either customer session shows the other identity, stop, sign out and re-establish separate sessions before testing access. Compare each customer’s own booking against the table above. Upload different harmless filenames so accidental sharing is easy to recognise. Where a record has a direct address, try Maya’s address in Daniel’s session. If the interface uses an in-page panel rather than a unique address, have the developer check the corresponding authenticated record request; copying `/account/tickets` alone tests the section, not a particular ticket. A suitable result gives Daniel his own material and refuses or omits Maya’s private record. Record both sessions and the exact outcome. Do not describe an expected refusal as an observed one. Use **Sign Out** when finished and reopen `/account`. It should return to sign-in. Also check that the browser does not leave a sensitive file open in another tab after signing out. ## Part 5 — Add branding and advanced views **Custom layout.** Build a separate portal wrapper page with your header, footer and restrained styling. Choose it in **Client Portal → Layout page**, save and test the actual account pages at desktop and phone widths. Preserve the account content and navigation; a decorative homepage should not cover the forms a client needs. The custom wrapper was not selected in this rehearsal. **Project dashboard.** The settings drawer offers Projects, but the inspected default navigation currently comments out its Projects entry. Selecting that pill alone is not enough to deliver a project tracker. Use [the operations dashboard course](appmint-build-an-operations-dashboard.md) for the data model and [Vibe Studio](appmint-vibe-build-a-client-app.md) for a custom client application. Bind records to the signed-in customer rather than hard-coding Maya’s record ID. **Messages, Profile and Help.** The inspected Messages component uses an empty mock thread and local-state sending; Profile save is unfinished; Help contact actions are placeholders. Keep those incomplete actions out of your initial service promise. Use the [tested website chat](appmint-run-customer-conversations.md) and a confirmed support channel while those components are completed and rechecked. Do not repair a missing record by broadly changing its owner in raw JSON. First establish the correct customer and record relationship. A typo fix and a transfer of ownership are different business actions. ## If something goes wrong | Symptom | First check and next action | | --- | --- | | Every section is shown | Reopen Client Portal. An empty selection means All shown; select the intended sections explicitly. | | Save is disabled | Nothing has changed. Inspect the saved selection rather than toggling randomly. | | Different site was configured | Check Active Site and use Change Site before editing its portal. | | Registration has no password field | This form uses Full name, Email address and Send magic link. Do not follow older password-signup instructions. | | Existing email is rejected by Create one | Return to Sign in; the booking or earlier interaction may already have created the customer. | | Sign-in email is missing | Confirm the exact customer address and delivery configuration. A request screen does not establish mailbox delivery. | | Booking list is empty | Clear filters, compare customer identity and check whether account data failed to load. Avoid creating a duplicate customer. | | Appointment shows an unexpected date | Compare with the staff reservation’s actual start/end and timezone; do not rely on creation date. | | Projects selected but no Projects link | The current default navigation omits it in source. Use a tested custom implementation when needed. | | Customer upload is not on a staff ticket | Files and ticket attachments are separate. Establish the staff retrieval path explicitly. | | A hidden section still has a reachable URL | Menu settings are not access permissions. Check customer ownership on the server. | ## What happened behind the scenes
Developer notes and source locations The site stores the selected layout and feature keys under `data.myAccount`. Empty features means all navigation entries are considered visible. The account layout loads the site settings, customer account data and any chosen wrapper. The dynamic account route chooses a component by slug; menu visibility is not a route permission check. Customer data combines several service calls. The inspected `getAllClientData` uses `Promise.all`, so a failure in one participating module can affect account loading more broadly. File operations use a `client-account//` namespace. Each record type still needs its own ownership and response test; a wishlist ownership check does not certify tickets, files or reservations. Source relative to the projects directory: - `websitemint/packages/ui/src/components/welcome-screens/buttons/client-portal-drawer.tsx` - `websitemint/packages/ui/src/components/crm/contacts/customer-form.tsx` - `base-app/src/app/(auth)/register/register-content.tsx`, `login/login-content.tsx` and `actions.ts` - `base-app/src/app/account/layout.tsx` and `[slug]/page.tsx` - `base-app/src/components/my-account/links.ts`, `Reservations.tsx`, `Files.tsx`, `Messages.tsx`, `Profile.tsx`, `HelpCenter.tsx` - `base-app/src/components/my-account/tickets/tickets-list.tsx` - `appengine/src/client-account/client-account.controller.ts` and `client-account.service.ts` The API now includes `PUT /client-data/profile`, but the inspected portal Profile component does not call a save endpoint. Backend availability alone does not establish the website button’s behaviour.
## Where next Continue with [staff roles and permissions](appmint-roles-groups-and-permissions.md) to give colleagues the access needed to handle client work. For a custom frontend using customer records, follow [Build a connected web or mobile client](appengine-build-a-connected-web-or-mobile-client.md). **Evidence:** saved portal configuration, controlled customer sign-in, reservation readback, private file upload and owner DAM readback, ticket create/reload, Daniel isolation, owner reply, sign-out redirect, and desktop/mobile chat/sidebar checks are recorded in [portal reservation/display evidence](../application-fixes/portal-reservation-display.md), [portal files](../application-fixes/portal-files-local.md), [portal ticket ownership](../application-fixes/portal-ticket-ownership.md), and [chat CSS isolation](../application-fixes/chat-css-isolation.md). The custom wrapper remains an advanced extension outside this course. --- # Prepare team access and check what a colleague can actually do > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-roles-groups-and-permissions.html # Prepare team access and check what a colleague can actually do ![Sales grants the LeadEditor role and shows its saved description.](../application-fixes/assets/local-roles/14-group-description-fixed.png) **Who:** owners and administrators onboarding colleagues. **Time:** 45–60 minutes, plus a separate colleague test. **Level:** beginner configuration, then permission checks. **Product:** Appmint Studio Manager. **Checked:** local Studio 0.6.2, 19 September 2026. **Example:** Copper Kettle, owner Ada Okafor and colleagues Nora Ellis and Maya Ito. **Verification:** role/group creation, invitation acceptance, allowed operations, visible Delete refusal, approval and fresh-session revocation have passed locally. Account lock/unlock, fresh sign-in refusal, existing-session refusal and local socket disconnection/recovery have also passed. Locked-user list status, active counts, reload persistence and recovered CRM access have also passed. All required course actions are locally verified. ## What you will have at the end You will understand the difference between permission verbs, visible screens and group membership; create an explicit role definition; save two job groups; invite a colleague into Sales; and know how to test a grant, a refusal and a later change. You will also learn why an administrator’s broad access is unsuitable for testing a colleague’s restrictions, why a saved group card can disagree with the user record, and how to report the exact failed action without repeatedly resubmitting it. Configuration, membership addition/removal and Nora’s actual invitation acceptance have been recorded. A restricted session opens Leads. An allowed save, direct-address refusals, an approval decision and a fresh-session access revocation have also passed. The repaired server and browser now refuse Delete and keep the practice record visible. ## What you need - Your owner account from [Welcome to Appmint](appmint-welcome.md). Keep that account in its normal browser. - A second browser profile and an accessible email inbox controlled by the colleague or your test team. - A harmless enquiry record from [the CRM course](appmint-turn-an-enquiry-into-a-project.md) for a later edit test. - A short description of the job. In this example: “Follow up enquiries; read, create and update lead records; do not administer the team.” The primary screenshots below use **0.6.2**. The earlier 17 September production rehearsal used **0.6.1**, with older forms. The differences are explained near the relevant steps; do not combine the two interfaces into one click sequence. ## The story Ada wants Nora to work the enquiry pipeline. An owner login would also let Nora administer unrelated parts of the company. Ada defines the job, puts that definition in a Sales group and tests Nora’s own account before relying on the access. ```mermaid flowchart LR A[Job: follow up enquiries] --> B[Role: actions and screens] B --> C[Group: Sales] C --> D[Named colleague] D --> E[Test permitted work] D --> F[Test refused work] E --> G[Recheck after any access change] F --> G ``` There are three different questions to answer: | Question | Control | Example | | --- | --- | --- | | What actions are intended? | Role content/component permissions | Read, Create, Update | | Which screens should be offered? | Role Menu access | CRM → Leads | | Who holds that job? | Group membership | Maya belongs to Sales | A menu restriction is not a per-collection security rule. A role name such as LeadEditor is only a name; it does not automatically scope every server request to lead records. ## Part 1 — Define the role before adding people ### 1. Open Role & Permission Expand the chevron beside **Configuration**, then select **Role & Permission**. Selecting the group name itself opens the Configuration dashboard; the chevron exposes its child links. The **Role & Permission Manager** lists system roles and custom roles. Select **New Role**. ![The current custom role form.](../application-fixes/assets/local-roles/01-role-before-menu.png) If the sidebar is reduced to icons, select its **Expand sidebar** control first. The course’s local session initially used a narrow window and this collapsed view; it was not a missing Configuration module. ### 2. Give the role a stable name and a useful description Enter: | Field | Value | | --- | --- | | Name | `LeadEditor` | | Description | `Enquiry team: read, create and update records; Leads screen only.` | The name is an identifier. Use letters, numbers, hyphens or underscores, and avoid spaces. The description tells a future administrator why the role exists. Changing the description is easier than discovering later that two similarly named roles mean different things. ### 3. Select the record actions Under **What this role can do with records**, select **Read**, **Create** and **Update**. Leave **Delete**, **Review** and **Approve** unselected. - **Read** supports opening records. - **Create** supports adding a new enquiry. - **Update** supports correcting or progressing an existing enquiry. Leave **What this role can do on pages** clear for this example. Page component actions are a different responsibility from following up an enquiry. **You should see:** **3 of 6 granted** for record actions. The permission verbs and visible screens are separate controls; choose both deliberately. ### 4. Select the Leads screen explicitly In **Menu access**, keep the **Appmint** tab selected. Find the **CRM** section and select **Leads**, whose displayed route is `/leads`. Some section headings and common dashboards remain visible in a narrow colleague session. A heading is not proof that the colleague can open every destination beneath it; test the actual destination in Part 4. Do not select the whole CRM group when the intention is one screen. The panel also has a **Business Made** tab because a role record can carry menus for both applications. Leave BusinessMade choices out of this Appmint example unless the job needs them. ![Role settings with explicit menu choices.](../application-fixes/assets/local-roles/02-role-ready.png) > **Three different menu states:** **Allow everything** means unrestricted menus; selecting specific entries creates an allow-list; **No access** is the explicit no-screen choice. Clearing every selected link is not a reliable way to mean “deny everything”. Read the panel’s summary before saving. ### 5. Save the role, then reopen it Select **Create role** at the bottom. After saving, reload the page and select **LEADEDITOR** under **CUSTOM ROLES**. Read back its name, record actions and menu selection. ![The custom role reopened after reload.](../application-fixes/assets/local-roles/03-role-reloaded.png) **You should see:** an editable custom role with the saved choices. Reopening verifies the saved definition before you connect it to a group. **Historical interface reference:** 0.6.1 opened a floating **User Role** form with **Name**, **Description**, **Type**, **Content**, **Component**, **Menu Include** and **Menu Exclude**. Its menu options were older categories such as **CRM**, not the current `/leads` entry. The production rehearsal saved read/create/update with CRM, then reopened it. That broader category is not equivalent to a Leads-only menu. ![Production 0.6.1’s older role form.](assets/appmint-roles/04-role-ready.png) Use the instructions matching your build. If precise menu selection is unavailable, do not silently substitute a broader category while telling the colleague that only Leads is exposed. **Try it:** explain the difference between Update and Delete using a real task: correcting a phone number versus removing a lead record. **Check yourself:** if the role says LeadEditor but its menus include every CRM screen, is it Leads-only? No. Read the stored actions and menu entries, not the name. ## Part 2 — Put the job in a group ### 1. Create Sales Open **Configuration → User, Group**. Select **Groups** in the page header to reach **User Groups**, then **Create Group**. In **New group**, enter: | Field | Value | | --- | --- | | Name | `Sales` | | Description | `Copper Kettle enquiry team` | | Password policy | `Org default` | Expand **Roles this group grants** if necessary and select **LeadEditor** only. Leave **Members** empty for now. Select **Create group**. ![The group’s identity and selected role.](../application-fixes/assets/local-roles/04-sales-ready.png) Do not add Owner, ConfigAdmin or another broad role to make a failing custom role “work”. That changes the access you are trying to establish. ### 2. Reload and inspect the group Reload Studio, return to **Groups** and locate **Sales**. The card should show **Roles 1**, **LeadEditor**, and no members. ![Sales and its saved role after reload.](../application-fixes/assets/local-roles/14-group-description-fixed.png) The current card shows the description you saved. If a card looks out of date, reopen the group and inspect it before creating another. Once created, the current editor disables the group name and explains **Users are linked by this name — it cannot change**. Choose a durable job name such as Sales rather than a person’s name. ### 3. Prepare a separate website group Create a second empty group: - **Name:** `Website Editors` - **Description:** `Copper Kettle website editing team` - **Roles this group grants:** **Publisher** - **Password policy:** **Org default** Save and reload. The card should show Publisher and zero members. ![Website Editors saved as a separate empty group.](../application-fixes/assets/local-roles/07-two-job-groups.png) **Publisher is broad.** The installed permission resolver gives it read, create, update, review and delete actions. Its built-in menu coverage extends beyond a single website page. This group illustrates a distinct responsibility; it is not a narrowly scoped “may edit one homepage” grant. Review that scope before assigning a colleague. **Historical interface difference:** the older Create/Edit Group dialog showed name, description, colour and icon, without this role picker. Its separate **Permissions** action offered direct verb checkboxes. Do not treat that older panel as proof that the current server has linked the intended role to the group. ### 4. Add an existing colleague On **Sales**, select **Add User**. **Edit Sales** opens with **Members** and **Add people**. Search for the existing user by name or email, select their checkbox, then select **Add 1**. The person is now staged in Members; select **Save group** to persist it. If a newly created colleague is missing from the picker, close the editor and use User Management’s **Refresh**, then reopen the group. Do not create another account to make it appear. ![An existing account staged in Sales before Save group.](../application-fixes/assets/local-roles/09-owner-membership-staged.png) Reload, return to Groups, and reopen Sales. Confirm the person appears in Members and LeadEditor remains selected. The local membership rehearsal used Ada’s own test account and then removed it; this checked persistence, not restrictions, because Ada retained Owner access. ![The membership remains after reload.](../application-fixes/assets/local-roles/10-owner-membership-reloaded.png) ### 5. Remove a rehearsal membership In **Edit Sales → Members**, use **Remove from group** beside the person, then **Save group**. Reload and inspect the group again. The verified rehearsal returned Sales to zero members while retaining LeadEditor. ![Sales after removing the rehearsal member and reloading.](../application-fixes/assets/local-roles/11-owner-removed-reloaded.png) Remove only the membership you intend to change. A person may hold other groups and direct roles; removing Sales does not cancel those separate grants. An entry marked **via** a role is an inferred membership, not the same as someone explicitly added to the group. For a new colleague who does not yet have an account, continue with the invitation below instead of using Add User. **Try it:** inspect an empty group and an administrator group without changing either. Identify the role and why each listed person belongs. **Check yourself:** did Ada’s successful membership save prove Nora will be restricted? No. Ada retained her administrator grants; Nora needs her own session. ## Part 3 — Invite a colleague into the job you prepared ### 1. Enter the colleague’s identity and select the job group Open **Configuration → User, Group → Invites → Send Invitation**. In **Send Team Invitation**, enter the person’s **First Name**, **Last Name** and **Email Address**. Use the address the colleague will use to sign in. The successful local rehearsal uses **Nora Ellis** in Copper Kettle, with the existing **Sales** group granting **LeadEditor**. Nora is a separate test colleague from Maya Ito, whose earlier invitation exposed the repaired application defect. Under **Assign to Groups**, select **Sales**. Clear **User** if selected. This example needs Sales alone: adding another group combines its grants with Sales and changes the restriction you are testing. Leave Website Editors and administrator groups unselected. Enter a short **Personal Message**, such as “Join the enquiry team. You will follow up incoming enquiries.” Keep **Send welcome email with login instructions** selected. ![Nora’s invitation with Sales selected and the other groups clear.](../application-fixes/assets/local-roles/22-nora-sales-only-invitation.png) The message explains the job; the selected group grants access. Check both before continuing. ### 2. Send once and inspect the pending invitation Select **Send Invitation** at the bottom of the drawer. The drawer closes and **Invites → Pending** shows the colleague, Sales, inviter, sent date and expiry. The verified invitation appeared immediately without reloading. ![The sent invitation appears in Pending with its group and dates.](../application-fixes/assets/local-roles/23-invitation-immediate-refreshed-date.png) If a response is slow, inspect Pending before sending again. Email delivery may be queued. In this local rehearsal, mail was captured by the organisation’s test email service; no external colleague was contacted. The row’s three-dot menu includes **Copy Link**, **Resend Invitation**, **View Details** and **Cancel Invitation**. **Copy Link** copies this invitation’s actual acceptance link. It is useful when handing the invitation directly to the intended colleague. Treat that link as private; do not paste it into documentation or a public chat. Use **Resend Invitation** only after checking the address and delivery status. ### 3. Complete the invitation in the colleague’s own browser Keep your owner session open. In a separate browser profile, have the colleague open their actual email link or the link copied from their invitation row. The page is **Finish signing up** and names the inviter and organisation, followed by the personal message. Check that the organisation is correct. The form shows **Email**, **First name**, **Last name**, **Password** and **Confirm password**. Enter the colleague’s first and last name, choose their password, and repeat that same password in Confirm password. They should set their own password; the owner does not need to know it. ![The invitation completion form, with the password fields masked in this capture.](../application-fixes/assets/local-roles/24-nora-acceptance-ready.png) Select **Create my account** once. In the verified flow, Studio opened automatically under Nora’s identity. Its welcome screen showed **LeadEditor**, inherited from Sales, without the additional User role. ![Nora’s own Studio session after accepting the Sales invitation.](../application-fixes/assets/local-roles/25-nora-inherited-role-after-signup.png) The first visit also starts the **44-stop interface tour**. Use **Next** and **Back** if the colleague needs that introduction, or **End** to continue this focused permissions exercise. The tour is an introduction; it does not change the colleague’s grants. Expand the sidebar if it shows icons. Expand **CRM**, then select **Leads**. Nora reached the Lead Command Center successfully. An empty **Unassigned Leads** board does not mean the organisation has no leads: use **Show Pipelines** to display the pipeline containing your training enquiry. ![Leads opens under Nora’s own account. The business pipeline is available beside Unassigned Leads.](../application-fixes/assets/local-roles/26-nora-leads-access.png) **Try it:** compare the identity in the lower-left sidebar with the owner’s separate browser. Then identify which group grants the job and which role supplies its actions. Do not use the owner’s broad session to judge the colleague’s restrictions. **Check yourself:** does opening Leads prove that Update and Delete are correctly enforced? No. The next part tests an actual saved change and a refused action. ## Part 4 — Test the grant before relying on it ### 1. Create a disposable practice lead Use the colleague's account while it holds **Sales / LeadEditor only**. The website grant in Part 5 includes Publisher and therefore changes what Delete should do; complete this narrow-role check before granting it, or after removing it and signing in again. Open **CRM → Leads**. Under **Show Pipelines**, turn on the pipeline you will use. The rehearsal reused **Cedar & Form — Design Enquiries**, the training pipeline from the CRM walkthrough. Use the equivalent pipeline in your organisation; its name is not a permission setting. Select **Add Lead**. Enter: | Field | Practice value and purpose | | --- | --- | | First Name | `Permissions` | | Last Name | `Verified` | | Email | An address reserved for your controlled training record | | Title/Position | `Training record` | | Pipeline | Your training enquiry pipeline | | Stage | `New` | | Deal Value | `0`, so the practice record adds no sales value | | Notes | `Disposable permission check. Do not contact.` | Select **Create Lead** once. If you are still looking at Unassigned Leads, enable the selected pipeline under Show Pipelines; a lead saved to that pipeline will not appear in Unassigned Leads. Confirm the name before opening it. Do not use a real prospect for the deletion test below. ### 2. Save a permitted change and read it back Open the practice lead, select **Edit**, and change **Title/Position** to `Update verified`. Select **Update Lead**. The detail view should show the new title immediately. Close it, reload, show the same pipeline and reopen the lead. Read the title again. This tests Create and Update as real server operations, not just visible buttons. ![The updated title appears immediately in the repaired detail view.](../application-fixes/assets/local-roles/50-repaired-immediate-edit-readback.png) The earlier rehearsal also changed Maya Bennett's existing title, reloaded to verify it, then restored the original. The disposable record is a cleaner choice for your own exercise because it keeps the entire permission test separate from customer work. ### 3. Verify that Delete is refused On the **disposable practice lead only**, select **Delete**, then read the confirmation. Confirm Delete to exercise the actual server restriction. Under the tested Sales/LeadEditor role, the server returned **403 Forbidden**. The confirmation remained open with **Forbidden** visible, and the lead remained in both the detail view and pipeline. Select **Cancel**, reload, and verify the practice lead still exists. ![The actual Delete refusal keeps the record visible and shows the server error.](../application-fixes/assets/local-roles/55-delete-refusal-visible-and-record-retained.png) A visible Delete button does not mean the role has Delete permission. Conversely, hiding a button alone would not prove enforcement. This check tests the operation itself. The original rehearsal found a missing server check and a misleading browser removal; both were repaired and this sequence was repeated. If the practice record is deleted, stop the restriction test and report the organisation, role, record identifier and action through **Support → Submit Ticket**. Do not test again on a real customer. Check whether the colleague has another role or group that grants Delete before concluding that the intended restriction is in effect. ### 4. Try an unrelated screen directly As owner, open **Configuration → Role & Permission** and copy that page's address. Open the copied address in the colleague's browser. The tested colleague received **Not available for your role**, naming `/role-permission`. ![Nora cannot open role administration through a direct address.](../application-fixes/assets/local-roles/38-nora-direct-role-admin-refused.png) This checks the screen gate separately from the lead operation. A developer can retain the request statuses alongside the UI evidence: the repaired rehearsal returned **201** for Create, **200** for Update and **403** for Delete. The [sanitized operation evidence](../application-fixes/assets/local-roles/repaired-lead-operation-responses.json) contains no credentials. ### 5. Clean up through the owner After recording the refusal, have the owner remove the disposable practice lead. Confirm its exact name and email before Delete. The owner may delete it because that account has the corresponding permission; the colleague's refusal is still a valid result. Keep real enquiries unchanged. If you used a reversible existing-record edit, restore the original value and read it back. **Check yourself:** why test a direct URL, a saved edit and a refused Delete? They test different boundaries: a screen, a permitted operation and a forbidden operation. ## Part 5 — Handle requests and ongoing access ### Ask for the task you need, then review the actual grant First, as owner, open **Build Studio**, select the training website and choose **New Web Page**. Copy the editor address from the browser and share it privately with the colleague. This permission exercise needs no page content or Save action; use an existing training site from [the website course](appmint-build-a-business-website.md). As the colleague, open that website editor address. Under the Leads-only role, the page displays **Not available for your role** and names `/build-studio`. The same account also received a refusal when opening the owner's **Role & Permission** address directly. ![The actual website-editor refusal in Nora's Leads-only session.](../application-fixes/assets/local-roles/39-nora-website-editor-refused.png) In **Ask for access**, enter the work you need to do. For this exercise: “I need to edit our website pages for the enquiry team. Please grant Website Editors for this training exercise.” Select **Request access** once. The page confirms **Request sent** and names the person it is waiting on. Keep your current access while the request is reviewed; sending a request does not grant it. ![The submitted request names the owner who must review it.](../application-fixes/assets/local-roles/41-website-access-request-result.png) In the owner's separate browser, expand **App Root → Approvals**. Open **Waiting on you**, then **Access to /build-studio**. Read the requester, reason and screen. **Grant through** offers roles and groups that can provide access to that screen. Choose **Website Editors**, the group you prepared earlier. Review its Publisher scope before approving: Publisher includes Delete and more than one page. This is suitable only if the colleague's website responsibilities justify that wider grant. Do not choose it as a shortcut to solve a Configuration administration request. ![The owner selects Website Editors for the website request.](../application-fixes/assets/local-roles/43-owner-website-group-approval-ready.png) Select **Approve**. **Decided** now lists the website request as **approved**. The page also offers **Decline**, **Reassign** and **Escalate**; those are distinct decisions, not required steps in granting this request. ![The website request in the owner's Decided list.](../application-fixes/assets/local-roles/44-website-request-approved.png) Have the colleague sign in again in a fresh session. Nora's new session showed **Sales** and **Website Editors**, with effective roles **LeadEditor** and **Publisher**. Opening the same website editor address then displayed the canvas instead of the refusal. ![The same website editor opens after approval and fresh sign-in.](../application-fixes/assets/local-roles/46-website-editor-after-approval.png) No website content was saved for this permission test. The check was that the requested editor became accessible through the selected group. Page creation and publishing are covered by the website course. ### Take back the extra group and verify the result As owner, return to **Configuration → User, Group → Groups**. On **Website Editors**, select **Add User** to open its member editor. Under **Members**, find the colleague and choose **Remove from group**, then **Save group**. Reload and check the Website Editors card. In this exercise it returned to zero members, retaining Publisher. Sales still contained Nora and Maya; their enquiry job was not removed. ![Website Editors after the temporary membership was removed and the page reloaded.](../application-fixes/assets/local-roles/48-website-membership-revoked.png) Have the colleague sign in again. Nora's effective access returned to **Sales / LeadEditor**, and the same website editor URL again displayed **Not available for your role**. ![The fresh session refuses the website editor after the group is removed.](../application-fixes/assets/local-roles/49-fresh-session-website-revoked.png) This verifies the changed grant in a new session. It does not prove immediate invalidation of every previously issued token. Review active sessions separately when an access change must take effect immediately. ### Distinguish direct roles from inherited roles In **User Management → Users**, open the colleague's row menu and select **View Profile**. **Access & Permissions** separates **Groups** from **Direct roles**. A person can have no direct roles and still inherit LeadEditor from Sales. ![Nora belongs to Sales and has no direct roles assigned.](../application-fixes/assets/local-roles/27-nora-owner-profile-raw-grants.png) For an unwanted direct role, use **Remove** beside that role. Read **Remove direct role?** carefully: it identifies both the role and the person and explains that group access remains unchanged. Select **Remove role** to confirm, then reload and reopen the profile. ![Removing Maya Ito's old direct User grant leaves her group membership separate.](../application-fixes/assets/local-roles/29-maya-direct-role-confirmation.png) The repaired local flow removed Maya Ito's historical direct User role and kept Sales. After reload, her profile showed Sales and **No direct roles**. It did not convert inherited LeadEditor into a permanent direct role. Maya then signed in again: the new session inherited LeadEditor only and opened Leads successfully. ![Maya's profile after removal and a complete reload.](../application-fixes/assets/local-roles/31-maya-removal-reloaded.png) Use this control for a direct grant you intend to remove. To change an inherited grant, edit the group or its membership instead. Protected administrator roles are not offered for removal by this ordinary profile control. Return to **Configuration → Role & Permission** and inspect LeadEditor’s holder summary. It counts people with direct assignments and people inheriting the role from a group. In this example, it shows **2 people**: Maya Ito and Nora Ellis. Reload and check again. A person who holds the role both ways should still count once. ![LeadEditor correctly lists the two colleagues who inherit it through Sales after reload.](../application-fixes/assets/local-roles/64-role-holders-after-reload.png) ### Recognise devices and access cards In **User Management → Devices**, compare the device, person, status and recent activity before using any trust or block action. The local page showed eight devices, including recognised Chrome sessions for Nora and Maya. Compare the user, status and last-seen information; a browser session is not automatically a trusted device. No trust or block setting was changed. ![Device Management and its status filters.](../application-fixes/assets/local-roles/57-device-management-readonly.png) Use **Cards** to open **Access Cards**. The page identifies these as NFC staff badges for quick POS sign-in and offers Set passcode, Issue card and status filters. The training organisation had no cards. Issuance and revocation affect a physical login route; use the employee/device course when setting them up. No card was issued or written in this permissions lesson. ![Access Cards entry.](../application-fixes/assets/local-roles/58-access-cards-readonly.png) ### Keep password policy separate from job permissions Open **Configuration → Password Policy** to inspect the available policies. The current screen is a data table, including the label **MIN LENGHT**. The training company had no custom policy records. ![The PasswordPolicy table.](../application-fixes/assets/local-roles/59-password-policy-readonly.png) A group’s **Password policy** selector controls which policy it references; it does not add CRM or website permissions. Leave **Org default** while learning the access model, then define company requirements deliberately. ### Lock a colleague’s account and verify the result Use a controlled colleague for this rehearsal. Keep the owner signed in separately so you can restore the account. Removing Sales or hiding a menu changes permissions; it does not by itself stop sign-in. 1. As owner, open **Configuration → User, Group → Users**. On the colleague’s row, open the menu, choose **View Profile**, then **Security**. 2. Read **Account status**. An unlocked account shows **Active — can sign in normally** and **Lock account**. 3. Select **Lock account**. The confirmation names the colleague’s email. Check it before confirming; do not lock your administrator account or an unrelated colleague. 4. Confirm **Lock account** once. Wait for **Locked out — cannot sign in** and the replacement **Unlock account** control. Return to Users: the colleague’s row should say **Locked** and the active count should fall by one. Reload to confirm the saved state. In the rehearsal, total users stayed five and active users changed from five to four. 5. In a separate colleague browser, try normal password sign-in. The tested account received **Account is locked. Contact your organization owner.** Existing authenticated HTTP requests also returned403. Live chat disconnected. Separate live checks verified that device-event and workspace connections disconnected and refused reconnection while locked. ![The owner confirms the named colleague before locking.](../application-fixes/assets/local-roles/68-nora-lock-confirmation.png) ![A fresh normal password sign-in is refused while the account is locked.](../application-fixes/assets/local-roles/72-locked-password-signin-refused.png) For this rehearsal, restore the colleague: return to their **Security** tab, select **Unlock account**, check the email in the confirmation and confirm. Wait for Active again, then have the colleague sign in normally. Nora’s recovered session retained **Sales / LeadEditor**; the lock did not replace her groups or roles. An account’s saved data also remains in place. ![The locked colleague and corrected active count persist after reload.](../application-fixes/assets/local-roles/81-locked-status-survives-reload.png) ![Unlock restores the colleague and active count.](../application-fixes/assets/local-roles/82-restored-active-user-counts.png) After signing in again, open **CRM → Leads**. Use **Show Pipelines** to include the existing practice pipeline, or choose **Lead Manager** to read the saved cards. Confirm the original enquiries remain. If a connection failure shows **Could not load leads and pipelines**, restore the connection and select the top **Refresh** button. Do not create a replacement pipeline because data failed to load. ![The recovered colleague can read the original enquiries, including Maya’s restored title.](../application-fixes/assets/local-roles/89-recovered-lead-manager-records.png) **Phone access needs its own follow-through.** This control does not revoke a previously issued phone-provider token or end an ongoing call. Do not treat it as confirmation that every phone endpoint has stopped. Review the employee’s phone assignment and registration in [employee phones and mobile CRM](appmint-mobile-employee-phones-and-crm.md); that course’s provider/handset verification remains separate. Likewise, staff account access and customer website accounts are distinct identities. For an actual departure, inspect the person’s job groups, direct grants and physical access routes as well as locking the account. Keep the rehearsal account unlocked afterwards unless you intentionally mean to suspend it. For API work, use [the AppEngine client course](appengine-build-a-connected-web-or-mobile-client.md). Do not assume a key’s displayed scope chips provide stricter enforcement than its creator’s effective permissions. ## If something goes wrong | Symptom | Check and next action | | --- | --- | | Sidebar shows only icons | Use Expand sidebar; then expand Configuration with its chevron. | | Role form looks like a generic data editor | You may be on production 0.6.1. Do not substitute its broad CRM category for the current Leads path. | | A role shows every menu | Inspect Allow everything versus explicit entries. Use No access for a deliberate no-screen role. | | New group is missing immediately after create | Reload and search by name before creating another. | | Group card appears out of date | Reopen Edit and check the saved description. Refresh before creating another group. | | Membership save reports an error | Reload the group and the person’s profile. Report the group, colleague and exact error through Support → Submit Ticket; do not repeatedly resubmit or assume the displayed avatar proves a working grant. | | Custom role fails during sign-in | Check the saved role and group names. Report the role, organisation and exact error through Support → Submit Ticket; do not add an administrator grant as a workaround. | | Owner still sees everything | Owner is an all-access role; use a separate colleague session for restriction tests. | | Changed access behaves inconsistently | Use a fresh sign-in, then inspect every applicable group/role and the actual server operation. | | Leads cannot load | Read the error, restore the connection and use the top Refresh button. Previously loaded data may be stale; an unavailable result is not an empty organisation. | | No invitation arrives | Check the exact recipient and pending record. Do not repeatedly resend or expose the invitation token in a report. | | Approval did not enable the requested task | Check whether the chosen group actually grants that task, then verify the colleague’s session. | ## What happened behind the scenes
Developer evidence and implementation pointers The role editor saves `userrole.data.permissions`, including record/component verbs and menu paths. Current AppEngine resolves custom role definitions within the organisation and combines direct and inherited grants. Group membership changes use field-scoped user updates. Invitation acceptance validates its stored organisation, groups and roles before creating the account. A Sales-only invitation creates explicit Sales membership with no default direct User role. Authentication enriches the session with inherited LeadEditor but does not persist that inherited role back as a direct grant. Direct-role changes update only the stored roles field and verify readback. The menu layer and server action guards are separate. A visible screen is not proof of permission to perform every operation it contains. The live Delete defect and its verified repair demonstrate why both sides need testing. Relevant source paths relative to projects: - `websitemint/packages/ui/src/components/configuration/role-permission/role-form.tsx`, `menu-access-panel.tsx`, `use-allowed-menu.ts` - `websitemint/packages/ui/src/components/user-management/group-editor.tsx`, `modern-invitations.tsx`, `user-management-store.ts` - `websitemint/packages/ui/src/components/approval/request-access.tsx` - `appengine/src/users/users.service.ts` and `users/auth/jwt.auth.guard.ts` - Installed `appengine/node_modules/@jaclight/dbsdk/src/types/role-type.ts` - `appengine/src/approval/approval.service.ts` Application source repairs and local verification are recorded in [permissions](../application-fixes/permissions.md), [invitation grants](../application-fixes/invitation-grants.md), [direct-role changes](../application-fixes/direct-role-removal.md), [group/invitation feedback](../application-fixes/roles-invitation-ui.md) and [lead operation permissions](../application-fixes/lead-action-permissions.md). Each report distinguishes completed browser evidence from checks still pending.
## Where next Continue with [Appmint Mobile daily work](appmint-mobile-welcome-and-daily-work.md) and [employee phones and mobile CRM](appmint-mobile-employee-phones-and-crm.md). For the HR side of the same person, use [BusinessMade employee onboarding](businessmade-hire-and-support-an-employee.md). **Evidence:** current local Studio 0.6.2, own Copper Kettle organisation `ck-local-mu83iwh3`, Ada Okafor, Nora Ellis and Maya Ito. Actual role/group save/readback, membership addition/removal, invitation acceptance, allowed enquiry update, direct-screen refusals, website approval and fresh-session revocation are captured in [local roles evidence](../application-fixes/assets/local-roles/PROGRESS.md). The original disposable probe exposed a Delete defect; the repaired test now returns403 and keeps the record visible. Earlier rehearsal evidence is preserved in [the historical manuscript](../learner-review-records/appmint-roles-manuscript-before-live-repair-20260919.md). [Companion-video guide](production/appmint-roles-groups-and-permissions.md). --- # Hire an AI employee and supervise its first piece of work > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-hire-and-supervise-ai-employees.html # Hire an AI employee and supervise its first piece of work ![A saved project coordinator, with draft status and the six setup milestones.](../application-fixes/assets/ai-employees-local/draft-created-setup.png) **Who:** business owners, team managers and developers connecting an employee runtime. **Product:** Appmint Studio Manager; BusinessMade provides the same management feature under Admin. **Example:** a project coordinator who prepares a manager's daily review. **Reviewed:** 24 September 2026. You will create the employee, connect its runtime, give it a small launch-checklist review, inspect an attached record and private document, make a human approval decision, and pause it. A saved employee, an online badge and a completed useful task are three different outcomes; this course walks through each one. ## The result you are working towards Your coordinator should identify a missing launch check and explain what the manager needs to verify before going live. Start with a fictional checklist inside the task; extend to a project review only after its access and runtime are proven. It should not change deadlines, message customers or approve its own requests. You will define that job, give the employee appropriate access, inspect its work and learn how to stop it. An **AI employee** has its own user identity, groups, work queue and history. The **AI Assistant** panel helps the person currently using it. Hiring an employee does not mean configuring that assistant panel, and a job description does not grant database permissions. ```mermaid flowchart LR A[Define the job] --> B[Create a draft] B --> C[Choose access] C --> D[Connect its runtime] D --> E[Switch on and verify connection] E --> F[Give one bounded task] F --> G[Review result and approvals] G --> H[Pause and verify] ``` ## Before you begin Use your organisation's Owner or ConfigAdmin account. For the first exercise you need only the management application; leave the employee as a draft while you learn the controls. For the execution exercise you also need a configured worker runtime and a small set of records that your organisation permits it to read. Prepare the access group before activation. Follow [Prepare team access](appmint-roles-groups-and-permissions.md) to understand roles, groups and actual permission checks. Select a group for the coordinator's specific work; do not give it an administrator group simply to make an access error disappear. Decide which person will review results and approvals. A useful starting job description names the inputs, output and limits: > Review the project records assigned for this exercise. Produce a table of project, next action, owner and missing information. Cite each source record. Do not change records, invent a deadline or contact anybody. Ask the manager when the evidence is incomplete. The captures use **Tutorial Project Coordinator** for draft configuration and a separate **Tutorial Manual Lifecycle** employee for isolated execution tests. The latter also ran the real model examples; its name does not mean every result was manual. Captions identify the type of evidence. This kept the original draft inactive throughout the review. For your own exercise, use the employee you created and check its name before each action. The business can use any suitable employee name. The example names and handles below identify this walkthrough; they are not mandatory product values. ## 1. Find the employee management area In Studio Manager, expand **AI, IVR, Automation** and select **AI Employees**. In BusinessMade, expand **Admin** and select **AI employees**. ![BusinessMade's own Admin entry.](../application-fixes/assets/ai-employees-local/businessmade-admin-entry.png) The top-level tabs answer different questions: | Tab | Use it for | | --- | --- | | Employees | Open an employee, hire another and review the team's current state. | | Approvals | Find work waiting for a person's decision. | | All work | Follow queued, in-progress, completed, failed and cancelled jobs across employees. | | Instructions | Write organisation instructions and choose who receives them. | | Templates | Start from an existing role description. | | Settings | Review company context, knowledge, defaults, escalation and the AI team workspace. | Open **Settings** first and read the source labels. **Platform default** means the value is inherited; **Your organization** means this organisation supplies it. Changing shared company context can affect more than the employee you are about to hire. The reviewed setup inherited Session manager, UTC, around-the-clock hours, a $5 daily budget and approval requirements. These are observed defaults, not a required paid Appmint subscription or a promise about a provider's charges. ![Settings showing the configured Session manager provider.](../application-fixes/assets/ai-employees-local/settings-current-runtime.png) ## 2. Choose a role and create an inactive draft Open **Templates**. The reviewed platform offers Receptionist, Sales follow-up, Support triage, Accounts receivable, Bookkeeping assistant and Social media. **Hire** starts an employee from that role; **Copy** is a template action. Read the role before using it: a sales follow-up role may have very different responsibilities from a read-only project review. ![The actual platform role templates.](../application-fixes/assets/ai-employees-local/templates-current-runtime.png) For this exercise, select **New AI employee** instead and enter: | Field | Example and purpose | | --- | --- | | Name | `Tutorial Project Coordinator` — the name people see. | | Job title | `Training project coordinator` — its responsibility at a glance. | | Handle | A new identifier such as `tutorial-coordinator-20260924`. Optional: leaving it blank derives it from the name. | | Reports to | Leave unselected for this inactive exercise. Select the responsible manager for real work. | | Job description | `Fictional training draft for reviewing AI employee setup. Remain inactive. Do not contact customers, send messages, change records, or execute work.` | | Listens to | Leave optional extra listeners unselected while preparing the draft. | | Working hours and timezone | Review the inherited settings. The captured draft used around-the-clock hours and UTC. | | Daily budget | `0` for this inactive exercise. | | Concurrent jobs / attempts | Keep `1` concurrent job and `2` attempts for the draft. | | What needs a person's OK | Keep deletion, bulk messages, money and users/permissions requiring approval. | **Name** is required. The runtime is inherited from configuration; there is no runtime selector in this creation form. Do not confuse leaving extra listeners empty with disabling all event sources: the profile still describes direct messages, mentions and assigned work. ![The completed form before Create.](../application-fixes/assets/ai-employees-local/draft-before-create.png) Select **Create** once. The employee opens on **Connection**. Confirm the name, **Draft**, **Never connected**, and **Not switched on yet**. In **Setup**, **Created here** should be complete; the other milestones should not suddenly be treated as successful. Open **Profile** and check the saved description, budget, hours and approval settings. Reload the page, reopen this employee and verify the values again. In the recorded exercise the same draft persisted, its user was locked and every work count remained zero. ![The saved profile, rather than unsaved form values.](../application-fixes/assets/ai-employees-local/draft-profile.png) **Gotcha: the two money controls mean different things.** **Daily budget** limits the work budget. Under **Money**, **Ask a person first** requests a human decision. Choosing **Let it go ahead** exposes **…but still ask above**; its hint says **0 means never ask**. Do not enter zero there expecting every money action to need approval. Keep **Ask a person first** for this exercise. **Gotcha: a zero budget stops work.** It is useful for an inactive rehearsal. Before a real task, set an authorised nonzero limit appropriate to your runtime. Do not diagnose a zero-budget employee as a broken queue. Keep activation off while configuring it; a written “remain inactive” instruction is not an access control. ### Change its working week and verify the saved result Open **Edit**, find **Working hours & budget**, turn off **Works around the clock** and choose Monday through Friday. For the training example set **From** to **10:00**, **Until** to **16:00** and **Timezone** to **America/Chicago**, keep **Daily budget** at **0**, and select **Save**. Reopen **Profile**: it should show the five days, both times and timezone together. In the local rehearsal these changes persisted while the employee remained Draft. Choose your own business hours for real use; 10:00–16:00 is only this example. ![Saved weekday hours, budget and approval rules on the draft profile.](../application-fixes/assets/ai-employees-local/hours-approval-saved.png) ## 3. Give the employee the access its job needs 1. Open the employee's **Overview**. 2. Find **Access** and select **Change access**. 3. Select the intended existing group after reviewing what its roles allow. 4. Select **Save access**. 5. Reload and confirm the group remains on the employee. The application always keeps the **AI** group. **No groups yet** in this card means there are no additional permission groups, not that the identity failed to be created. The user remains locked until activation. ![Access before any extra group is assigned.](../application-fixes/assets/ai-employees-local/draft-access-locked.png) For a read-only review, use a group whose actual grants match that job. The group name alone does not prove what it permits. Check a permitted read and a forbidden update using the employee's identity before relying on the restriction. Reusing the owner's session cannot establish employee permissions. The local rehearsal selected **Reviewer**, saved it and checked the visible group while the login remained locked. This proves the access setting was saved, not that Reviewer is the right group for every business task. ![Saved Reviewer access with the login still locked.](../application-fixes/assets/ai-employees-local/access-reviewer-saved.png) To remove an accidental selection, reopen **Change access**, clear that group's checkbox and **Save access**. Reopen the card to verify removal. Removing an extra group should not remove the mandatory AI identity or delete the employee's history. ## 4. Tell this employee how to work The job description describes its role. **Instructions** add operating rules; **Settings** supplies company context. Use the smallest scope that matches the instruction. 1. Open the top-level **Instructions** tab. 2. Read **From the platform** before adding a conflicting rule. 3. Select **Add instruction** and enter a descriptive title, such as `Project review — training only`. 4. Enter the review brief below. 5. Choose **Only these** and select this coordinator. Leaving **All AI employees** selected would broaden the instruction to the whole team. 6. Select **Save** in the unsaved-changes bar. 7. Reload and confirm its content, enabled state and selected employee. > Use only the project records attached to the task. For each one, report the recorded owner, next step and any missing date. Label missing information explicitly. Do not infer a promised completion date. Return the table for the manager to review; do not send it to customers or edit the records. ![Current Instructions includes inherited platform guidance.](../application-fixes/assets/ai-employees-local/instructions-current-runtime.png) ![A scoped instruction before Save, with the selected employee and unsaved state visible.](../application-fixes/assets/ai-employees-local/instruction-scoped-before-save.png) **Only these** with nobody selected is not an instruction for everyone. Check the selected employee chips. Editing a card is also not the same as saving it: wait for the unsaved state to clear, then reopen. Inside an employee, **Instructions** shows what it receives. The saved training instruction appeared under **Your organization**, after the platform guidance and before **Just Tutorial Project Coordinator**, which contains the job description. Check this effective view after editing the shared list. ![The effective instruction layers after saving the employee-scoped rule.](../application-fixes/assets/ai-employees-local/instruction-effective-for-draft.png) **Notes** is its own retained working memory; use a task or conversation to direct it rather than assuming a note you create elsewhere will become an instruction. ## 5. Connect the system that runs it This chapter describes the current connection controls. Provider setup must be verified for the employee you intend to run. The later exercises record real bounded model runs. Studio **Provision** and **Check provider** have also been exercised against an isolated Session manager. Open **Connection** and read the provider before choosing an action: | Provider | What it means | What to check | | --- | --- | --- | | Session manager | A configured service provisions and runs the employee. | Provision succeeds, Check provider reports the agent, then the employee signs in. | | External | Your team runs a compatible worker separately. | Its issued credentials are installed in that worker and it signs in as this employee. | | Stub | A placeholder records simulated behavior. | Do not present a stub result as useful AI work. | For **Session manager**, the developer/operator must configure `AI_EMPLOYEE_SESSION_MANAGER_URL` and `AI_EMPLOYEE_SESSION_MANAGER_KEY` on AppEngine and provide a reachable compatible runtime. Those are server settings, not fields in the New employee form. With the runtime ready, select **Provision**, read the result, then select **Check provider**. If an error appears, resolve it before repeating provisioning: the first attempt may have reached the remote system even if its response was lost. ![Actual managed provider check: the process exists, while the employee is still paused and locked.](../application-fixes/assets/ai-employees-local/managed-provider-checked.png) In the local check, Studio provisioning created the agent on the isolated provider. **Check provider** reported **the agent is there · running**, followed by **Its login is locked — switch it on in AI Employees**. That is a running provider process waiting for an authorised employee sign-in; it does not mean business work has started. **Developer configuration for an unprovisioned employee:** the New/Edit form does not expose a runtime selector. The reviewed owner changed only the execution fixture through `PUT /ai-employees/` with the body below, while it was paused, budget zero, had no open jobs and had no provider external ID. This is distinct from changing shared company defaults. Migrating an already provisioned employee between providers is outside this exercise. ```json { "runtime": { "provider": "session-manager", "model": "sonnet" } } ``` Use your organisation header and owner authentication, and a model supported by your configured provider. The model name above identifies the reviewed runtime. Then reopen **Connection**, verify the provider, select **Provision** and **Check provider** as described above. Normal users whose employee already inherits the intended Session manager do not need this API change. Provisioning can start a runtime process. It is not just another Save button. For the managed Appmint service, ask the team to resolve an unavailable runtime rather than trying to configure server environment variables in Studio. For an **External** runtime, use **Connection → Issue sign-in**. The one-time drawer contains API address, organisation ID, email, password and authenticator secret. Transfer them privately to the worker configuration and select **Done**. Do not include this drawer in screenshots or recordings. **Issue a new sign-in** replaces the old credentials; update the worker when rotating them. The worker uses its own authenticated session, not the owner's bearer token. Developers can consult the [platform runtime contract](../../appmint-docs/platform/ai-employees.md#for-the-system-that-runs-it) for sign-in, hello, briefing, lease, step, approval and completion routes. ## 6. Switch on and verify the connection The inactive draft was deliberately configured not to work. Change those settings before expecting a task to run: 1. Select **Edit** and replace the draft's “Remain inactive” job description with: `Review only the fictional training material supplied in an assigned task. Report missing checks and ask the manager when a decision is required. Do not contact people, change business records or invent completed actions.` 2. Set **Daily budget** to the limit your team authorises. The isolated lifecycle rehearsal used **1**; its separately capped model runner used at most $0.50 per model session. Budget values are limits, not a subscription purchase. 3. Make sure you are inside the employee's saved working hours. For a supervised short rehearsal you can enable **Works around the clock**, then restore your intended schedule after testing. A 10:00–16:00 employee should not be expected to start at 17:00. 4. Keep **Jobs at once** at **1** and the approval categories on **Ask a person first**. Select **Save** and reopen **Profile**. 5. Recheck **Overview → Access** and **Instructions** for this employee, then confirm its runtime is ready. Do not leave the zero-budget, inactive-role instructions from the draft exercise in place and diagnose the resulting idle state as a failure. Select **Switch on**. A refusal saying to give it access first means the additional group requirement has not been satisfied. Return to **Overview → Access**, correct the selection and save it. Activation unlocks the identity; it does not prove the worker has reached AppEngine. Watch **Connection** for the employee's first sign-in/hello and its reported runtime identity. **Never connected** requires investigation of worker sign-in or reachability. Use **Check provider** to distinguish a missing provider agent from an agent that exists but has not signed in. Read the milestones individually: created, given access, provisioned, switched on, signed in and said hello, finished its first job. Do not skip from the first checkmark to assuming the last one. ## 7. Give one bounded piece of work Start with a fictional checklist written directly in the task. This lets you verify the worker and its result before bringing customer records into the exercise. The real local runtime completed this kind of task; it did not merely receive a manually supplied success response. Open **Give work** and enter a title on the first line, followed by the inputs and the requested result: > Review the fictional launch checklist > > This is an isolated software acceptance test. All necessary information is here: Fictional launch checklist: (1) page title is set, (2) contact form submit has not been tested, (3) mobile layout was checked. Identify the one missing check and explain why it matters in one sentence. Do not read any files, APIs, docs, chats or records. Do not change anything or contact anyone. Use ./ae step note to record your reasoning, then ./ae complete with your final answer. No approvals or external action are needed. Keep **Normal priority** and select **Send** once. The drawer also supports Command/Ctrl+Enter; do not press the shortcut repeatedly. If the employee is inactive, the drawer says the work waits in its queue. Queued means the task was saved, not that it ran. ![The real Give work drawer, here demonstrating an inactive training assignment before Send.](../application-fixes/assets/ai-employees-local/give-work-before-send.png) Open the employee's **Work** tab or top-level **All work**, find the title and open it. Follow queued → in progress → done. In **What it did**, inspect reported steps and expand **Details** where present. Read **Result** rather than relying only on the Done badge. The real isolated runtime used the employee's own password/authenticator sign-in, obtained its briefing and leased the task. The model correctly identified the contact-form submit test and explained that an untested form could silently lose visitors' messages. The completed job recorded a model cost of approximately **$0.0351**. That is a reported execution cost from this review, not a paid Appmint plan requirement or a promise about future provider prices. ![Genuine model result, recorded steps and the rounded $0.04 displayed cost.](../application-fixes/assets/ai-employees-local/actual-model-completed.png) The `./ae` commands in this exact tested brief are worker-side reporting commands, not instructions for you to run in Studio or a terminal. The tested worker provides them; a different external worker must implement its own reporting adapter. The record is in [the actual model proof](../application-fixes/assets/ai-employees/actual-model-proof.json). This exercise reviewed supplied facts: it did **not** actually submit a website form. A correct answer must distinguish “the checklist says untested” from “I tested it and it failed.” Check that the result names the missing test, explains its consequence and does not invent an action it never performed. ### Follow the recorded work, including a failed attempt A separate, explicitly manual local rehearsal verified the same job-history controls, human approval and result persistence: ![A completed manual rehearsal, including the human approval and explicitly manual result.](../application-fixes/assets/ai-employees-local/manual-completed-job.png) This capture is labelled manual because it is not model output. Another controlled rehearsal failed once, returned to the queue, and failed finally after the second attempt. Read the error and attempt history before assigning another copy of a failed task; otherwise both an old retry and your new task may run. ### Attach a real record and check that it was actually read The attachment picker selects existing records; it has no Create/Add/New record action. You can use a harmless Task you already own, or ask your developer to prepare the exact fictional fixture below. The reviewed fixture was created through the owner API, then selected through the actual Studio interface. 1. Select **Give work → Records**. 2. Search for and select the **Task** collection. 3. In the record table, search **Fictional launch checklist attachment**. 4. Tick that exact row and select **Attach**. 5. Check the attachment card's title and record ID. Leave **Say what this is for** empty for this particular test, so its answer must come from the record. 6. Enter the brief below. Replace `` with the ID on your own attachment card, not the example screenshot's ID. 7. Select **Send**, open the saved job and compare its result with the Task. > Inspect the attached fictional task > > Read only the exact attached task record using ./ae api GET /repository/get/task/. Report the verification marker in its note and the missing checklist check. Do not change the record or read any other record, file, chat or document. Use ./ae complete with your findings. ![The selected Task attachment before sending the actual job.](../application-fixes/assets/ai-employees-local/attachment-before-send.png) The real model returned **LANTERN-4827** and identified the missing contact-form submit test. That marker existed only in the task's note; it was absent from the instructions and attachment summary. This established an actual record retrieval rather than a plausible answer copied from the prompt. ![The genuine attached-record result with the verification marker and rounded $0.03 cost.](../application-fixes/assets/ai-employees-local/actual-model-attachment-completed.png) The employee's own session also passed an exact-record read and received **403** when attempting the tested update route against this fictional fixture. This verifies the tested identity and operation; it does not mean every group automatically has the same read-only policy. Attaching a record does not grant permissions. **Developer preparation for the same fixture.** Use the authenticated owner request setup from [Connect your own client](appengine-build-a-connected-web-or-mobile-client.md). Send the following to your own AppEngine base address, replacing the header placeholders with your training organisation and owner session: ```http POST /repository/create Content-Type: application/json orgid: Authorization: Bearer ``` ```json { "datatype": "task", "isNew": true, "data": { "name": "ai-attachment-training-your-unique-name", "title": "Fictional launch checklist attachment", "status": "new", "description": "Harmless internal training record for exact-read AI attachment acceptance. Not a customer record.", "note": "Verification marker: LANTERN-4827. Fictional checklist: page title checked; mobile layout checked; contact form submit test is missing. No customer or external operation is involved.", "assignTo": [] } } ``` Use a fresh name, submit once and retain the returned record ID. **isNew: true** is required: the initial local attempt without it was refused. Check the record exists before opening the picker. The empty assignee list keeps this example from assigning work to an unrelated person. For a later business review, attach the appropriate project records and ask for a table of recorded owners, next actions and missing information. Check every row against the originals. If the employee cannot read a record, its result should say so rather than inventing a summary. ### Review a private document A project checklist may arrive as a file rather than a Task record. Use this branch to give the employee that document and check whether its answer actually came from the attachment. Use these exact contents for the first exercise: ```text FICTIONAL TRAINING DOCUMENT — no customer information Verification marker: MAPLE-7392 Launch checks: page title checked; mobile layout checked; contact form submit test is missing. This document is only an application tutorial acceptance fixture. Do not contact anyone or modify any business record. ``` 1. Save the text as **fictional-launch-checklist.txt**. Keep the marker out of the task instructions: you will use it to check that the employee read the file. 2. Open your employee and select **Give work**. Use the composer's direct **Upload** control for this new local file. **From files** opens File Manager to choose an existing asset; that is a different route. 3. Before uploading, select the checkbox labelled **Public** so its label changes to **Private**. This choice controls the uploaded file's visibility; attaching a file to an employee does not automatically make a public upload private. 4. Select **Upload**, choose your text file and wait for its attachment card. Check the filename. Leave the optional comment empty for this example. 5. Enter the following brief, then select **Send** once: > Actual model: inspect a private training document > > Read only the exact attached training document. Report its verification marker and missing launch check. Do not modify anything, contact anyone or read other files/records. Record one note with ./ae step note "Reviewed the private training document", then ./ae complete with findings. ![The actual private upload selected before Send; check Private and the attached filename.](../application-fixes/assets/ai-employees-local/private-upload-before-send.png) If the employee is paused, the job remains **Queued**. Review the budget, hours and access from chapter 6 before switching it on. Once connected and permitted to work, it can acquire the queued job; you do not need to send a duplicate. Open the completed job. Compare the returned marker with the original file, check which launch check it identified, and find **Reviewed the private training document** under **What it did**. That note confirms the worker recorded the requested step; **Done** and the result show how it finished. ![The genuine private-document result, recorded note and rounded $0.04 execution cost.](../application-fixes/assets/ai-employees-local/actual-model-private-file-completed.png) In this run the file contained **MAPLE-7392**, and the missing check was submitting the contact form. The model returned both correctly. Recorded model usage was **$0.035042**, displayed as **$0.04**. That amount is the recorded runtime usage for this example, not a paid Appmint plan requirement or a guaranteed cost for another task. Select **fictional-launch-checklist.txt** under **Attached** to open your original in a new tab and compare the result. Private stored files get a fresh authorised temporary link when opened; do not make the upload public to work around a failed old link. If the file cannot open, return to the job, try its filename again and check your file access. Do not forward the temporary link as a permanent document address. The result is a review of the supplied checklist. It does not mean the employee opened the website or submitted the contact form. A useful next business task would ask it to compare several authorised project checklists and cite each document; approve that wider access and scope before assigning it. ## 8. Handle an approval deliberately When a worker requests a protected action, the job shows **Waiting for your OK** and appears in **Approvals**. Open it and read the action category, summary, amount if relevant, and details. For the tested refusal exercise, use **Give work → Send** with this bounded brief: > Ask before recording a fictional decision > > This isolated training task has no external action. First run ./ae approval other 0 "May I record the fictional launch checklist as approved?" and stop immediately when told WAIT. When the owner responds, respect the decision: if rejected, do not approve anything; use ./ae complete to state the owner rejected the fictional proposal and no action was performed. If approved, record only the fictional decision in your completion text. Do not read records, files, chats or docs, and do not contact anyone. This is the same kind of explicit worker-side command used in the first exercise. Wait for **Needs approval**, open the request and select **Reject**. If you opened the work-details drawer and it shows **Note for it (optional)**, you may explain the decision there. The tested Approvals action did not require a note. Check the saved decision and the worker's subsequent result. ![The genuine model resumes after the owner's rejection and completes with a no-action result.](../application-fixes/assets/ai-employees-local/actual-model-rejection-completed.png) In the real local run, the model asked, stopped while waiting, resumed after the human rejection and completed with: “The owner rejected the fictional proposal to record the launch checklist as approved. I did not approve anything and performed no action.” The UI shows **Done** because it finished handling the task; the proposed approval was still **rejected**. These are different states. For an approved exercise, choose a bounded action your team actually intends and has authorised. Review the permitted scope, add an optional note if the work-details drawer offers it, and select **Approve**. Verify both the decision and the eventual result. Approval by itself does not prove that an action finished, and rejection is a decision about that requested action rather than a command to erase the entire job. The following actual screenshot comes from a **manually driven local worker rehearsal**, not autonomous model output. It shows the real waiting job, action summary, note field, decision buttons and recorded steps. The owner approved this fictional training result through Studio; no customer record or external service was changed. ![Actual approval drawer for an explicitly manual training task.](../application-fixes/assets/ai-employees-local/manual-approval-job-details.png) The employee cannot approve its own request. Keep a separate human owner/manager session for this chapter. Do not run a money-transfer or bulk-message example merely to obtain an approval screenshot. ## 9. Pause, inspect and resume only when ready Select **Pause** on the employee. Return to **Overview → Access** and check the locked-login message. Review queued or in-progress work separately; pausing an employee is different from cancelling a specific job. Open an unfinished job and use **Cancel job** if that job must not be resumed. Reopen it and check its cancelled status. Preserve completed history so another manager can understand the result and decisions. Before **Switch on** resumes the employee, inspect its queue, current instructions, access and remaining budget. Fixing a runtime connection may allow existing queued jobs to start; do not assume a previously unsuccessful task disappeared. ![Paused employee after the local control exercise; the recent connection badge is separate from permission to work.](../application-fixes/assets/ai-employees-local/manual-paused-locked.png) **Gotcha: Paused and Online can appear together.** The connection badge reflects recent contact; it is not the activation state. The local review confirmed that after Pause, both the previous employee token and a fresh sign-in received 403. Likewise, a completed Switched on setup milestone records an earlier setup action rather than proving the employee is currently active. Check the current status and access. ![Setup is complete, but the employee is currently Paused. The milestones retain its history.](../application-fixes/assets/ai-employees-local/setup-complete-paused-fixed.png) For an external runtime, also manage the process where it runs. Locking Appmint access does not undo a third-party action already completed. Verify refused API access and stopped work acquisition with the employee's existing session when testing offboarding. ## Troubleshooting without losing the thread | What you see | What to do next | | --- | --- | | Draft and Never connected after Create | Expected for the inactive first exercise. Continue through access and runtime setup when ready. | | No groups yet | Add the intended extra group; the mandatory AI identity is separate. | | No useful work despite an active employee | Check connection, working hours/timezone, daily budget, queue state and approval requests. | | Session manager configuration error | Have the runtime operator check the two server settings and service reachability. Do not repeatedly recreate the employee. | | Stub result | Read it as simulated behavior; it does not demonstrate an autonomous worker. | | Work waiting for approval | Open the request, review the exact action and decide as the authorised person. | | Instruction changes disappear | Save the unsaved changes and reopen; also check the selected employee scope. | | Permission error reading an attachment | Review actual group grants using the employee's identity. Do not broaden to administrator access by default. | | Employee is paused but the old task still exists | Inspect the job and use Cancel job if it should not continue later. | ## Evidence and companion video The screenshots were captured from the actual local application on 24 September 2026. [Draft and interface review](../application-fixes/ai-employees-local-review.md) records the UI checks. [Genuine model execution](../application-fixes/ai-employees-actual-model.md) records the inline task, attached Task, private document and resumption after human rejection; [manual lifecycle checks](../application-fixes/ai-employees-manual-lifecycle.md) separately cover approval, retries, budget, hours and authentication controls. [Runtime isolation](../application-fixes/ai-employees-isolated-runtime.md) describes the controlled test environment and its limits. These checks cover the bounded examples, not every role template or external business integration. The [companion-video brief](production/appmint-hire-and-supervise-ai-employees.md) supplies the recording sequence; completed videos are not part of this evidence. --- # Build a personalised-product catalogue, with the right name, artwork and price > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-sell-custom-products-online.html # Build a personalised-product catalogue, with the right name, artwork and price ![The saved product’s optional artwork setting and required size choices](../application-fixes/assets/store-personalisation-readback.png) *One catalogue product; a different name and artwork for each customer. The size price belongs to this product, while the reusable field definitions live in Attribute.* **For:** owners and operators selling made-to-order goods. **Allow:** 45–60 minutes for catalogue and shipping setup, followed by a separate customer-order rehearsal. **Level:** beginner setup with advanced fulfilment checks. **Product:** Appmint Studio Manager and the website storefront. **Catalogue setup rechecked:** 21 September 2026, local Studio 0.6.2. Customer-order verification is in progress. **Example:** Tutorial Sign Studio’s `Personalised Welcome Sign`, SKU `SIGN-WELCOME-01`. ## What you will have at the end The first practical result is a saved $45 product with a useful description, three personalisation fields and a default shipping configuration. A 30 cm sign keeps the base price. A 45 cm sign adds $12. Customers supply the name for their sign and may attach artwork. The second result is an order-readiness checklist: how to distinguish catalogue images from customer artwork, check different personalisations in a cart, and reconcile the order, payment, shipment and any return. Those records answer different questions. Saving one of them does not finish all the others. > **Verified walkthrough:** catalogue and shipping setup, customer registration/sign-in, personalised cart lines, saved-cart restoration, correct pricing, and operator access to uploaded artwork have passed locally. Checkout still needs a configured payment sandbox; a completed order, shipment and refund have not been demonstrated. Those remaining steps are explicitly marked below. ## What you need - An organisation and a saved website. Complete [Appmint signup and first setup](appmint-welcome.md), then [Build a business website](appmint-build-a-business-website.md). - Access to **Storefront → Product**, **Attribute** and **Shipping** in Studio Manager. - These two original practice files: [The Okafors crest](assets/appmint-store/artwork/okafor-crest.svg) and [Maya’s floral welcome](assets/appmint-store/artwork/maya-flowers.svg). Download the SVG files themselves. They are sample customer artwork, not screenshots of the product. - For the later buying rehearsal: a customer account and mailbox you control, plus an authorised test configuration for your payment provider. Artwork uploads require a signed-in customer. A Studio administrator’s session is not a substitute for checking the customer experience. You can finish catalogue and flat-rate shipping setup without a carrier account or payment-provider credentials. Use a training organisation while learning. Replace the example product copy, prices and delivery promises with details your business can actually fulfil before selling. ## The story Priya runs Tutorial Sign Studio. The shop sells a welcome sign, but the workbench needs more than a product name: which family, which design and which size? A message buried in an inbox is easy to separate from the purchase. Personalisation fields keep those instructions with the customer’s line item. We will prepare two designs: **The Okafors**, 30 cm, and **Welcome Home Maya**, 45 cm. They share one SKU but must retain different names, files and prices. That is the central test of this course. ## The route ```mermaid flowchart LR A[Product: name, SKU, price] --> B[Attach reusable attributes] B --> C[Configure shipping] C --> D[Check the website and two cart lines] D --> E[Rehearse checkout and fulfilment] E --> F[Reconcile payment and any return] ``` ## Part 1 — Give the customer a clear product to buy ### 1. Open the catalogue Expand **Storefront** in the left sidebar and choose **Product**. This opens **Product Center**. Its top tabs are **Dashboard**, **Products** and **Partner Sync** on the checked build. Choose **Products** to see individual records. On the dashboard, **New Product** takes you to that list; choose **Add Product** on the list to open the actual creation form. ![Product Center, the entry point for the catalogue](assets/appmint-store/01-product-center.png) The distinction matters when the catalogue is empty: reaching the Products tab has not created anything. The creation form has **Product Name**, **SKU** and **Create Product**. ### 2. Name the product and give it a stable SKU In **Product Name**, enter `Personalised Welcome Sign`. In **SKU**, enter `SIGN-WELCOME-01`. The name helps the customer recognise the item. The SKU helps your team match it across orders, stock and external sales channels. Keep it stable when you improve the public wording later. ![The real product form with the training name and SKU](assets/appmint-store/02-product-basics.png) **URL Slug** is the readable identifier used in product links. The form says **Auto-generated from name**, but it remained blank in the initial saved record during this walkthrough. Enter `personalised-welcome-sign` explicitly and check it again after saving. Do not build a customer link from the name until you have opened the actual product page. > **Watch for:** an unsaved field on screen is not a finished product record. After each group of changes, wait for **Product saved successfully**, then reopen the product. This is especially useful if the rich-text editor or an upload is still updating. ### 3. Set the base price and internal cost Scroll to **Pricing** and enter: | Field | Value | Why it is here | | --- | --- | --- | | Price | `45.00` | The base selling price before the larger-size addition | | Compare at Price | Leave blank | This exercise is not advertising a markdown | | Cost per Item | `18.00` | An internal cost for margin reporting, not another customer charge | | Stock Quantity | `25` | The quantity field on this catalogue record | | Status | **Available** | The product’s descriptive status | Leave **Hide this product from the storefront** off for the training product. Review **Charge tax on this product** according to your business’s actual tax setup; the checkbox alone does not establish the correct jurisdiction or rate. ![Price, cost and the catalogue quantity entered on the product](assets/appmint-store/03-product-pricing.png) > **Visibility:** use the explicit hide control when withholding a product from the catalogue. Do not rely on a status label such as Draft or Sold Out to make the public listing unavailable. Check the website after making a visibility change. > **Stock:** after the local repair, both the product editor and Products list show `25`. Location-based inventory is still a separate operating setup; a catalogue quantity alone does not establish stock at each business location. ### 4. Create the record, then reopen it Choose **Create Product**. After creation the editor offers **Save Changes**. Return to the product list and find `SIGN-WELCOME-01`; reload the page and open the same row again. Check name, SKU, price, cost and quantity. In the practiced record, the editor retained `45`, `18` and `25` after reload. ![The saved sign in the Products list](assets/appmint-store/04-product-saved-list.png) You now have the record that attributes, cart lines and sales-channel settings can refer to. Do not create another product just because the first is missing a description or image; edit this one. ### 5. Explain what the buyer is ordering In **Basic Information → Description**, use the rich-text area to enter: > A welcome made personal. Add a family name or a short greeting, choose a 30 cm or 45 cm sign, and upload your own crest or artwork. We use the details on your order to prepare your sign. The 45 cm option adds $12 to the $45 base price. Choose **Save Changes**, wait for success and reopen the product. This description persisted in the walkthrough. The explicitly entered slug also persisted after the later restart and readback. ![The saved rich-text description; the slug was still blank in this earlier readback](assets/appmint-store/13-description-slug.png) For your own product, add material, dimensions, what the buyer receives and how artwork approval works. Make turnaround and shipping promises only when your team can meet them. A good description answers the questions that would otherwise become support messages. ### 6. Prepare the catalogue image separately from customer artwork Download [welcome-sign-gallery.png](assets/appmint-store/artwork/welcome-sign-gallery.png), the original **tutorial product illustration** supplied with this course. Open it to confirm it is the round welcome sign image. This fixture lets you rehearse the gallery upload without needing a real product photograph; replace it with accurate imagery of your own item before selling. The [editable SVG](assets/appmint-store/artwork/welcome-sign-gallery.svg) is also supplied. Keep the two customer-artwork SVGs for the later personalisation exercise. The **Media** section is for the product’s public gallery. **Select from computer** uploads an image; **Choose from File Manager** selects an existing asset. After a usable thumbnail appears, the star on that image is **Set as default image**. Uploading and choosing the default are separate actions; finish with **Save Changes**. The public catalogue image should show the product a customer is buying. A customer’s own crest belongs in their personalisation upload later, not as a replacement for the shop’s gallery image. In the local recheck, uploading the supplied illustration, selecting its default star and saving produced a working gallery thumbnail and product-list image after reload. Confirm both views before continuing. A file selected on your computer is not proof that the upload finished. **Try it.** Rewrite the first sentence of the description for your own product, save it, then reload. Have a colleague explain what is included without asking you. **Check yourself.** Does putting the family name in Product Name create the right personalisation model? No. Keep the catalogue name reusable; the family name belongs to that customer’s line item. ## Part 2 — Add the three questions that make each sign different An **attribute definition** describes a reusable question and its input type. Attaching it to a **product** decides whether that question is required and which choice prices apply. The customer’s **answer** then belongs to their cart or order line. ### 1. Create the name field Open **Storefront → Attribute** and choose **Add Attribute**. Enter: | Control | Value | | --- | --- | | Name | `sign_name` | | Title | `Name on the sign` | | Type | **Text (free input)** | Choose **Create Attribute** and find the new record in the list. ![The name attribute’s actual text-field settings](assets/appmint-store/05-name-attribute.png) `sign_name` is the stable internal name; **Name on the sign** is the shopper-friendly title. The checked text settings have no maximum-length control. If your production process has a character limit, state it clearly in the product copy and review orders against it; do not assume the app enforces an unseen 24-character limit. ### 2. Create the artwork field Choose **Add Attribute** again. Set **Name** to `sign_artwork`, **Title** to `Your artwork`, and **Type** to **File upload**. Set **Accepted file types** to `.png,.svg`. Leave **Allow multiple files** off: this product asks for one artwork file. Leave the upload-note option off for this first exercise. Choose **Create Attribute**. ![The artwork attribute accepts PNG and SVG as a single-file upload](assets/appmint-store/06-artwork-attribute.png) Those accepted extensions describe what the picker allows. They do not tell your workshop whether the image is sharp enough to print. Add a separate artwork-quality requirement to the product description when you know your production needs. ### 3. Create the size choices Add a third attribute with **Name** `sign_size`, **Title** `Sign size`, **Type** **Selection (preset choices)** and **Display widget** **Dropdown**. Use **Add Option** to create: | Label | Value | Param | | --- | --- | --- | | `30 cm` | `30-cm` | Leave blank | | `45 cm` | `45-cm` | Leave blank | Choose **Create Attribute**. This defines the choices; you will set the surcharge on the product in the next step. ![The two reusable size choices](assets/appmint-store/07-size-attribute.png) Reload the Attribute list. Confirm all three definitions exist and have the intended types. ![Three saved definitions after returning to the list](../application-fixes/assets/store-attributes-created.png) ### 4. Attach the questions to the product Return to **Storefront → Product → Products**, open **Personalised Welcome Sign**, and expand **Variants**. Use **Add Attribute** once for each row. Select `sign_name`, `sign_artwork` and `sign_size` in the respective selectors. Set the name to **Required**, artwork to **Optional**, and size to **Required**. For `sign_size`, select both **30 cm** and **45 cm** as available options. ![Attributes attached to the product, including required and optional settings](assets/appmint-store/09-product-attributes.png) Why optional artwork? Some customers only want lettering. If every order in your real business requires artwork, make that product’s artwork field required instead. Do not make it mandatory merely because this exercise includes a sample file. ### 5. Put the size surcharge in the right place In the product’s `sign_size` row, leave the additional price for **30 cm** at zero and enter `12` for **45 cm**. Choose **Save Changes**. Reload, reopen the product and expand **Variants** again. You should find the same three attributes, required settings, two sizes and the `12` addition. These settings survived the full reload in the walkthrough. ![Persisted personalisation settings and the larger-size addition](../application-fixes/assets/store-personalisation-readback.png) The intended unit prices are now: | Selection | Calculation | Unit price before tax and shipping | | --- | --- | --- | | 30 cm | $45 + $0 | $45 | | 45 cm | $45 + $12 | $57 | Do not enter `57` as the surcharge: that would add $57 to the base price. Do not put `12` into the attribute’s **Param** field; that is not the product’s price addition. **Generate Variants** is a separate operation for variant records. It was not needed to save these attribute attachments. Before using it for size-specific SKUs or stock, decide how you will identify and manage each generated variant; do not treat customer names or files as a finite catalogue of variants. **Try it.** Reopen `sign_size` in Attribute and then reopen it on the product. Point to the reusable label in the first screen and this product’s price addition in the second. **Check yourself.** Would a different product automatically charge $12 for 45 cm because it uses `sign_size`? No. Its own product attachment needs the appropriate pricing. ## Part 3 — Offer a simple delivery choice ### 1. Open shipping configuration Choose **Storefront → Shipping**. In **Shipping Center**, select the top **Settings** tab. This opens **Shipping Settings**, whose sub-tabs are **Overview**, **Shipping Methods**, **Providers** and **Product Shipping**. On Overview, choose **+ Add Method** under **Active Shipping Methods**. The drawer title is **New shipping configuration**. ### 2. Name the configuration and make it the default Enter **Name** `tutorial-flat-rate`, **Title** `Tutorial flat rate`, and **Currency** `USD`. Keep **Active** selected and select **Use as the site default**. The internal name identifies the configuration. Its title helps operators recognise it. The option you add next provides the customer-facing delivery name. ### 3. Add the $8 offer Choose **Add a shipping option**. In the new option: | Control | Value | | --- | --- | | Name | `Standard delivery` | | How it is priced | **Flat fee** | | Amount | `8` | | Charge per item | Off | | Free above | Leave disabled/blank | | Countries, States, Postcodes | Leave blank for the training calculation | | Handling per order / per item | Leave blank | | Markup | **None** | ![The actual flat-fee shipping configuration before creation](assets/appmint-store/11-flat-shipping.png) **Charge per item** is off because the example is one $8 shipping charge for the order, not $8 for each sign. Empty destination conditions mean the offer is not geographically restricted. For a real shop, enter the destinations you actually serve before advertising the rate. Flat fees do not need carrier-rate credentials. **Live carrier rates** is a different method and requires the corresponding provider setup and shipping information. Do not switch to it simply to obtain a carrier label later. ### 4. Save and verify the default Choose **Create configuration**. After the success notice, reload Shipping Center, open **Settings** again and check **Default: Tutorial flat rate**. ![The saved shipping configuration after a full reload](../application-fixes/assets/store-shipping-readback.png) This verifies the configuration. The website checkout must still show an actual matching shipping option for the destination and cart. A saved default does not prove that a carrier label has been purchased or that a parcel has shipped. **Try it.** Explain the difference between one 45 cm sign and two 45 cm signs under this flat offer. Before tax or discounts, the expected totals are `$57 + $8 = $65` and `$114 + $8 = $122`. **Check yourself.** If you accidentally enabled Charge per item, which check would expose it? A two-sign cart: compare its shipping line with the one-sign cart before placing an order. ## Part 4 — Rehearse the customer journey before taking orders This section is the acceptance rehearsal for the configured product. Complete it on your actual website before making a sales promise. The saved setup screenshots above are not a substitute for the following customer results. ### Put the storefront on the intended site page On **App Root**, find **Active Site**. If it says nothing is loaded, select **Choose a site** and choose your existing site. Check the name before changing its features. First create the page that will host the shop. Open **Build Studio → New Web Page**, confirm the site selector, and use the top toolbar's **Save** disk icon. In **Page → Information**, enter **Name** `store`, **Slug** `store` and **Title** `Tutorial Sign Studio — Shop`, then select **Save**. This can be an empty page: Storefront supplies its body. The [website course](appmint-build-a-business-website.md) explains the builder and page-save controls. Return to **App Root → Active Site → Site Features**. Turn on **Storefront**. Select **Choose page** on the Storefront row and choose **Tutorial Sign Studio — Shop**, identified by `store` and `/store`. Do not select **No page**: that leaves the feature unattached. Reload Home. The Storefront row should remain enabled and show **on /store**, with **Change**, **Edit** and **View** controls. The earlier default-page selector is no longer the interface on the locally checked build. ![The site's Storefront feature is attached to the saved store page.](../application-fixes/assets/store-site-route.png) Enabling Storefront and attaching its page are separate actions. A page with the right name in a different site does not complete this setup. Use **View public site** on your own site card, then open its store page and follow the product from the catalogue. Copy the address you actually reach. Do not paste the training server’s address into customer documentation or assume every shop uses `/product/…`. The catalogue opens the product under the shop's route—for this saved page, `/store/personalised-welcome-sign`. Confirm that the gallery illustration loads, the name and artwork controls appear, and **Sign size** offers30 cm and45 cm. Select45 cm: the displayed unit price must change from$45 to$57. Return to30 cm before preparing the first line. ![The working product page:45 cm costs57, and a missing required name is highlighted.](../application-fixes/assets/store-required-name-fixed.png) The **Required** result above was produced by choosing a size and selecting **Add to Cart** without a name. The cart stayed empty. This check catches an incomplete personalisation before an order exists. ### Check the small sign first Open the product as an ordinary visitor. Check that the page answers: what is this, how much is it, what can I customise, and how do I add it? For the first line use **Name on the sign** `The Okafors`, size **30 cm**, quantity `1`. The price should start from $45 before tax, shipping or discounts. Do not add this line yet: finish the signed-in artwork upload below first. If you already added a no-artwork practice line, remove that line before creating the complete personalised one; a later upload is not proof that an earlier cart line changed. Sign in before uploading the artwork. If you choose a file while signed out, the uploader displays **Please sign in to upload files for this product**. Select its **sign in** link; it carries the product address so you return here after authentication. 1. On **Sign in**, existing customers enter their email and password, or enter their email and select **Send magic link**. 2. For a new customer, select **Create one**. Enter **Full name** and **Email address**, then select **Send magic link**. In this rehearsal the customer is Ada Okafor; use a mailbox you control for your own practice. 3. Open the email titled **Your secure login link** and follow its sign-in link promptly. The browser completes sign-in and returns to this product. Do not register the same person again while waiting for mail; use the existing-customer sign-in form to request another link if necessary. 4. Select30 cm and enter`The Okafors` again if navigation cleared the unsaved fields. Select **browse** under **Your artwork**, choose the downloaded`okafor-crest.svg`, and wait until the uploaded filename appears with its remove control. A progress row or selected local filename alone does not prove upload finished. 5. Select **Add to Cart** once. Open the shopping-bag button and confirm the line contains **The Okafors**,`30-cm`, quantity1 and`okafor-crest.svg`, with a$45 line amount. ![The signed-in customer has uploaded the first artwork before adding the line.](../application-fixes/assets/store-first-artwork-upload.png) The gallery remains the same illustration for everyone. The uploaded crest belongs to this customer's cart line; it does not replace the product gallery. ### Add a visibly different second line Close the cart. If the previous artwork remains in the product form, use its remove button before choosing the next file; this changes the form, not the already-added cart line. Use **Name on the sign** `Welcome Home Maya`, size **45 cm**, quantity `2`, and `maya-flowers.svg` after customer sign-in. Inspect the cart for two distinct personalisations. The second line should be `$57 × 2 = $114` before order-level additions. The first line must still say The Okafors and retain its own artwork. Reload the page and repeat the comparison. Open the cart if it is closed. Both designs, both filenames and their quantities must remain. With the saved flat delivery offer, the cart shows **Products$159**, **Shipping$8**, **Total$167**. ![Two distinct personalised lines retain their own filenames and prices after reload.](../application-fixes/assets/store-two-cart-lines-fixed.png) If the two designs merge, a name disappears, an upload fails or the size addition is missing, stop this order rehearsal at that result. Keep the exact cart state and correct the problem before accepting customer work. Do not compensate by changing the shared product name for each buyer. ### Check checkout and payment as separate results Use a customer mailbox you control for the rehearsal: order submission can send a confirmation. Configure your gateway’s test environment deliberately, keeping credentials out of recordings. A no-gateway checkout is not a demonstration of a paid order. ![The actual checkout shows the two personalised designs alongside contact, shipping and payment sections.](../application-fixes/assets/account-layout-checkout-fixed.png) Before submitting, compare the checkout lines, destination, selected delivery method, tax, discounts and final amount. For the two example lines together, the merchandise subtotal should be `$45 + $114 = $159`; with the example $8 offer and no tax or discounts, the total would be `$167`. Use the actual tax and shipping results on your site rather than forcing the example total. ### Developer setup: connect the training Stripe configuration The checkout below used an organisation-owned Stripe test integration created through the normal API. This is the demonstrated setup path; a separate provider-setup UI was not exercised. Use a training organisation with no conflicting Stripe configuration, an owner session, and matching publishable/server keys from your own Stripe sandbox. Keep the server key out of the browser, screenshots and shared files. Send `POST /repository/create` to your AppEngine API with `orgid: `, the owner's `Authorization: Bearer ` and `Content-Type: application/json`: ```json { "pk": "", "sk": "", "isNew": true, "version": 0, "name": "tutorial-stripe-sandbox", "datatype": "config", "data": { "name": "tutorial-stripe-sandbox", "title": "Tutorial Stripe sandbox", "provider": "StripeProvider", "type": "Payment", "useCases": ["Payment"], "status": "active", "priority": 100, "secretKey": "", "publishableKey": "" } } ``` Save the returned configuration ID. Reuse an existing matching training configuration instead of creating another active provider each time you revisit the lesson. Confirm the selected provider is in test mode before entering the test card: the review independently checked Stripe's `livemode: false` result and the resulting test PaymentIntent. A title containing “sandbox” alone does not select test mode. See [Stripe's test setup](https://docs.stripe.com/testing) for its sandbox keys and card details. ### Complete a Stripe sandbox card payment Use this branch only after your operator has configured and verified Stripe in test mode for the training organisation. The normal gateway service selects its active organisation integration; there is no configuration-ID dropdown for the customer. The author’s fresh payment rehearsal used one 30-cm sign for Mira Sandbox: $45 merchandise plus $8 shipping, total $53. It is separate from the earlier two-design $167 artwork exercise. 1. Review **Contact Information** and **Shipping Address**. Use **Edit** beside either section to correct it before paying. 2. In **Payment Method**, select **Card**. Check the **Order Summary** again: the chosen size, personalization, quantity, shipping and total must match your cart. 3. In the test form, enter card `4242 4242 4242 4242`, a future expiration such as `12/34`, and CVC `123`. Set the billing country and ZIP for your fictional address. These are Stripe’s test values, not a real payment card. [Stripe’s interactive testing instructions](https://docs.stripe.com/testing?testing-method=card-numbers). 4. Select **Pay now** once. Wait for the response; do not click again while the first payment is being checked. 5. On **Order confirmed**, read the order number, **Paid** badge, **Paid with Stripe** amount and total. Retain the order number for the operator check below. ![Actual Stripe test form for one personalized sign and $8 shipping.](../application-fixes/assets/stripe-sandbox/02-checkout-test-card.png) ![Actual successful sandbox order AZ1QTIMKN: Paid with Stripe $53 and Payment status Paid.](../application-fixes/assets/stripe-sandbox/03-order-confirmed-paid.png) For this run, Stripe independently reported the matching payment as `succeeded` with `livemode: false`. The customer confirmation is therefore supported by a provider test result. It does not mean a physical parcel has shipped, and it does not turn the earlier unpaid order into a paid one. **If the page fails after you press Pay:** check for the order and provider result before retrying. A missing confirmation page alone does not prove the payment failed. If the cart reports a pricing error before submission, resolve that error before paying; do not force a zero-total checkout. After a completed test checkout, retain the order number. In **Storefront → Order**, open that order and compare its lines and payment information with the confirmation. Verify the transaction in the provider’s test environment as well. A confirmation page, an order status and a settled transaction are related results, not interchangeable evidence. **Try it.** Before the paid rehearsal, deliberately omit the required name and see whether the customer interface prevents adding an incomplete line. Restore the name and repeat with the other size. **Check yourself.** A customer sees their file in the cart. Is the workshop ready to print? Only after the operator can retrieve the right file for the right placed order and verify any required design approval. ### Return to your saved cart in another browser session An empty cart in a fresh browser does not necessarily mean your selections were lost. The website saves a server copy when it updates the open cart, so sign in with the same customer account before trying to recover it. 1. Open the cart. If you are signed out, select **Sign in to restore your saved cart**. Enter the email used for this rehearsal, select **Send magic link**, and follow the new link from that mailbox. You return to the store. 2. Open the cart again. When it is empty, select **Restore saved cart**. Let restoration and pricing finish. This action reads your saved customer cart; it does not place an order. 3. Compare both lines: **The Okafors / 30-cm / 1 / okafor-crest.svg** and **Welcome Home Maya / 45-cm / 2 / maya-flowers.svg**. The amounts should return to **$45** and **$114**, with the configured **$8** delivery charge and **$167** total. 4. Change Maya's quantity to **1**. After pricing finishes, the total should be **$110**. Change it back to **2**, wait for **$167**, then reload and reopen the cart. Both designs and their original quantities should remain. ![The restored customer cart, with both sizes, names, attachments and the $167 total](../application-fixes/assets/store-cart-restored.png) **If recovery cannot finish:** **There are no items in your saved cart** means this signed-in customer's saved cart has no selections to restore. Check that you used the same customer account. **Could not restore your saved cart** means the request failed; sign in again if the session expired, then retry. Restoration is offered for an empty cart and will not replace items added while its request is running. ## Part 5 — Advanced: deliver the right sign and handle a return ### Check the artwork handoff before taking orders You have two designs for the same product. The fulfilment task is to keep each design attached to its own name, size and quantity—not simply to find two files. 1. In Studio Manager, expand **Storefront** and select **Cart**. Find the row with the rehearsal customer's email and the **$167** total. Open that row. 2. Under **Product Items**, check the first line: `SIGN-WELCOME-01`, **The Okafors**, **30-cm**, quantity **1**, amount **$45**. In its **uploaded files** section, select **Open okafor-crest.svg**. The artwork opens in another browser tab. 3. Return to the cart detail. Check the second line: **Welcome Home Maya**, **45-cm**, quantity **2**, amount **$114**. Select **Open maya-flowers.svg** under that line. Compare the design itself, not just the filename. 4. Return to Studio and compare **Subtotal $159**, **Shipping $8** and **Total $167** with the customer's cart. If you change a quantity on the customer site, let the cart finish updating, then refresh Studio before using the figures for fulfilment. ![Studio cart detail showing each personalised line and its artwork-opening control](../application-fixes/assets/store-owner-artwork-fixed.png) **Watch out:** a filename appearing in the customer cart does not prove the operator can retrieve the file. Open both attachments during your rehearsal. If Studio shows **could not load artwork**, use **Retry**. If the file tab displays **NoSuchKey**, report the affected cart and filename through Support; that is a storage-path failure, not a design approval. Keep the signed file URL out of support screenshots—it grants temporary access to private artwork. ### Retrieve the artwork from the placed order 1. In Studio Manager, open **Storefront → Order**, then **Order Management**. Find the order number from the customer confirmation. Select its order-number row to open the detail drawer. 2. Select **Items (2)**. Match **The Okafors / 30-cm / quantity1 / $45** and **Welcome Home Maya / 45-cm / quantity2 / $114** before downloading anything. 3. On the first line, select **Open okafor-crest.svg**. Inspect the opened design: it reads **The Okafors**. Return to the order and select **Open maya-flowers.svg** on the second line; that design reads **Welcome Home Maya**. Keep each file associated with its own name, size and quantity during production. 4. Select **Payments**. Check **Gross collected**, **Uncollected balance** and the payment records before releasing production. The earlier screenshot below labels these amounts **Paid** and **Balance**. Our local order **RPSDCGHC4** explicitly shows **Paid$0**, **Balance$167**, **Unpaid**. It proves the order retains artwork after checkout clears the selected server cart; it does not prove a paid sale. ![The actual unpaid customer order retains both names, sizes, quantities and artwork filenames.](../application-fixes/assets/store-unpaid-customer-order.png) ![The operator's Items tab provides a separate artwork-opening link for each ordered design.](../application-fixes/assets/store-unpaid-operator-artwork.png) **Original artwork rehearsal:** RPSDCGHC4 was created once through the real customer-authenticated checkout API without a payment gateway or reference. Its customer/operator screens and both actual files were checked. The separate fresh sandbox order AZ1QTIMKN subsequently passed the ordinary online payment screen. Do not create fake payment references, mark an unpaid order paid, or use the customer's active cart as the permanent order archive. ### Match the paid order to its provider payment For your paid sandbox order, open **Storefront → Order**, select its order, then **Payments**. Check four things together: - **Order total**, **Gross collected** and **Uncollected balance**: at payment, AZ1QTIMKN had $53, $53 and $0 respectively. The earlier capture labels the latter two **Paid** and **Balance**; the current screen also separates refunded and net-retained amounts. - The number of payment rows: this run has one paid row, not two charges. - **Gateway** and **Reference**: the row says Stripe and carries the same payment-intent reference returned by the provider. - The provider's test result: the corresponding intent is `succeeded`, for 5,300 USD minor units, with `livemode: false`. ![Actual operator capture before the refund and summary-label repair: one Stripe payment, $53 collected and $0 uncollected.](../application-fixes/assets/stripe-sandbox/04-operator-payment.png) Do not select **Take Payment** again merely because the button remains available on a fully paid order. Inspect the saved payment first. Matching these records establishes this sandbox payment; it does not establish shipment or refund. ### Record a shipment you arranged outside Appmint Use manual tracking when the carrier arrangements already exist and you need to record them against the right order. It does not purchase a shipping label. The screenshots below use an explicitly fictional tracking entry on the sandbox order; no parcel was dispatched. 1. Open the paid order's **Shipping** tab and select **Add Manual Tracking**. Check **Items Included** so the entry belongs to the intended product and quantity. 2. Choose the actual **Carrier**. Selecting **Other** reveals **Carrier Name**. Enter the shipment's **Tracking Number** and **Tracking URL or Instructions**. For the local exercise the labels explicitly say **Tutorial record — no carrier** and **TUTORIAL-NO-PARCEL-20260924**; use real carrier details only for a real shipment. 3. Fill **Service Type** and **Shipping Cost** from the shipment you arranged. Optional package dimensions are **L (in)**, **W (in)**, **H (in)** and **Wt (lb)**. These are shipment details, separate from the flat delivery amount charged at checkout. 4. Set **Status** to the actual shipment stage. The training record used **Shipped** with a note that no parcel was dispatched. Verify the selected item, then select **Save Tracking Info** once. ![Actual manual tracking form on the paid sandbox order, clearly labelled as a no-parcel training record.](../application-fixes/assets/stripe-sandbox/05-manual-tracking-form.png) 5. Read the saved shipment and item counts, then reopen the order. In the corrected local readback, this one-sign order has **one manual shipment**, a fully shipped status and no pending item quantity. That records the operator's entry; obtain delivery evidence from your carrier before telling a real customer the parcel arrived. ![Corrected Shipping overview: one of one items recorded as shipped, with the saved no-carrier training marker.](../application-fixes/assets/stripe-sandbox/12-manual-shipping-fixed.png) ### Refund the payment and reconcile the result The order refund is the money operation. The return record below tracks the item and inspection. Use the order that actually received the payment—here **AZ1QTIMKN**, not the earlier unpaid artwork exercise. 1. Open the order's **Actions** tab and scroll to **Refund**. Enter the approved refund in **Amount (max $53.00)** and explain it in **Reason**; the maximum reflects this order’s refundable amount. This sandbox rehearsal refunded the complete **53**, covering the $45 sign and $8 delivery amount, with an explicit test-payment reason. Choose your actual refund amount from the agreed resolution. 2. Select **Process Refund** once. Wait for the result, then reopen the order. This run changed the header to **Refunded** and saved one refund-history entry for **$53**. If the response is interrupted, inspect the existing refund history and provider result before submitting anything again. 3. Match the saved refund reference with the provider's record. The actual Stripe sandbox refund was `succeeded` for **5,300 USD minor units**, against the same payment intent as the original $53 payment; the charge reported `refunded: true` and `livemode: false`. ![Actual order header after the single successful Stripe sandbox refund.](../application-fixes/assets/stripe-sandbox/08-order-refunded.png) 4. Open **Payments** and compare the summary: **Gross collected $53.00**, **Uncollected balance $0.00**, **Refunded $53.00**, **Net retained $0.00**, **Refunded**. The balance is zero because the original amount was collected; the refund does not create another bill for the customer. ![Current Payments summary separates the original collection, returned amount and zero net retained.](../application-fixes/assets/stripe-sandbox/11-refund-summary-fixed.png) The original paid transaction remains part of the order's history. Read collected, refunded and remaining amounts together rather than expecting that original transaction to disappear. A refund request accepted by a provider is not automatically its final outcome; check the provider status before confirming the result to the customer. ### Separate gallery, artwork and design proof | Item | Purpose | | --- | --- | | Product gallery image | Shows everyone what is for sale | | Customer-uploaded artwork | Supplies the particular design for one line | | Design proof | Shows a composition or placement to check before production | **Design Preview** in the product editor supports a base image and zones tied to an artwork attribute. Treat this as an additional setup exercise: test alignment, a differently proportioned image, the rendered proof and operator retrieval. Uploading any SVG does not automatically mean an approved print proof exists. ### Reconcile a damaged return without losing the money trail Use **Storefront → Return** for the goods coming back and the order’s actual refund controls for the money. The reviewed return lifecycle is `requested → approved → shipped → received → completed`, with rejection and inspection branches. Follow the current state rather than skipping directly to a completion label. When returned items reach **Received**, inspect each personalised line independently: 1. Open **Storefront → Return → Returns**. Find the RMA number, open its three-dot **Actions** menu, and select **Start Inspection**. 2. Read the line number, name, SKU, returned quantity and price. The same SKU can appear more than once because the designs differ. 3. Choose **Accept** or **Reject** for **every** line. Accept means that line is accepted for this return; it is not a declaration that the goods are fit to sell again. Record the inspection explanation in **Notes**. 4. Select **Inspect**. The return advances to **Inspecting**. Confirm the saved decisions and accepted value before completing any refund. ![A clearly labelled synthetic inspection fixture shows independent decisions for two personalised lines sharing one SKU.](../application-fixes/assets/store-return-independent-decisions.png) **What this rehearsal proves:** rejecting the$45 Okafors line and accepting one$57 Maya item saved separate decisions and a$57 accepted value. The screenshot is an isolated received-return fixture, not a shipped or paid customer order. The accepted-value list was checked at$57. The same synthetic fixture was then completed with a deliberate zero refund and no payment reference; its saved timeline says no money moved. For a real return, choose the amount from the actual approved resolution—zero is not a workaround for an unpaid provider refund. The real unpaid order RPSDCGHC4 remains unchanged. A returned 45 cm sign in this example has a $57 merchandise price before allocated discounts and tax, not the $45 base price. Determine the refund from the original paid order and your return policy; do not blindly refund $45 or the whole two-sign order. The **Complete return** action does not itself call an external payment gateway or restock goods. An external refund reference is a recorded reference, not provider verification. Cash refunds must match the saved POS refund records. **Store credit** and **Gift card** are different: those options can add real stored value to a gift card. Choose the method deliberately; do not use them as a harmless placeholder while waiting for a provider refund. A personalised sign may not be resaleable; inspect it before recording a separate stock-return decision. ### Connect the paid order’s return to its existing refund The paid sandbox order now has a real application return request, **RMA-QXR1Q7WMPZ**, created through the customer's API and completed in Studio. It is a fictional goods-handling exercise: no parcel was returned. The **$53** provider refund had already succeeded before recording this RMA, so completing it must not send another refund. **Create the request through the supported API.** No customer return-request form was available in the reviewed storefront. A developer can submit `POST /storefront/returns/request` using the signed-in customer's bearer, their organisation's `orgid` header and JSON content type. Use the customer's own order number and ordered line values. For the one-sign training order, the request shape was: ```json { "orderNumber": "", "reason": "Local training return for the already refunded sandbox order; no physical goods are being returned.", "reasonCategory": "other", "type": "return", "items": [{ "sku": "SIGN-WELCOME-01", "name": "Personalised Welcome Sign", "quantity": 1, "returnQuantity": 1, "price": 45, "reason": "Fictional inspection exercise", "condition": "opened" }], "refundMethod": "original_payment" } ``` Retain the returned RMA number. Check for an existing request before repeating a submission; the completed review record should be reopened, not recreated. 1. In Studio, open **Storefront → Return → Returns**. Find the RMA and use its row's three-dot menu to select **Approve**. 2. Fill **Return name**, **Street**, **City**, **State**, **Postal code**, **Country** and **Approval notes**, then select **Approve**. These fields describe where a real return should go. Our notes explicitly identify a fictional local return and the already-refunded test payment. ![Actual approval form for the sandbox order’s return, with its destination and notes.](../application-fixes/assets/stripe-sandbox/09-rma-approve.png) 3. Use the same row menu and select **Receive in store**, add the receipt notes, then select **Receive**. For a real return, do this only when the goods arrive. The training record describes no physical receipt. 4. Select **Start Inspection**, inspect the single sign line and choose **Accept**, record the explanation, then select **Inspect**. Confirm the return value is **$45**, the merchandise portion. 5. Select **Complete return**. Enter **Refund amount 45**, choose **Original payment**, and paste the **already succeeded provider refund reference** into **Refund record IDs or external reference (required above zero)**. In **Notes**, explain that this $45 is part of the earlier **$53** refund; the other **$8** was order shipping. Select **Complete** once. ![Complete Return records the merchandise amount against the existing provider refund; it does not send another refund.](../application-fixes/assets/stripe-sandbox/14-rma-complete-form.png) 6. Confirm the row shows **Completed**, the correct order number, one item and **$45.00**. A fresh read of `GET /storefront/returns/rma/` returned `status: completed`, `refundAmount: 45`, `refundStatus: recorded` and the same external refund reference. Rechecking Stripe still showed the original single **$53 succeeded refund**. ![The completed RMA stays linked to Mira’s actual sandbox order.](../application-fixes/assets/stripe-sandbox/15-rma-completed.png) **Recorded** describes the RMA’s external-reference bookkeeping. Use the independently checked provider record to establish the money outcome. This exercise neither restocked the sign nor added store credit or a gift card. **Try it.** Explain which record answers each question: “What did Maya order?”, “Where is her artwork?”, “Has the payment been refunded?” and “Can we sell the returned sign again?” **Check yourself.** A return says completed but there is no provider refund. Has the customer received their money? The return status alone cannot establish that. Trace the refund separately. ## If something goes wrong | Symptom | First check | Next action | | --- | --- | --- | | New Product opens a list | You are still on Product Center navigation | Use **Add Product** on Products | | Slug is blank after save | Reopen URL Slug, despite its auto-generation hint | Enter it, wait for save success, reopen and use the actual website product link | | Description or another field reverts | An editor/upload may still be updating | Save a small change, wait for success and verify it after reopening | | Gallery upload has no usable thumbnail | Upload completion and saved gallery entry | Reopen and verify the image; do not use a broken gallery as launch-ready media | | Stock differs between screens | Confirm the same product and whether the screen shows catalogue or location stock | Refresh; investigate an unexplained difference before promising stock | | No 24-character setting on the name attribute | Text type has no extra settings on this build | Explain a real production limit in copy; do not describe a nonexistent control | | Large sign has the wrong price | Base price versus additional choice price | Set 45 on the product and 12 as the 45 cm addition | | Product page shows Application error | The link reached from the correct catalogue | Record the path and exception for support; do not treat the shop as ready for checkout | | Storefront is enabled but /store says page not found | The page record in the active site | Create and save the store page, then retry | | Public store shows unrelated products | Site/domain and environment routing | Verify the intended company/site before sharing the link or ordering | | Artwork requests sign-in | Customer upload authentication | Use the normal customer sign-in and retry with a controlled account | | Two personalisations lose their distinction | Cart-line names, sizes and filenames | Preserve the failing example and resolve it before taking orders | | Shipping is $16 for two signs | **Charge per item** | Turn it off for a single $8 order-level offer and check checkout again | | Order exists but is unpaid | Gateway and actual payment transaction | Complete a properly configured test payment; do not merely edit a status label | | Artwork is absent from order detail | Operator retrieval path for the placed order | Verify the stored attachment and supported retrieval route; do not assume the cleared cart is an archive | | Return is completed but no money moved | Actual provider refund | Reconcile the refund and reference separately from the return record | ## What happened behind the scenes
Implementation notes for developers and course maintainers The product and attributes are separate records (`sf_product`, `sf_attribute`). The UI saves the product’s data and post fields through the repository; `AttributeEditor` supplies product-specific required flags and option prices. Relevant source files under `/Users/imzee/projects`: - `websitemint/packages/ui/src/components/storefront/product/product-form.tsx`: creation, description, slug, gallery/default image and product sections. - `websitemint/packages/ui/src/components/storefront/attribute/attribute-form.tsx`: actual text, file and selection settings. - `websitemint/packages/ui/src/components/storefront/shipping/shipping-config-form.tsx` and `shipping-options-editor.tsx`: configuration versus individual shipping offer, conditions and fees. - `websitemint/packages/ui/src/components/welcome-screens/buttons/site-features-section.tsx`: current feature row and page selection. - `base-app/src/lib/site-feature-pages.ts` and `components/storefront/StorefrontRoute.tsx`: selected store route and child product route. - `base-app/src/components/storefront/utils/custom-attributes.ts`: text/file definitions, required checks and `options[]` with file information. - `base-app/src/lib/storefront-upload.ts`: authenticated `client-data/files/upload`; multipart transfer and the sign-in-required result. - `appengine/src/storefront/storefront.order.service.ts`: server cart calculation, copied options/design, cart clearing and order-confirmation notification. - `appengine/src/storefront/services/returns.service.ts`: return transitions and completion record. Do not infer a gateway refund from its completion note. Current source checks do not replace payment-provider or customer-account acceptance tests. Earlier specifications described paid orders, an assumed `/product/…` path, an unavailable text maximum length and a $45 refund for the larger sign. This manuscript replaces those assumptions with the observed setup and explicit completion checks.
## Where next - [Prepare the catalogue for other sales channels](appmint-sell-anywhere.md). - [Follow payments and payouts](appmint-manage-payments-and-payouts.md). - [Manage local deliveries](appmint-manage-deliveries.md). - [BusinessMade stock, sales and returns](businessmade-stock-sell-and-return.md) for the separate BusinessMade operating workflow. ## Evidence and companion-video handoff **Practiced:**21 September2026 local recovery organization: product/gallery/attributes/shipping/site attachment saved and reloaded; working product page; customer registration, locally captured magic-link sign-in and return to product; two artwork uploads; distinct45/114 cart lines and159+8=167 server total; reload persistence; quantity change/repricing110→167 and close/reopen; Studio server-cart readback and opening both actual SVG attachments from their matching lines; restoring a saved cart after fresh sign-in; stable cart ID through repricing and reload; a fresh upload opened by the operator, followed by removal of its temporary test line. Screenshots above are actual local captures. [Repair and evidence report](../application-fixes/storefront-personalisation.md). **Verified24 September:** one customer-authenticated unpaid checkout; the selected server cart cleared; both artwork files opened from the placed order; customer and operator line/total readback; Payments shows Paid$0 and Balance$167. **Current acceptance:** actual payment-screen checkout, matching Stripe sandbox payment and operator payment readback passed on AZ1QTIMKN. The manual training shipment, actual provider refund, corrected shipping/payment readbacks and customer-requested RMA through completion also pass. Physical carrier dispatch and bank settlement are not represented by these training records. A finished companion video has not been recorded. Earlier product-rendering failure is repaired locally and is retained in the repair report as history. **Producer:** use the [companion-video guide](production/appmint-sell-custom-products-online.md). It identifies existing screenshots, missing footage, scene purpose and the results required before filming an end-to-end sale. --- # Prepare one product for Google and Etsy—and check what will actually be sent > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-sell-anywhere.html # Prepare one product for Google and Etsy—and check what will actually be sent ![The sign restricted to Google Merchant Center and Etsy in its Sales Channels settings](assets/appmint-channels/local-product-channels-reopened.png) *Two selected destinations keep the catalogue intent clear. Account connection, listing validation and publication are separate steps.* **For:** shop owners and catalogue operators adding another selling destination. **Allow:** 45–60 minutes for the Appmint preparation and pricing lab; provider account setup and review take separate time. **Level:** beginner configuration, followed by a developer validation lab. **Product:** Appmint Studio Manager. **Checked:** product selection and pricing persistence rechecked 24 September 2026 in local Studio and AppEngine; provider forms and adapter review from 18 September remain separately identified. **Example:** Tutorial Sign Studio, SKU `SIGN-WELCOME-01`, selling price $45, cost $18. ## What you will have at the end You will have selected the sign’s intended channels, found the real Google and Etsy connection controls, and learned to check a proposed channel price before sending it anywhere. You will be able to distinguish an eligible product, a connected account, a locally valid listing and a listing accepted by the destination. The advanced lab demonstrates an important result with the real API: **15% markup on an $18 cost is not 15% added to the $45 selling price**. It also checks a channel exclusion and the difference between Google’s and Etsy’s local validation results. > **Walkthrough status:** on 24 September, Google/Etsy product selections were saved, the page was reloaded and both selections persisted. A disabled pricing rule was created through Studio, reloaded and reopened; the actual authenticated API returned 20.70, 51.75 and the unchanged 45.00 control result. The rule was disabled, then deleted and its absence checked; the product’s original selection was restored. Provider forms were inspected in the earlier walkthrough without authorising external accounts. No listing, marketplace order or price/stock sync has been performed. ## What you need - The saved sign from [Personalised products](appmint-sell-custom-products-online.md), including its description, price and cost. The course’s current website/image failures must be resolved before using it as a real external listing. - Access to **Storefront → Product** and **Storefront → Sales Channels**. - For actual connection: control of your Google Merchant Center account or Etsy shop, and permission to grant Appmint access. Use your real account identifiers; sample IDs in API documentation are not accounts you can connect. - Product images you can publish, the correct product identifier if one was assigned, an accurate category and a working customer-facing product URL. - For the optional API lab: a developer who can authenticate normally to your training organisation and send the documented requests. Keep tokens outside screenshots and source files. Do not invent a GTIN to silence a warning. Handmade and one-of-a-kind products can require different identifier treatment. Google documents how to use `identifier_exists` when identifiers do not exist; that provider rule must also be represented correctly by the integration. See [Google’s identifier guidance](https://support.google.com/merchants/answer/6324478?hl=en). ## The story Priya already maintains the welcome sign in Appmint. She wants people to discover it through Google and buy through the shop, and she is considering an Etsy listing. Her main risk is duplication: another price to maintain, another stock number to trust, or an Etsy buyer whose artwork never reaches the workshop. We start with one SKU and two intended destinations. We will inspect what Appmint is preparing before allowing any external action. A useful first result is knowing what must be corrected, not a green badge obtained with invented product data. ## The route ```mermaid flowchart LR A[Catalogue product] --> B[Choose intended channels] B --> C[Connect the correct provider account] C --> D[Validate fields and price] D --> E[Send one controlled listing] E --> F[Inspect the actual destination] F --> G[Reconcile orders, stock and later updates] ``` ## Part 1 — Understand the two commerce screens ### 1. Open Sales Channels Expand **Storefront** and choose **Sales Channels**. The module’s top tabs are **Dashboard**, **Channels**, **Orders**, **Inventory**, **Pricing**, **Mappings**, **Optimization** and **Analytics**. ![Sales Channels in the training organisation](assets/appmint-channels/01-channel-dashboard.png) Use Dashboard for a summary, Channels for connection status, Pricing for rules, and Mappings for translating catalogue fields and categories. The actual product-publishing interface is reached through **Product → Partner Sync**, which we will open later. The training dashboard shows zero revenue and orders. Its percentage changes nevertheless display `+12.5%`, `+8.2%` and `-2.1%`. Those are fixed display values on this build. Do not interpret them as sales growth for a new organisation. ### 2. Read the channel catalogue Choose **Channels**. The checked catalogue has eleven entries: Amazon, eBay, Walmart, Etsy, Google Shopping, Microsoft Bing, Facebook, TikTok, Pinterest, Shopify and WooCommerce. ![Available channel definitions with no connected accounts](assets/appmint-channels/02-channel-catalogue.png) The labels **Inventory**, **Orders**, **Pricing** and **Fulfillment** describe the channel definition. They are not a receipt showing that each operation has run successfully for your account. Begin with one destination and test the exact operations your business needs. Google appears as **Google Shopping** here and **Google Merchant Center** in the product selector. The names refer to the same intended Google destination in this lesson. ### 3. Restrict the sign to the intended channels Open **Storefront → Product**, choose **Products**, and open **Personalised Welcome Sign**. Expand **Sales Channels** in the product editor. Wait for the channel chips to load. This section briefly displayed **No channels available.** during its initial request, then showed all eleven choices. Do not create another account or product because of that transient state. Select **Etsy** and **Google Merchant Center**. Their chips should appear selected. Choose **Save Changes**, wait for **Product saved successfully**, and reopen the product to check the selection. ![Google and Etsy selected on this particular product](assets/appmint-channels/local-product-channels-reopened.png) > **Blank means all:** leaving every chip unselected means the product is eligible for all channels in the validation model. Selecting two expresses a restriction. It does not connect either account or create a listing. The saved record in this walkthrough contained `etsy` and `google`. A separate validation request for eBay returned **Product is not enabled for this channel**. Treat that as a validation check; confirm the chosen publishing path respects the restriction before using any bulk or all-partners action. **Try it.** Point to the product’s selected chips, then the channel account status. Explain why the chips can be selected while the account still says Not connected. **Check yourself.** Is an empty channel selection a way to block all external publishing? No. Keep the desired destinations explicit and control the publishing action separately. ## Part 2 — Connect the account from the actual product-publishing screen ### 1. Open Partner Sync Return to **Product Center** and choose its **Partner Sync** tab. The page title is **Product Sync**, with **Commerce Sync** and **Social Media** modes. Stay on **Commerce Sync**. The product list is on the left; **Sync Destinations** contains provider cards on the right. The checked page showed the saved sign and **0 of 1 selected**. Provider cards showed **Not Configured**. ![Partner Sync separates product selection from destination configuration](assets/appmint-channels/08-partner-sync.png) This is where you prepare a product push. The Sales Channels dashboard, provider configuration, product selection and external push are different actions. Keep Social Media for the [social automation course](appmint-social-media-automation.md). ### 2. Open Etsy Quick Connect On the Etsy destination card choose **Configure Etsy**. The drawer offers **Quick Connect**, **Manual Configuration** and an **Etsy Shop Setup Guide** link. Quick Connect displays **Connect with Etsy** and explains that it redirects to Etsy for authorisation. ![Etsy’s actual Quick Connect entry](assets/appmint-channels/09-etsy-quick-setup.png) When connecting your own shop, use that ordinary provider flow. Check the account you are signing into and the access being requested before approving. Return to Appmint and verify the intended shop/account details and saved configuration. Reopen the setup to check persistence; simply closing a provider window is not confirmation of connection. The walkthrough stopped before starting OAuth because no controlled Etsy shop was supplied. The screenshot shows the entry point, not a connected shop or a successful permission grant. ### 3. Understand Etsy Manual Configuration In the same drawer choose **Manual Configuration**. This opens the integration-provider catalogue. Choose **EtsyProvider** to open **EtsyProvider Configuration**. ![The actual Etsy provider configuration fields, with credentials empty](assets/appmint-channels/11-etsy-config-fields.png) | Field | What belongs here | | --- | --- | | Name | A meaningful configuration name for your shop | | Priority | Provider selection priority; the screen explains higher priorities are chosen first | | Access Token | The valid Etsy OAuth token for the authorised account | | Refresh Token | Its associated refresh credential where available | | Shop Id | Your actual Etsy shop ID | | Shipping Profile Id | The real shipping profile the listing should use | Do not copy placeholder credentials or a shop ID from this lesson. Keep the supplied provider type. **Save** persists a configuration; **Test** is a separate control. The reviewed Etsy Test implementation reports configuration status without checking the account over the network, so it cannot establish that Etsy will accept a listing. In this walkthrough the fields were left empty and no configuration was saved. If you only came to inspect the form, use **Cancel**. ### 4. Open Google’s connection entry Back on Partner Sync, choose **Configure Google Merchant Center**. Use its **Quick Connect** entry for the intended Google account, or **Manual Configuration → GoogleProvider** when your administrator supplies the required configuration. ![Google Merchant Center’s actual connection entry](assets/appmint-channels/12-google-quick-connect.png) The manual form contains **Customer Id**, **Refresh Token**, **Access Token**, **Merchant Id**, **Ads Account Id**, **Maps Api Key**, **Cloud Api Key** and **Store Path**. GoogleProvider serves several Google capabilities, so the presence of an Ads or Maps field does not make it a prerequisite for selling this sign. For Merchant Center, have your administrator confirm the actual merchant identity and appropriate authorisation; do not fill unrelated fields with sample values. ![GoogleProvider’s manual fields before any credentials are entered](assets/appmint-channels/13-google-config-fields.png) A connected Google identity must belong to the business/account intended for this catalogue. After returning from authorisation, check the Merchant Center destination and product link configuration before sending the sign. A successful general Google login is not the same as an accepted Merchant Center product. For this fixture, first resolve the website course’s public-routing and product-page failures. A feed should not direct shoppers to an unrelated catalogue or an application error. **Try it.** Open a provider’s setup guide from the drawer, then return without submitting anything. Identify where you would configure the account and where you would select the product to send. **Check yourself.** Does Configure Etsy publish the sign? No. Configuration prepares the provider; product selection and the actual sync action come afterwards. ## Part 3 — Check the data without inventing a successful listing ### 1. Learn what Mappings actually contains Return to **Storefront → Sales Channels → Mappings**. Its sub-tabs are **Categories**, **Attributes** and **Templates**. **Add Category** starts a category mapping; it is not a product picker for an individual Google validation test. ![Mappings contains category, attribute and template tools](assets/appmint-channels/03-mapping-entry.png) A mapping translates one vocabulary into another. Your catalogue may call a group “Welcome signs”; the destination expects an actual taxonomy identifier. Match the real destination category rather than entering a plausible number. An attribute mapping similarly relates an Appmint field to the field expected by that channel. The controls depend on connected channels. With none connected, finish account preparation before trying to invent a destination choice. The developer lab below can test Appmint’s validation independently of an external account. ### 2. Read the sign’s actual validation results The developer rehearsal sent the saved product’s data to the local validation endpoints. It produced: | Destination | Local result | What to do with it | | --- | --- | --- | | Google | Missing `brand`, `gtin`, `link`; category and image-count warnings | Correct the real data and identifier mapping; do not fabricate a barcode or working URL | | Etsy | `valid: true`, with brand/category/image-count warnings | Continue the provider-specific checks; this is not Etsy approval | | eBay | Missing `salesChannels`, with exclusion warning | Expected for a product restricted to Google and Etsy | Read [the saved eligibility result](assets/appmint-channels/eligibility-lab.json) for the exact responses. They are actual API output, not a screenshot of a nonexistent Validate form. The Google validator and publisher also read identifiers differently on the checked source: validation checks a `gtin` field, while the converter uses the product’s ISBN/barcode value. Resolve that mapping before submission. Entering random numbers into both fields would only conceal the problem. ### 3. Check what personalisation will survive The Appmint website’s name/artwork/size fields do not automatically become Etsy personalisation questions. The reviewed Etsy converter sets `is_personalizable` to false, and its create-listing request does not configure the three Appmint questions or upload their artwork. For this sign, a complete Etsy readiness check therefore includes creating the intended personalisation experience at the destination and testing its order-to-workshop handoff. Do not advertise artwork upload merely because the Appmint product has a File attribute. Etsy’s current API supports separate typed personalisation configuration, including text, selection and file questions. That capability requires the corresponding integration work; the checked Appmint adapter does not demonstrate it. See [Etsy’s personalisation migration guide](https://developer.etsy.com/documentation/tutorials/personalization-migration/). ### 4. Review the listing as a customer would Before a provider push, prepare a small comparison sheet: | Detail | This example | Destination check | | --- | --- | --- | | SKU | `SIGN-WELCOME-01` | Matches the item you will fulfil | | Name | Personalised Welcome Sign | Accurate and readable | | Price | $45 base; $57 for 45 cm on the Appmint site | Destination’s size/price model represents what the buyer receives | | Images | Working product images | Actual images exist at the destination, not only in Appmint | | Maker and production timing | Your real process | Override or resolve inaccurate adapter defaults | | Personalisation | Name, artwork, size | Buyer can supply all required instructions | | Shipping | Your real destinations and service | Correct provider shipping profile and charge | | Product link | Actual working site product URL | Opens outside the administrator’s session | The reviewed Etsy adapter defaults to “someone else” as maker and “made to order” as timing. It also creates a new listing on a push. Those defaults must match your actual business before use; repeatedly syncing to fix a mistake may create another listing rather than update the first. **Try it.** Explain why the Etsy `valid: true` result is compatible with an unusable personalised listing. The local validator only checked its own required fields; it did not create the customer’s questions or upload the pictures. **Check yourself.** Does “three images recommended” mean your single saved image loads correctly? No. Open the actual image and destination preview. ## Part 4 — Check channel pricing before it reaches a customer ### 1. Open the rule form In **Sales Channels → Pricing**, stay on **Rules** and choose **Add Rule**. The form is **Create Pricing Rule**. In a training organisation with no connected channels, enter **Rule Name** `Tutorial cost markup 15 percent`, **Rule Type** **Markup – Add fixed percentage to cost**, **Value (%)** `15`, and **Priority** `10`. Leave Min Price and Max Price blank. The available channel choice in this session was **All Channels**. ![The actual pricing-rule fields](assets/appmint-channels/local-rule-create.png) Before saving, clear **Enable this rule**. The current screenshot shows this disabled rehearsal configuration; its example name is `Tutorial channel review 20260924`. Use your own clearly recognisable training name. Use **Create Rule**, reload **Rules**, reopen your named training rule and check the switch is still off. Do not enable an unreviewed all-channel rule in a live shop. Only the separately scoped API calculation exercise below temporarily enables its exact-SKU test rule and disables it again. ### 2. Calculate a local estimate before connecting a seller account Choose **Calculator** inside **Sales Channels → Pricing**. This calculates with the organisation's saved rules. It does not publish, sync or contact a marketplace, so you can use it before connecting Google or Etsy. 1. Enter **SKU** `SIGN-WELCOME-01`, **Base Price ($)** `45` and **Unit Cost ($)** `18`. 2. Choose **Google Shopping** under **Channel**, then select **Calculate Price**. In the local rehearsal there were no active pricing rules, so **Base Price** and **Calculated Price** both showed **$45.00**. A disabled rule from the previous step should not change that result. ![Actual Google Shopping estimate with no connected seller account or active rule:45.00 remains45.00.](../application-fixes/assets/channels-local/calculator-google-local-success.png) 3. Choose **Etsy**, clear the optional **Unit Cost ($)** field and calculate again. With no active rules, the result stays **$45.00**. This checks that the selected channel and optional cost reach the calculator correctly. ![Actual Etsy estimate with optional cost blank, calculated locally without a seller connection.](../application-fixes/assets/channels-local/calculator-etsy-local-success.png) 4. Compare these baseline results with the scoped-rule experiment below. With its 15% cost-markup rule enabled, price 45 and cost 18 produce 20.70 before rounding; omitting cost changes the calculation basis to 45. The next section explains why those are very different selling decisions. If an expected rule does not apply, check its **enabled** state, channel, SKU condition and priority before changing a listing. A successful calculation is a price estimate, not a marketplace listing or connection test. There is no `.99` rounding control in the checked rule form; the supported API field is used explicitly in the next experiment. ### 3. Compare cost markup with a selling-price increase The API rehearsal scoped the saved rule to Etsy and the exact training SKU, set `roundTo` to `0.99`, and calculated both cases: | Request inputs | Calculation before rounding | Returned price | | --- | --- | --- | | `price:45`, `cost:18`, markup15% | $18 ×1.15 = $20.70 | **$20.99** | | `price:45`, no cost supplied, markup15% | $45 ×1.15 = $51.75 | **$51.99** | | Another SKU with the exact-SKU condition | Rule does not apply | **$45.00** | That first result is below the existing $45 selling price. If your intent is to cover marketplace fees by increasing the current selling price, do not select cost markup and expect a selling-price increase. Choose and test the appropriate model; the name you give a rule does not change its calculation. The checked `.99` calculation uses the whole-dollar floor plus `.99`. It is a price-ending operation, not a universal guarantee that every rounding operation respects a previously set min/max. Test edge cases before relying on price limits. ### 4. Clean up the experiment The historical initial UI save stored All Channels as an empty string, and the Etsy-specific calculation incorrectly ignored that rule. The local backend repair recognises existing empty-string global rules and normalises new global saves; deployed matching still needs its own acceptance check. The developer rehearsal changed the channel to `etsy`, added the exact-SKU condition and rounding, verified the results, then set `enabled:false` and read it back. ![The rehearsed rule persisted, scoped to Etsy and disabled](assets/appmint-channels/05-rule-readback.png) Read [pricing-lab.json](assets/appmint-channels/pricing-lab.json) to compare the before, calculation, control and disabled readback states. This makes the experiment reproducible without leaving an accidental discount active. The fresh local check is recorded in [pricing-live.json](assets/appmint-channels/local-pricing-live.json). It used a separate exact practice SKU, `TUTORIAL-CHANNEL-20260924`, with no rounding: 20.70 with cost, 51.75 without cost and 45.00 for `OTHER-SKU`. After checking `enabled:false`, we deleted only that rehearsal rule and verified it was absent. To remove your completed experiment, select its trash icon in **Rules**, confirm the deletion and reload. Keep a real business rule if your team still needs it; remove only the uniquely named practice rule. ![The disabled rule still present after a full page reload](assets/appmint-channels/local-rule-reloaded.png) > **Priority:** higher numbers run first, and only the first matching enabled rule applies. The old form said the opposite; the local repair corrects that label without changing the ordering. If your screen still says lower numbers have higher priority, report the old label and keep your rehearsal to one rule. Before enabling overlapping rules, test your own exact training SKU with two distinguishable results and confirm the higher-priority result, then disable the rehearsal rules. **Try it.** Calculate both 15% cases on paper before looking at the returned values. Then explain which input your intended pricing model needs. **Check yourself.** Does a correct local calculated price prove Partner Sync used that price? No. The reviewed product push sends the product to its provider separately; inspect the actual destination price. ## Part 5 — Advanced: reproduce the validation and calculation Complete the staff sign-in in [the connected-client course](appengine-build-a-connected-web-or-mobile-client.md#1-establish-the-reservation-definition-as-staff), using your own practice organisation. Use its `API`, `ORG` and separate `STAFF_TOKEN` values; a customer or public guest token is not the operator credential for this lab. The [AppEngine API reference](https://appengine.appmint.io/documentation) exposes the endpoint schemas without requiring private source access. Before any pricing request, verify the authenticated identity: ```bash curl -sS "$API/profile/whoami" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` The response must be your staff email, not `Anonymous User` or another identity. Stop on an authentication error. Keep tokens out of screenshots, source files and shared transcripts. Every relative route below uses the same headers. For example, this read lists rules without changing them: ```bash curl -sS "$API/sales-channel/pricing/rules" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` For a POST, add `-X POST`, `-H 'Content-Type: application/json'` and send that step’s JSON with `--data @your-local-request.json`. Replace example product identifiers with your own saved practice product. The laboratory performs local validation/calculation only; it does not publish to a marketplace. ### Read the product before validating it `GET /storefront/product/personalised-welcome-sign` returned the training SKU, price 45 and cost 18. Extract the product’s **data** object for validation, preserving the actual channel list and fields. Send that object to: ```text POST /sales-channel/mappings/validate/google POST /sales-channel/mappings/validate/etsy POST /sales-channel/mappings/validate/ebay ``` The result contains `valid`, `missingRequired` and `warnings`. A successful HTTP response means the check ran; inspect `valid` to learn whether the data passed. ### Scope an existing training rule Read `GET /sales-channel/pricing/rules`, find your named training rule and retain its returned `id`. Update that same rule with `POST /sales-channel/pricing/rules`, keeping its ID: ```json { "id": "", "name": "Tutorial Etsy cost markup 15 percent", "channel": "etsy", "type": "markup", "value": 15, "priority": 10, "enabled": true, "roundTo": 0.99, "conditions": { "skuPattern": "^SIGN-WELCOME-01$" } } ``` Using a new or omitted ID creates another rule; retaining the existing ID updates the experiment you already made. Do not change a business rule that happens to have a similar name. Call `POST /sales-channel/pricing/calculate/etsy` with: ```json {"sku":"SIGN-WELCOME-01","price":45,"cost":18} ``` Repeat without `cost`, then with a different SKU. Compare the returned `calculatedPrice` and `appliedRule`, not merely the HTTP status. Finish by saving the same rule with `enabled:false` and reading `GET /sales-channel/pricing/rules/`. This laboratory isolates local validation and pricing. It does not show that a marketplace integration applied the rule or approved the product. ## Part 6 — Run a controlled provider rehearsal when the prerequisites are ready Choose a single prepared product in **Product → Partner Sync → Commerce Sync** and the one intended, configured destination. Check the selected count before invoking its sync action. Do not start with Select All or a push to every partner. After the authorised push, inspect the individual provider result and then open the destination’s management interface. The **View in Merchant Center** or **View in Etsy Shop Manager** links take you to management pages; they are not themselves proof of a specific accepted listing. Locate the actual SKU/listing ID and compare the preparation sheet from Part 3. For Etsy, the reviewed create path makes a new listing and does not upload its images. A draft listing and an active purchasable listing are separate states in Etsy’s API. Confirm the actual result at Etsy and finish the required images and settings there; do not assume Appmint’s success envelope means the listing is live. See [Etsy’s listings tutorial](https://developers.etsy.com/documentation/tutorials/listings/). Before a second push, check whether that provider updates the existing listing or creates a duplicate. Record the original external listing ID. Do not repeatedly press sync to repair a field without understanding that behaviour. ### Reconcile operations after listing The **Orders**, **Inventory** and **Analytics** tabs in Sales Channels are operational surfaces, not evidence that order import or stock synchronisation is running. The reviewed adapters do not provide a demonstrated complete order/price/fulfilment loop for the two destinations in this lesson. For the first controlled marketplace order, compare the destination order number, SKU, quantity, customer instructions and fulfilment state with whatever appears in Appmint. Until that import is verified, operate the marketplace order in its own seller interface and maintain a deliberate stock reconciliation. Do not promise that a local Appmint status change sends tracking or a refund back to Etsy. Use **Optimization** to review Appmint’s listing-quality suggestions, then inspect and save the product itself. A local score or an “applied” suggestion flag is not provider approval, a customer-visible edit or a published listing. Use **Analytics** only after you have identified the actual imported transactions behind the metrics. **Try it.** Create a one-order reconciliation sheet before launching: Appmint SKU, external listing ID, external order ID, quantity, chosen personalisation, amount, fulfilment owner and stock adjustment. Make one person responsible for checking it. **Check yourself.** Where should an operator fulfil an Etsy order when no reliable import has been established? In the actual Etsy seller workflow, while reconciling the Appmint record deliberately. ## If something goes wrong | Symptom | First check | Next action | | --- | --- | --- | | Product initially says No channels available | Registry request still loading | Wait for the chips; the captured empty state resolved | | eBay validation rejects salesChannels | Product intentionally restricted to Google/Etsy | Keep the exclusion, or deliberately add eBay and save before validating | | Calculator does not offer supported channels | Current Calculator screen and channel-list load | Refresh and report a failed load; seller connection is not required for a local estimate | | All Channels rule never applies | Rule is enabled, SKU conditions match and no higher-priority rule wins | The local empty-string matching defect is repaired; compare the returned appliedRule in the scoped API lab | |15% returns about 21 rather than 52 | Cost was supplied | Distinguish cost markup from a selling-price increase | | No rounding control | Current rule form | Treat `roundTo` as an API-only rehearsal field on this build | | Priority behaviour disagrees with the hint | Actual matched rule | Test a single rule, then controlled overlaps | | Google asks for GTIN on a handmade item | Real product identifiers and adapter mapping | Follow provider identifier rules; never invent a barcode | | Etsy validates locally but lacks questions/images | Adapter does not transfer these features | Complete and test the actual destination configuration before selling | | Provider Test says configured | Whether Test performs a network check | Verify real account access and a controlled provider result | | Re-sync creates another Etsy listing | Create-versus-update behaviour | Compare external IDs and resolve duplicates in the actual seller workflow | | Dashboard percentages look positive on zero sales | Fixed display values | Use actual orders and amounts; do not report those percentages as growth | ## What happened behind the scenes
Source and verification notes for developers Paths below are relative to `/Users/imzee/projects`. - `websitemint/packages/ui/src/components/storefront/product/product-form.tsx`: channel chips load asynchronously; the product stores `salesChannels`. - `appengine/src/sales-channel/channel-mapping.service.ts`: local eligibility and required-field validation. Etsy currently uses the fallback required set of SKU/name/price, not an Etsy-specific category requirement. - `websitemint/packages/ui/src/components/storefront/sales-channel/channel-mappings.tsx`: Categories/Attributes/Templates UI; the earlier topic’s individual-product validation screen was not present. - `channel-pricing.tsx` in that UI folder and `appengine/src/sales-channel/pricing.service.ts`: empty-string All Channels mismatch, request field mismatch, priority ordering, cost markup, single-rule selection and rounding. - `websitemint/packages/ui/src/components/storefront/product/product-sync.tsx` and `partner-config-panel.tsx`: Partner Sync, actual Configure actions, OAuth and manual provider configuration. - `appengine/src/storefront/storefront.partner-sync.service.ts`: provider dispatch and product payload. This path needs its own eligibility and price-application verification; validation and calculation tests alone do not establish publishing enforcement. - `appengine/src/integrations/etsy/product.converter.ts`, `etsy.service.ts`, `etsy.provider.ts`: create-listing payload, fixed maker/timing defaults, missing personalisation/image setup and non-network Test response. - `appengine/src/integrations/google/product.converter.ts`: product link construction and ISBN-to-GTIN mapping differ from the validator’s raw-field checks. No software source was changed for this course. The course records issues and continues through the working setup and local API checks.
## Where next - [Social media automation](appmint-social-media-automation.md) for posts and campaigns, separate from marketplace product listings. - [Payments and payouts](appmint-manage-payments-and-payouts.md) for following the money. - [Reliable background jobs](appengine-run-reliable-background-jobs.md) for scheduled processing and sync operations. ## Evidence and video handoff Live work used training organisation `learnmu4qn1ha` and the product created in the preceding course. Product eligibility was saved in the UI and read through the API. The initial pricing rule was created in the UI; channel/rounding/SKU scoping and the calculation comparison were explicitly an API lab. Its final rule is disabled. Provider setup was inspected with blank credentials. No external account, publishing, payment, order import or provider refund is claimed. See [screens and timestamps](assets/appmint-channels/evidence.json), [initial validation](assets/appmint-channels/api-lab.json), [pricing experiment](assets/appmint-channels/pricing-lab.json), [eligibility readback](assets/appmint-channels/eligibility-lab.json), and the [companion-video guide](production/appmint-sell-anywhere.md). --- # Follow the money: customer payments, wallet entries and payouts > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-manage-payments-and-payouts.html # Follow the money: customer payments, wallet entries and payouts ![Three records answer three different money questions](assets/appmint-finance/money-map.svg) **For:** business owners, finance operators and developers connecting an earning workflow. **Time:** 35–45 minutes for the training ledger and settings; provider setup is a separate session. **Level:** beginner, with a developer section. **Product:** Appmint Studio Manager → Finance. **Screens checked:** 24 September 2026, local Studio Manager 0.6.2. The original illustrated ledger and a fresh-account repeat both passed; customer destination setup was also exercised locally. A customer buys something. A partner earns a share. The partner asks to be paid. Those sound like one transaction, but they leave different records in Appmint. Knowing which record to open is the difference between answering “Where is the money?” and guessing from a green badge. This course starts with the screens and a small, reversible training ledger exercise. It then follows the handoff to a payout, including the destination, approval and bank-file checks that matter before money goes out. The opening ledger exercise does not charge a customer or send a transfer. The later sandbox branch follows a verified $53 Stripe test payment, and the ACH branch builds and downloads a $5.40 training bank file. No bank submission or settlement is claimed. ## What you will have at the end - A map of the eight Finance tabs and the question each answers. - Saved rules that require a person to approve each payout and leave payout cycles on demand. - A clearly named practice wallet and an explained credit/debit trail. - A method for checking customer payments separately from earnings and payout records. - A checklist for introducing a real payment provider or payout destination without confusing local records with provider outcomes. ## What you need Use an owner account in a **training organisation**. Keep this exercise out of the live business ledger: the example amounts are practice entries, not income or money owed to a real person. No payment gateway or bank account is needed for the practice section. Start in Studio Manager. If you have just created your organisation, complete [your welcome and first setup](appmint-welcome.md) first. Use the actual customer, order and earner identities from your business when you later apply the workflow outside training. ## The story Cedar & Form wants to pay reviewers who refer design clients. Jordan needs to distinguish the client's purchase, the reviewer's earned balance and the eventual transfer. Before connecting that process to real people, Jordan rehearses the internal ledger with **Tutorial Ledger Practice** and makes the approval policy explicit. The practice record is deliberately separate from Ada's future affiliate account. A display name entered in a wallet form does not establish that a customer signed in, supplied a payout destination or earned a commission. ## The route ```mermaid flowchart LR A[Customer pays for an order] --> B[Payments: local payment record] B -. compare provider reference .-> C[Gateway: provider records] D[Qualifying earning or adjustment] --> E[Wallet Transactions] E --> F[Wallet balance] F --> G[Payout request and approval] G --> H[Provider transfer or ACH file] H --> I[Confirm outcome and reconcile] ``` The dotted comparison matters: a locally recorded payment and a provider charge need to be matched. A manually recorded cash payment will not have a card-provider charge to match. ## Part 1 — Find the right record before changing anything ### 1. Open Finance In Studio Manager's sidebar, open **Finance**, then **Dashboard**. The module heading is **Finance — Wallets & Payouts Management**. ![Finance dashboard and its in-page navigation](assets/appmint-finance/01-finance-overview.png) **1 — Wallets** is where you inspect an earner's balance. **2 — Payments** is the customer payment ledger. The tabs between them cover paying earners and tracing wallet entries. The in-page strip has more destinations than the sidebar. Use that strip throughout this course: | Tab | Open it when you need to know… | | --- | --- | | **Dashboard** | What the currently loaded wallets and payouts add up to | | **Wallets** | Who has a balance, which amounts are held/reserved, and what payout methods exist | | **Payouts** | Which requests need attention and what happened to an individual request | | **ACH Runs** | Which approved bank payouts are waiting for a file, and the state of each bank file | | **Payout Rules** | Who approves requests and how payout cycles are initiated | | **Wallet Transactions** | Which credit or debit changed a wallet, with its reason and reference | | **Payments** | What Appmint recorded for customer charges, manual payments and refunds | | **Gateway** | What configured payment providers return about their transactions | **You should see:** in a new organisation, zero wallets, zero balance and no payouts. Those zeroes do not mean a payment provider has been connected or queried successfully. > **Watch for:** the dashboard totals the lists loaded by the page. It is an overview, not a complete reconciliation report across every page of records or every currency. ### 2. Open Payments Select the **Payments** tab. Its own sub-navigation contains **Transactions**, **Take Payment** and **Verify Payment**. Stay on **Transactions** first. ![Payments with an empty transaction list and payment controls](assets/appmint-finance/03-payments-empty.png) **1 — Take Payment** is an action surface. **2 — Verify Payment** is separate from reading the transaction list. The table includes **TRANSACTION**, **CUSTOMER**, **TYPE**, **AMOUNT**, **GATEWAY**, **STATUS**, **CREATED** and **MODIFIED**. Status and type filters narrow the list; the footer controls pagination. For an existing business, start with the payment reference from the order you are investigating. Compare the customer, amount, currency, gateway and reference before taking any action. A matching amount alone can belong to another order. **For a new organization:** expect `0 payments` and **No payments found.** In the 24 September repeat, the shared training organization already contained an earlier manual invoice payment (`RECOVERY-DEPOSIT-200`, $200). We left it unchanged. Its presence is a useful distinction: a manual payment can exist while Gateway has no provider configured. ### 3. Understand the Take Payment entry Select **Take Payment** inside Payments. The current screen opens **Payment request**. ![Take Payment: amount, payer, purpose and collection choices](../application-fixes/assets/finance-local/11-take-payment.png) Work down the form in this order: 1. Enter the amount. This form displays **US Dollar** and submits USD; it does not offer a currency selector. Use it only for the matching USD obligation. 2. Under **Who is paying**, use **Find a customer** to identify the payer. Match their identity, not just an amount from another payment. 3. Explain **What it is for**. The form says the customer sees this on the request and receipt. If you are collecting an existing balance, choose **Invoice** or **Order** instead of describing an unrelated new charge. 4. Under **How are they paying?**, distinguish requesting money from recording money already received. **QR code** and **Payment link** let the customer pay through their own phone or an emailed link. **Cash**, **Check**, **Bank transfer** and **Other** sit under **Already received**. For the ledger exercise, inspect these choices and return to **Transactions** without submitting. Recording “already received” asserts that a separate payment happened; it does not charge a card or make a bank transfer. We did not collect another payment during this repeat. **Verify Payment** is a separate tab. It asks for **Payment Gateway** and **Payment Reference** (a gateway payment-intent ID or transaction reference). Use the provider's real reference after provider setup; a made-up reference cannot verify the ledger credit in Part 3. For a store sale, [the online store course](appmint-sell-custom-products-online.md) prepares the product and order. Neither that course nor this ledger exercise completes payment-provider onboarding. Before a card checkout, complete the provider preparation below; then match the resulting order and provider reference here. ### 4. Open Gateway Select **Gateway** in Finance's main tab strip. ![Gateway Transactions before any payment gateway is configured](assets/appmint-finance/05-gateway.png) The heading is **Gateway Transactions**, with the description **Live transactions from your payment gateways**. The captured training organisation shows **No transactions found** and **No payment gateways configured**. These messages are different from a declined card. There is no configured provider to ask yet. Typing a payment reference into this list cannot connect one. **Prepare the payment provider:** the ledger exercise does not perform provider onboarding. The companion store course now includes an actual sandbox checkout; prepare the provider before following that branch. For Stripe, first create/select an isolated sandbox, obtain its publishable and server credentials, and use the provider's test payment details. Follow [Stripe's API-key setup](https://docs.stripe.com/keys) and [Stripe's test payments guide](https://docs.stripe.com/testing). Keep server credentials private and use keys from the same sandbox throughout. In the verified store rehearsal, order **AZ1QTIMKN** showed **Paid $53**, **Balance $0** and one matching Stripe payment. The provider independently returned `succeeded` with `livemode: false`. Follow [the sandbox checkout and operator readback](appmint-sell-custom-products-online.md) to see the actual screens and checks. Paying an earner is a separate integration from charging a shopper. A PayPal payout exercise needs an enabled payout application, sandbox sender and controlled sandbox recipient; follow [PayPal's Payouts API setup](https://developer.paypal.com/api/payouts). Adding a PayPal address to an Appmint wallet only stores the destination—it does not enable that provider account or transfer money. The local destination exercise below deliberately uses a nonpayable training address. When a provider is configured, compare its transaction reference with the local payment. Check the gateway account and test/live environment as well as the amount. An offline payment may legitimately have no provider record; a purported card charge needs an explanation if the provider cannot find it. **Try it:** go back to **Payments → Transactions**, then return to **Gateway**. Say which screen reads Appmint's stored records and which asks the payment integration for provider records. **Check yourself:** does an empty Gateway list prove the customer has not paid? No. First check whether the payment was cash/offline, whether the correct provider is connected, and whether a query failed. The list alone cannot settle that question. ## Part 2 — Choose who approves a payout ### 1. Open Payout Rules Select **Payout Rules** in the main Finance tab strip. ![Payout Rules with approval choices and the no-timer notice](assets/appmint-finance/07-payout-rules.png) There are two separate decisions: **Who approves a payout** and **When payouts are raised**. Approval decides whether a request waits for a person. A cycle decides which eligible wallets get requests created. Neither setting is a substitute for a verified transfer outcome. ### 2. Require approval for every amount Under **Who approves a payout**, select **Somebody approves every payout**. Leave **…except anything under** empty. That field is an exception to manual approval. Entering an amount would let smaller payouts pass through the approval stage. For example, with an exception of `10`, a request below `10` can qualify for automatic approval; a request exactly at `10` waits for a person. For this exercise there is no exception. ### 3. Keep the cycle on demand Under **When payouts are raised**, select **Only When Asked**. The other choices are **Daily**, **Weekly**, **Biweekly** and **Monthly**. The screen also says **Nothing here runs on a timer yet — a cycle happens when this is pressed**, beside **Run a cycle now**. Selecting a frequency does not start a background timer in this build. > **Watch for:** **Run a cycle now** evaluates eligible wallets across the organisation. It is not a preview for just the wallet you last opened. Keep it out of a single-wallet practice exercise. ### 4. Save and check the policy Press **Save the rules** at the bottom. The screen shows **Saved.** beside the button. Leave the tab and reopen **Payout Rules** to check the selected choices. ![Manual approval and on-demand rules after saving](assets/appmint-finance/08-rules-saved.png) **1 — Somebody approves every payout** is selected. **2 — Only When Asked** keeps cycles on demand. The blank exception means the policy has no small-amount shortcut. The saved record was independently read back with `approval.mode: manual` and `schedule: manual`. **Try it:** explain the difference between “approve every payout manually” and “run a cycle manually”. The first controls approval; the second controls creating requests for eligible wallets. **Check yourself:** changing the rule to automatic would not show that someone received money. Request creation, approval, provider processing and settlement are separate events. ## Part 3 — Make a ledger entry you can explain and reverse This is a standalone training wallet. Its owner ID is a lab marker, not a customer's account ID. It has no email address, bank details or payout destination. The exercise teaches the ledger controls; the real earner connection comes afterwards. ### 1. Open the wallet form Select **Wallets** and look for wallet number `TUTORIAL-LEDGER-01` before creating anything. If it exists, open that record and inspect its transactions; reuse a completed practice result instead of repeating the adjustments. For an intentionally separate rehearsal, choose a different unique practice wallet number and keep it throughout the exercise. If the selected number is absent, press **Create Wallet** at the upper right. The drawer opens in **Modern** view. **JSON** is an alternate editor; stay in Modern for this exercise. ![The Modern wallet form before entering the practice values](assets/appmint-finance/02-create-wallet.png) The form separates wallet details, balance and owner information. The opening-balance fields are editable, but beginning at zero makes the later change visible as a transaction rather than an unexplained starting number. ### 2. Fill Wallet Details and Balance Use these exact training values: | Field | Value | Purpose | | --- | --- | --- | | **Wallet Number** | `TUTORIAL-LEDGER-01` | Identifies the practice record in the list | | **Status** | **Active** | Allows the ledger exercise | | **Wallet Type** | **Partner** | Describes this practice wallet | | **Currency** | **USD - US Dollar** | Keeps every amount in the exercise in one currency | | **Current Balance** | `0` | Creates no unexplained opening credit | | **Hold Amount** | `0` | Starts the exercise without a hold | The form previews **Available Balance $0.00**. Do not enter `5.40` directly into Current Balance: the next steps deliberately create a traceable adjustment. ### 3. Fill Owner Information Scroll to **Owner Information** and enter: | Field | Training value | | --- | --- | | **Owner Type** | **Partner** | | **Owner ID** | `66eaa0000000000000000018` | | **Owner Name** | `Tutorial Ledger Practice` | ![Owner information for the deliberately standalone training wallet](assets/appmint-finance/09-training-wallet-fields.png) The ID above is an exercise marker. In a connected earning workflow, the owner must be the actual earner's record, not an arbitrary number copied from this lesson. Creating this form does not register a customer or give that person a login. ### 4. Save the wallet Press **Save** at the bottom of the drawer. **Wallet saved successfully** appears and the wallet list gains one row. If saving returns the module to Dashboard, select **Wallets** again before opening the record. Its identifying line reads **USD · TUTORIAL-LEDGER-01**, its type is `partner`, and its balance is `$0.00`. Open that row to inspect **Wallet Details**. ![The saved training wallet at zero, with Add Credit available](assets/appmint-finance/10-zero-wallet.png) **1 — Add Credit** opens the ledger action used next. The card also shows **Pending**, **Held**, **Reserved** and **Status**. > **Watch for:** the captured wallet list and drawer show `-` for Owner even though the form saved Owner Name. The Modern form stores these fields inside the wallet's data, while the detail header reads a separate owner object. A saved name alone is not a verified connection to a customer. Keep this standalone exercise out of actual payouts. Before making an adjustment, record the saved wallet ID and current balance. If a save times out, the browser closes or a success notice is missing, reopen that same wallet and inspect **Wallet Transactions** for the intended reference, amount, direction and balance change before retrying. A matching text reference is a way to find evidence; it is not a guaranteed idempotency key. If the outcome is still unclear, stop and reconcile the saved record instead of adding a second adjustment. Apply this read-before-retry check to both the credit and reversal below. ### 5. Describe the credit before adding it Press **Add Credit**. In the **New Credit** panel, fill: | Field | Value | | --- | --- | | **Amount** | `5.40` | | **Type** | **adjustment** | | **Reference** | `TUTORIAL-LEDGER-01` | | **Description** | `Training ledger credit only — no sale, commission or bank funds received` | ![The credit form with amount, adjustment category and training reference](assets/appmint-finance/11-credit-fields.png) Choose **adjustment** because that is what this action represents. Choosing **commission** would describe a different business event; it would not make a referral qualify or make a customer pay. The reference joins this entry to your explanation. Use a real order, job or correction reference for actual business entries. The amount by itself will not tell a colleague why the balance changed. ### 6. Add the credit Press the second **Add Credit** button inside the New Credit panel. **Credit added** appears. The wallet balance becomes `$5.40` and the Transactions section gains one entry. ![Wallet after the internal training credit](assets/appmint-finance/12-credit-saved.png) The captured transaction number is `SF4VZD7I6IYE`; yours will be generated separately. Its category is **Adjustment**, and its amount is `+$5.40`. This is a completed ledger write. It does not show a successful customer charge, an earned affiliate commission or cash in a bank account. ### 7. Reverse the exercise with an explained debit Press **Add Debit**. Fill the New Debit panel: | Field | Value | | --- | --- | | **Amount** | `5.40` | | **Type** | **adjustment** | | **Reference** | `TUTORIAL-LEDGER-01-REVERSAL` | | **Description** | `Reverse the training adjustment — no payout or refund sent` | ![The reversal is a new adjustment debit, with its own reference](assets/appmint-finance/13-debit-fields.png) Press the second **Add Debit** button inside the panel. **Debit added** appears and the wallet returns to `$0.00`. ![The training balance returns to zero](assets/appmint-finance/14-wallet-zero-again.png) The original credit remains in the history. This is intentional: another operator can now see both the practice change and its reversal. A debit adjustment is not the **Request Payout** action, and it does not refund a customer payment. ### 8. Read the pair in Wallet Transactions Close Wallet Details using the **×** in the upper right. Select **Wallet Transactions** in the Finance tab strip. ![The two adjustment transactions, with before and after balances](assets/appmint-finance/16-wallet-transactions.png) **1 — The reversal** is a debit of `$5.40`, from `$5.40` to `$0.00`. **2 — The original entry** is a credit of `$5.40`, from `$0.00` to `$5.40`. Both are completed entries in this internal ledger. Open the reversal row. In **Transaction Details**, compare **Type**, **Category**, **Balance Before**, **Balance After**, **Reference ID** and **Description**. ![The reversal detail connects the amount to its explanation](assets/appmint-finance/17-debit-detail.png) The reference type displays **Other** for this example. Appmint infers some reference types from text patterns; the reference is not an automatic link that creates an order, payout or job. Close the detail, leave the tab, then reopen **Wallet Transactions**. Both rows should still exist. Reopen the wallet too: the balance should remain zero. These records were also read back independently after the exercise. > **Watch for:** the wallet's **Total Debits** still reads `$0.00` after this adjustment debit. In this build that lifetime field increases for payout debits, not every debit category. Read the individual transactions when reconciling adjustments; do not use Total Credits minus Total Debits as an all-purpose balance calculation here. ![Lifetime summaries after the adjustment pair](assets/appmint-finance/18-lifetime-totals.png) ### 9. Practice a refusal without creating another entry Return to the zero-balance wallet. Press **Add Debit**, enter `1` for Amount, select **adjustment**, and use `TUTORIAL-INSUFFICIENT` as Reference. Press the form's **Add Debit** button. ![The zero-balance wallet refuses a further debit](assets/appmint-finance/15-insufficient-balance.png) The result is **Insufficient balance**. Close the drawer and check **Wallet Transactions**: there should still be only the original two entries. A failed attempt should not become a third debit. **Try it:** tell a colleague why the ending balance is zero without opening the dashboard. Use the two transaction amounts and their before/after balances. **Check yourself:** the debit's status is Completed. Was anyone paid? No. Its category and description identify an internal adjustment. A payout needs its own request, destination and transfer outcome. ## Part 4 — Prepare a real earner for a payout The training wallet is finished at zero. Leave it as an audit example. Use the wallet created by a real earning workflow when introducing payouts. A customer buying something does not, on its own, credit an affiliate or delivery wallet. The earning workflow must identify the earner, calculate the share and write its wallet transaction. Start from that transaction's reference and confirm the corresponding business event before requesting money out. ### Trace a delivery earning back to its job A useful comparison is the driver's wallet below. It contains **two job-generated earnings**, rather than the manual credit used in the first exercise. The local delivery rehearsal followed normal driver registration, owner approval, job assignment, pickup, dropoff and completion. Each job had a **$9 customer price** and **$6 driver pay**. No physical delivery or customer charge is represented by these training records. 1. Open **Finance → Wallets** and select the wallet belonging to the linked driver customer. Check its owner before comparing amounts. 2. Expand **Earnings Breakdown**. This example shows **Base Earnings $12.00** and **Adjustments $0.00**. The earlier standalone practice wallet has a different purpose; do not merge their histories. ![The delivery customer’s actual wallet: $12 base earnings, zero adjustments and no payout destination.](../application-fixes/assets/finance-local/delivery-earned-wallet.png) 3. Expand **Transactions**. Find the two **Earning** rows, each **+$6.00**. Their descriptions include the delivery job numbers **V9L8YAVMR1** and **CB9N2IKCXN**. Match those references to the completed jobs and their recorded driver-pay amounts. An amount without a source reference is harder to investigate later. ![Two actual job-generated earning entries, each tied to its own delivery job number.](../application-fixes/assets/finance-local/delivery-earned-transactions.png) 4. Check **Payouts** separately. Here **Paid $0.00**, **Pending $0.00** and **Total Requests 0** mean no payout was requested from this wallet. The yellow **No payout methods configured** message explains why **Request Payout** is unavailable. An earning can be correctly recorded before a recipient supplies a destination. The repaired completion workflow saves the earning and its status together with the completed job result. Repeating the first completed job did not add a second credit; the next distinct job added the second $6 entry. If a completed job has no matching earning, investigate its linked customer and completion error before entering an adjustment—otherwise a later retry could obscure the history. Continue with [the delivery course](appmint-manage-deliveries.md) for the operational setup. ### Read the wallet's readiness Open the earner's row in **Wallets**. Inspect these items together: | Item | What to establish | | --- | --- | | Owner | The wallet belongs to the intended earner's actual record | | Currency | The earning and requested payout are in the intended currency | | Transactions | Credits have a traceable reason and source reference | | Held / Reserved | These amounts may already be unavailable for another request | | Payout Methods | A destination belongs to the intended recipient and is usable | | Payouts | A request for the same earning is not already pending or processing | In the captured training wallet, **Request Payout** is disabled and the screen says **No payout methods configured**. Expanding **Payout Methods** shows no entries. The drawer tells you the wallet owner needs to add a bank account, PayPal or another payout method; it does not provide an Add method button here. Do not turn the lab marker into a real recipient by adding banking details to it. Connect the actual customer/earner account through the application that owns the earning workflow. Developers can follow the customer-authenticated destination exercise below. It uses the signed-in customer to select the wallet owner; entering Owner Name in the generic wallet form does not establish that relationship. ### Read available funds carefully The payout service checks: ```text amount available for a new request = balance − held balance − already reserved balance ``` For a hypothetical wallet with `20` balance, `3` held and `5` reserved, a new request has at most `12` available. This is an explanation, not a captured balance from the exercise. The current wallet detail header labels `balance` as **Available Balance** without doing that subtraction. Read Held and Reserved alongside the headline. A server refusal can therefore be correct even when the headline appears large enough. ### Follow the lifecycle without skipping its evidence The real payout walkthrough depends on a connected recipient and a configured payment rail. No real or sandbox transfer was submitted in the captured exercise. Use this table to understand the existing request records and to prepare that separate provider session: | State or action | What it means in Appmint | What to check next | | --- | --- | --- | | `pending` | A request exists | Recipient, destination, currency, amount and reason | | `approved` | Approval passed | The execution method and available destination | | `processing` | Processing has started | Provider response or bank-batch membership | | `completed` | Appmint recorded completion | The external outcome and the wallet debit | | `failed` | The request did not finish successfully | Failure reason and whether funds were released | | `cancelled` | The request was cancelled | Reserved balance and the reason recorded | The payout drawer's source implements **Approve**, **Process** and **Cancel** actions for appropriate states. Their presence does not establish that your recipient or provider is ready. A completion entered by an operator is an assertion that a separate payment happened; it is not a way to send that payment. The current UI sends `approvedBy: admin`. Do not treat that text alone as the verified identity of the human approver. Hiding a Finance menu is also not a substitute for server permission enforcement or separation of duties. ## Part 5 — PRO: understand bank files, provider outcomes and API boundaries ### ACH Runs: a file is one stage of paying by bank Select **ACH Runs** in Finance. The top section is **Waiting for the next run**, followed by **Runs**. If either list fails to load, the screen displays an error instead of reporting zero payouts or runs. Select **Refresh** after resolving the connection problem. Previously loaded rows can remain visible, but **Build the file** stays disabled while loading or showing an error; use the refreshed list before building. ![Studio during a deliberately simulated request failure: the screen displays the error instead of claiming an empty list.](../application-fixes/assets/finance-local/ach-simulated-load-failure.png) ![ACH Runs with no eligible requests or existing files](assets/appmint-finance/06-ach-runs.png) The captured state is `0 · $0.00` and **No ACH run yet.** **Build the file** is unavailable with nothing eligible. This is the actual starting screen; there is no captured bank submission or settlement in this lesson. ### Build and download the training file The next captures show a later, completed local file rehearsal. Its recipient was a normally registered customer with a customer-owned wallet. The **$5.40** came from an explicitly labelled training adjustment, not a commission or delivery earning. Its fictional bank destination remained **pending/unverified**. This demonstrates file creation only; never submit the training file to a bank. For your own operational run, first establish the recipient, the earning reference, the customer's usable bank destination and the organisation's bank-file configuration. The customer requests a payout against their own wallet; an authorised operator reviews and approves it. An empty **Waiting for the next run** list is not a form for adding a bank account or inventing an earning. 1. Open **Finance → ACH Runs** after the request has been approved. Read every row under **Waiting for the next run**. The captured run contains **TRAINING ACH FILE ONLY**, one request, a masked account ending **0001**, and **$5.40**. Match the recipient, request number, destination and total to the request you approved. The button builds the eligible waiting requests together; check the whole list before proceeding. ![One approved training request is ready for a $5.40 file.](../application-fixes/assets/finance-local/ach-training-ready.png) 2. Select **Build the file**. The confirmation asks **Build a file for 1 payout?** and states the amount. Compare both count and total with the list. Select **Cancel** if either differs from your intended run. Building a file creates the payment instructions; it does not transfer money. ![The confirmation identifies the payout count and total before building.](../application-fixes/assets/finance-local/ach-training-build-confirmation.png) 3. Confirm **Build the file** once. Look under **Runs** for the resulting batch. In this rehearsal, **Batch 0000001** contains **1 entry · $5.40** and reads **Waiting to go to the bank**. The waiting count becomes **0 · $0.00**, because the approved request now belongs to a file. That zero means nothing is waiting for a new file—not that the recipient has received their money. ![The built batch is waiting for a bank handoff; File downloads it.](../application-fixes/assets/finance-local/ach-training-built-not-submitted.png) 4. Select **File** on that batch. Save the download with its batch reference so your finance operator can reconcile it. Downloading it again should retrieve the same file; do not build another run just to recover a download. The actual browser download in this rehearsal matched the API download and a repeat download byte for byte. 5. Check the file against the batch before any bank handoff. The training file contained **one credit of 540 cents**, **zero debit cents**, and **10 records of 94 characters**. Its payout reference and batch identifier matched the saved records. These local structural checks do not establish bank acceptance. 6. Keep the run at **Waiting to go to the bank** during this training exercise. **Sent to bank** records a handoff through your bank's process; **Settle** records an outcome that must already be supported by bank evidence. Neither is the next practice button to press. The checked wallet still held **$5.40 balance**, **$5.40 reserved**, and **$0 lifetime debits** after file creation. **Watch for:** a request can be `processing` because it is included in an ACH file while the file is still waiting for the bank. Read the request, the batch and the external bank outcome together. The local training destination's pending status also shows why file generation alone cannot prove ownership of a bank account. After building, reopen **Finance → Wallets** and read the payout summary for the file’s recipient. In this example it shows **$0.00 paid · $5.40 pending**. The reserved amount still belongs to a processing request. Only a completed payout contributes to **Paid**. ![The actual training wallet now shows the built file’s payout as pending, with zero paid.](../application-fixes/assets/finance-local/processing-payout-summary-fixed.png) The full operational sequence is: | Stage | Operational meaning | | --- | --- | | Approved bank requests waiting | Requests eligible to be included in a file | | File built | The NACHA file exists; money has not moved merely because it was generated | | File downloaded and sent | An operator submits the file through the bank's agreed process and records that handoff | | Bank outcome received | Reconcile the bank's confirmation with the individual requests | | Settled or returned | Record the actual outcome and check wallet/payout entries | The source's labels include **Waiting to go to the bank**, **With the bank**, **Settled** and **Partly returned**. Do not click a settlement action just to move a tutorial to its last screen. Keep the original file identifier, bank acknowledgement and affected payout references together. ### Reconcile the actual sandbox refund For the store rehearsal, open order **AZ1QTIMKN → Payments**. The current summary shows **Gross collected $53.00**, **Refunded $53.00**, **Net retained $0.00** and **Uncollected balance $0.00**. The original paid row stays in the history. The refund reference belongs to the money returned; do not mistake the retained payment row for a second charge or a missing refund. ![The actual refunded sandbox order, with collected and refunded amounts shown separately.](../application-fixes/assets/stripe-sandbox/11-refund-summary-fixed.png) The provider independently returned one succeeded Stripe refund for 5,300 USD minor units against the original test payment, with `livemode: false`. [Follow the order refund steps](appmint-sell-custom-products-online.md#refund-the-payment-and-reconcile-the-result) for the amount, reason, single submission and readback. This is separate from recording an RMA's inspection and resolution. ### Check the individual provider outcome For PayPal, keep both the batch reference and the item's result. A batch identifier is a lookup key, not the answer to “did this recipient receive the money?” PayPal's batch-details endpoint includes individual item statuses, including outcomes that require attention. [PayPal: show payout batch details](https://developer.paypal.com/api/payments.payouts-batch/v1/payouts-get), [PayPal: transaction statuses](https://developer.paypal.com/api/payments.payouts-batch/v1/definitions/transaction_enum/). For a customer refund, confirm the actual refund status as well as its reference and amount. Stripe distinguishes pending, succeeded, failed and other refund states; a submitted refund is not automatically a successful one. [Stripe: Refund object](https://docs.stripe.com/api/refunds/object?api-version=2025-04-30.preview). Appmint's inspected payment-service paths still return pending placeholders for several PayPal and Helcim operations. A locally returned `paypal_pending` or `helcim_pending` is not evidence of a provider refund. Keep those integration gaps separate from a provider's genuine pending transaction. ### Developer checkpoint: canonical owners and customer authentication The server's dedicated wallet-creation path stores the owner in the record's top-level `owner` object. Its `createWallet` implementation currently uses the customer datatype regardless of the supplied owner-type label. Owner lookups use the owner ID. The Modern wallet editor used in the practice instead saves `data.ownerId`, `data.ownerType` and `data.ownerName` through generic record saving. The resulting record had no top-level owner object. That explains the blank header and why this practice record is unsuitable as a connected payout recipient. For a production integration, create or retrieve a wallet through the appropriate earning/customer workflow, then read it back and confirm the canonical owner before crediting money owed. Verify existing records before assuming a generic form has linked them correctly. These API surfaces have different audiences: | Surface | Purpose | | --- | --- | | `POST /finance/wallets` | Administrative wallet creation for an existing owner identity | | `POST /finance/wallets/:walletId/credit` | Internal ledger credit with amount, category, reference and description | | `POST /finance/wallets/:walletId/debit` | Internal ledger debit; not a customer card refund | | `GET /finance/wallets/:walletId` | Wallet, transaction and summary data | | `POST /finance/wallets/:walletId/request-payout` | Request against spendable balance and a saved destination | | `GET /client/finance/wallet` | The authenticated customer's wallet, transaction rows and calculated summary | | `GET /client/finance/payout-methods` | The customer's destinations; creates their zero-balance customer wallet if absent | | `POST /client/finance/payout-methods` | Add a destination to that customer wallet | | `PUT /client/finance/payout-methods/:methodId` | Rename, select or disable an owned destination | | `DELETE /client/finance/payout-methods/:methodId` | Remove an owned destination | | `GET /client/finance/payouts` | The customer's payout history | ### Connect the signed-in earner and save their own destination This is an API exercise for the developer connecting the earner portal. It does not require a customer to open source code. The Studio wallet drawer has no destination-entry form, so do not look for an **Add method** button there. **Prepare:** use a controlled customer in your training organization and the `API` origin and `ORG` organization ID from [Build a connected web or mobile client, Part 1](appengine-build-a-connected-web-or-mobile-client.md#part-1-read-real-customer-data). That lesson explains ordinary customer signup and sign-in. A Studio employee account and a customer account are different identities. For a real recipient, the recipient signs in with their own customer credentials; the developer must not substitute an owner token. 1. Sign in through `POST /profile/customer/signin`, with the `orgid` header and JSON fields `email` and `password`. Keep the response private. A successful local sign-in returned **201** and a `token`; retain that value as `CUSTOMER_TOKEN`. If the response requires another verification step, complete it before continuing. ```bash curl -sS -X POST "$API/profile/customer/signin" \ -H "orgid: $ORG" -H 'Content-Type: application/json' \ --data '{"email":"","password":""}' ``` 2. Confirm the identity before opening their money records. Read `GET /client-data/profile` with `Authorization: Bearer $CUSTOMER_TOKEN` and `orgid: $ORG`. Check `data.email` and retain `sk`, the customer ID. Do not continue if this is a different person. ```bash curl -sS "$API/client-data/profile" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" curl -sS "$API/client/finance/wallet" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" ``` A new customer can receive **200 with no wallet record** on the second request. That is not a failed login. The next endpoint prepares that customer's wallet when needed. 3. Read `GET /client/finance/payout-methods` with the same headers. The response is an **array**, initially `[]`. This call creates a zero-balance customer wallet if none exists. Repeating the read reuses the wallet. Read `/client/finance/wallet` again and compare `wallet.owner.id` with the profile's `sk`; `wallet.owner.datatype` should be `customer`. 4. In training only, add a clearly marked nonpayable destination. This demonstrates storage without supplying bank details or sending money. If a matching training method already exists, reuse its ID instead of creating a duplicate. `label` is for recognizing the destination; `paypal.email` is the destination address. ```bash curl -sS -X POST "$API/client/finance/payout-methods" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" \ -H 'Content-Type: application/json' \ --data '{"type":"paypal","label":"Tutorial destination — not payable","paypal":{"email":"earner.finance-tutorial@example.invalid"}}' ``` The checked response was **201**, with a generated `id`, `status: "pending"` and `isDefault: true` for the first method. Save that generated ID as `METHOD_ID`. A pending saved address has not been verified by PayPal. Real recipients must supply their own usable destination through your authenticated portal and complete its supported verification process. 5. Read `/client/finance/payout-methods` again. Find `METHOD_ID`, compare its type, label and address, then read `/client/finance/wallet`. Confirm the canonical owner remains the signed-in customer and `summary.availableBalance` remains zero. The destination operation must not create an earning. In Studio, reopen **Finance → Wallets** to inspect the customer's separate row; the standalone `TUTORIAL-LEDGER-01` record should remain unchanged. 6. A customer can rename or choose their owned method with `PUT /client/finance/payout-methods/$METHOD_ID` and JSON such as `{"label":"My payout address","isDefault":true}`. Verification belongs to the trusted provider/admin workflow. The local application now rejects a customer's `status: "verified"` update with **403**. Customers may disable their own method with `{"status":"disabled"}`; they cannot reset a disabled method to pending to bypass verification. A saved address or a self-declared status is not evidence of control of an account. 7. Clean up the nonpayable practice method when the checks are complete: ```bash curl -sS -X DELETE "$API/client/finance/payout-methods/$METHOD_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" curl -sS "$API/client/finance/payout-methods" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" ``` Expect `success: true`, then an empty array if it was the only method. The zero-balance customer wallet remains. Do not delete a real destination with pending payout obligations as a practice task. **The boundaries we checked:** a customer bearer alone reached their Finance routes; no staff or application bearer was required. No bearer returned **401**. A customer requesting the staff wallet-list endpoint returned **403**. Another controlled customer's attempt to rename this customer's destination returned **404**. These checks use actual authenticated accounts in the same organization, not invented owner IDs. **Before requesting a real payout:** use `POST /client/finance/payouts/request` with the customer's bearer and JSON fields `amount`, `methodId` and optional `notes`. The example training wallet has no earned funds and its destination has been removed, so do not submit that request here. First establish the genuine earning, available balance and verified destination, then complete the separate provider test session. Request creation, approval, execution and settlement remain distinct checks. ### Developer branch: prepare the customer-owned ACH request This is the setup used for the **Build and download the training file** sequence above. It uses the API because no bank-originator setup screen was exercised. It is separate from the zero-balance PayPal destination exercise; do not reuse that deleted method ID. 1. Use the normal customer signup/sign-in and identity checks above. Read `/client/finance/payout-methods` to establish the canonical customer wallet. Add a bank method with the same customer's bearer and organisation header: ```http POST /client/finance/payout-methods Authorization: Bearer orgid: Content-Type: application/json { "type": "bank", "label": "TRAINING FILE ONLY — DO NOT PAY", "bank": { "bankName": "TRAINING ONLY", "accountHolderName": "TRAINING FILE ONLY", "accountType": "checking", "routingNumber": "123456780", "accountNumber": "TRAINING0001" } } ``` These values are fictional local file-validation inputs, **not bank-approved sandbox details**. Keep them out of live business records and bank submissions. Save the returned method `id`, then read the methods again. The actual saved status was `pending`; do not change it to verified to make the lesson proceed. 2. Prepare the organisation's originator settings with an owner session. The executed local request used `POST /repository/create` with the owner's bearer, the same `orgid`, JSON content type and this body: ```json { "datatype": "setting", "isNew": true, "data": { "type": "payout_ach", "ach": { "originatorName": "TRAINING ONLY", "originatorId": "TRAINING01", "odfiRoutingNumber": "123456780", "companyEntryDescription": "TRAINING", "immediateDestinationName": "TRAINING ONLY", "immediateOriginName": "TRAINING ONLY" } } } ``` Inspect existing organisation configuration before creating a setting; reuse an existing training setup. An active ACH provider configuration takes precedence over this fallback setting. The rehearsal had no active organisation ACH provider and omitted `notifyEmail`. The application's existing finance encryption key remained in place. Do not replace encryption keys while setting up a payout method. 3. Establish the wallet's available balance. In this file-only rehearsal an owner added a clearly described **5.40 adjustment**, using the ledger controls explained earlier, to the **canonical customer's wallet**. This is the reason the request could reserve 5.40; no earning was invented. For a real payout, trace the qualifying earning instead. Reopen the wallet and check ownership, currency, available amount and existing reservations before submitting. 4. Request the payout as the customer—not as the owner. Send `POST /client/finance/payouts/request` with the customer's bearer, the same organisation header and JSON: ```json { "amount": 5.4, "methodId": "", "notes": "TRAINING FILE ONLY — not verified, not submitted, not an earning" } ``` Retain the returned payout identifier. Read the wallet and request again; the same request must account for the reserved amount. If the response is unclear, inspect existing requests before retrying. 5. Review as the authorised owner, using a separate owner session. The executed approval was `POST /finance/payouts//approve` with the owner's bearer, organisation header and JSON: ```json { "approvedBy": "", "notes": "Training file controls only. No bank verification or submission." } ``` The `approvedBy` text is not authentication; the bearer and server permissions govern the action. After approval, open **Finance → ACH Runs** and match the request to the waiting row before following the illustrated build/download steps. Do not call a processing, sent or settlement endpoint to manufacture the final state. **Result:** one customer-owned request produced one local training file. Its **5.40 remains reserved**. The file, destination and approval have separate meanings; none certifies a real earning or completed transfer. Preserve the review batch for inspection instead of recreating the request to obtain another screenshot. ## If something goes wrong | Symptom | First check | What to do | | --- | --- | --- | | Take Payment opens a blank request | Have amount, payer, purpose and collection method been selected? | Prepare those fields or choose an existing Invoice/Order; do not record money as received before it actually arrived | | Gateway is empty | Does it also say No payment gateways configured? | Set up the intended provider before expecting provider transactions | | Wallet owner displays a dash | Did the generic editor save only data-level owner fields? | Check canonical ownership before using it for payouts; keep the lab wallet standalone | | Balance changed, but no customer payment exists | Was the action Add Credit or Add Debit? | Read Wallet Transactions; those actions change an internal ledger | | Total Debits is zero after the practice reversal | Was the debit an adjustment? | Reconcile the transaction rows; the lifetime field omits this category in the inspected build | | Another debit is refused | Is the balance already zero? | Do not repeat it; verify that the failed request added no transaction | | Request Payout is disabled | Is the balance zero or the method list empty? | Inspect the actual earning and recipient setup; do not manufacture a balance | | A request exceeds available funds | Are funds held or reserved? | Compare the server's spendable amount with all three balance fields | | A weekly policy does nothing by itself | Does the screen display the no-timer notice? | A saved frequency is not a background scheduler in this build | | A payout or refund is reported as complete locally | Can you match its provider/bank outcome? | Reconcile the external result and local entry before telling the recipient it arrived | ## What happened behind the scenes
Software records and current source paths Paths below are under the software checkout, not the documentation tree. - `websitemint/packages/ui/src/components/finance/app.tsx` defines the eight tabs and aggregates loaded lists for dashboard totals. - `finance/wallet-form.tsx` saves Modern fields through generic record saving; `finance/wallet-list.tsx` reads the separate canonical owner, renders balances, and submits credit/debit requests. The header uses `data.balance`; it does not subtract Held and Reserved. - `finance/transaction-list.tsx` renders the ledger table and Transaction Details drawer. The captured credit and debit have distinct transaction numbers, categories, descriptions and balance transitions. - `finance/payout-rules.tsx` writes a `setting` record with `type: payout_config`, approval policy and schedule. It exposes an explicit cycle action and a no-timer message. - `finance/payment-list.tsx` embeds `finance/take-payment.tsx`, which provides the current Payment request form. The older storefront-only empty-state capture is historical. - `appengine/src/finance/payout.service.ts` implements wallet credits, debits, holds, reserves and payout transitions. Adjustment debits change balance without incrementing `lifetimeDebits`; payout debits increment that field. Recipient notifications are conditional on a resolved email. - `appengine/src/finance/payout.controller.ts` exposes administration endpoints. `finance-client.controller.ts` and `finance-client.service.ts` implement the customer-facing boundary and payout methods. - `appengine/src/finance/payout-batch.service.ts`, `nacha.ts` and `executors/paypal-payout.executor.ts` implement bank-file and PayPal execution paths. These were inspected, not exercised with an external destination in this capture. - `appengine/src/finance/payment.service.ts` contains payment-provider operations and the pending placeholder paths discussed above.
## Where next - [Sell personalised products online](appmint-sell-custom-products-online.md): prepare the order and storefront flow that can produce customer payment records. - [Run a customer campaign](appmint-run-a-customer-campaign.md): connect a real affiliate earning story to an identified recipient. - [Reconcile and close BusinessMade's books](businessmade-reconcile-and-close-the-books.md): the separate accounting workflow when your business uses BusinessMade. For filming, use the [companion production guide](production/appmint-manage-payments-and-payouts.md). It identifies the real captures and the provider sequences still requiring a separate recording session.
Capture evidence and scope — 18 September 2026 Practiced on local Studio Manager 0.6.2 and API port 3300 in organisation `learnmu4qn1ha`, using the registered training owner. Actual UI actions: eight-tab navigation; empty Payments; standalone Take Payment; empty Gateway; empty ACH Runs; manual payout policy saved; Modern practice wallet created; 5.40 adjustment credit; 5.40 adjustment debit; refused 1.00 debit; transaction detail inspected. Readback saved in `assets/appmint-finance/ledger-readback.json`; still metadata in `evidence.json`. Wallet `6aacef08db0d9b7a8a94a0a5`, number `TUTORIAL-LEDGER-01`, has no canonical owner, email or payout method. Its data-level owner marker `66eaa0000000000000000018` is intentionally fictional and was not a customer account. Final balance zero. Credit `SF4VZD7I6IYE`; reversal `I8FZ0WIBBWA9`. The failed debit created no third entry. Payout rules read back as manual/manual with no automatic threshold. No paid order, qualifying affiliate commission, card charge, customer refund, payout request, approval, provider transfer, bank destination, ACH batch or scheduled cycle was executed. Those earlier topic promises are replaced with the observed training ledger and explicitly bounded provider/developer material. No notifications were sent from the email-free wallet exercise. No video file is claimed; the production guide is preparation for a later recording. Concurrent source changes caused repeated Vite reloads; the tutorial Vite process was restarted with watching disabled to keep the capture stable. The application source was not edited. Reported gaps are date/build-specific and are logged in research for retesting.
Fresh-account repeat — 24 September 2026 Rehearsed in a separately registered local learner organization with normal owner/customer sign-in. Manual approval and on-demand rules survived leaving and reopening the tab. Created the course's standalone practice wallet at zero, credited 5.40 once, debited 5.40 once, then confirmed a 1.00 debit was refused. The transaction list contained only the two completed adjustments. The customer API exercise established a separate canonical customer wallet and pending fictional destination; customer-versus-staff and two-customer ownership checks passed. ![Fresh local rehearsal: the two explained adjustments](../application-fixes/assets/finance-local/07-two-transactions.png) ![Fresh local rehearsal: manual rules after reopening](../application-fixes/assets/finance-local/09-rules-reopened.png) The [local walkthrough report](../application-fixes/finance-live-walkthrough.md) records the application verification issue discovered while testing destinations and its fix status. No checkout, provider transfer, bank file or settlement is claimed by this repeat.
Sandbox payment and ACH file acceptance — 24 September 2026 The later Stripe rehearsal completed a real test-provider checkout on order AZ1QTIMKN for $53 and matched the operator payment row to a succeeded test intent. The ACH rehearsal used a separate canonical customer wallet, synthetic $5.40 adjustment and pending fictional bank method. Request, approval, file build and actual browser download passed. The batch remained built, the request processing, and no bank submission or settlement occurred. [File validation evidence](../application-fixes/assets/finance-local/ach-training-file-proof.json) records the totals, repeat-download match and duplicate-build refusal. These later passes supersede the earlier empty-screen scope only for the actions explicitly listed here.
--- # Plan a local delivery: define the area, check the price and prepare the job > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-manage-deliveries.html # Plan a local delivery: define the area, check the price and prepare the job ![A real delivery quote in the training organisation](../application-fixes/assets/delivery-requote-fixed.png) **For:** owners and dispatchers introducing local delivery. **Time:** 45–60 minutes for the planning exercise. **Level:** beginner, with integration notes for developers. **Product:** Appmint Studio Manager → Logistics Center. **Build checked:** local Studio Manager 0.6.2, 21 September 2026. Cedar & Form needs to move a sample board across Lekki. Before calling a driver, the dispatcher should be able to answer four questions: are both addresses in our service area, what will the customer pay, what is allocated to the driver, and where is the saved job? This lesson answers those questions with a real zone, an actual quote and a saved training job. It also tries an address outside the zone. You will see how the refusal clears the earlier quote and prevents saving until you correct the route and obtain a fresh price. ## What you will have at the end - An active four-mile zone named `tutorial_lekki_phase1`, with its boundary reopened and checked. - A pickup and drop-off draft with contacts, instructions and one sample-board item. - A successful route quote and an outside-area refusal. - A saved job, its creation timeline, and a cancellation that leaves the record available for review. - A clear view of the additional customer, agent and mobile-app setup required before dispatch. The practical exercise ends with the training job **Cancelled**. It does not send a driver to either address, charge a customer, create a proof-of-delivery photo or pay driver earnings. ## What you need Complete [Create your Appmint organisation](appmint-welcome.md) first. Sign in to [Studio Manager](https://studio.appmint.io/) with your **training organisation** owner account. Before continuing, check the organisation ID in the account area and open **Logistics**. If that entry is unavailable or access is denied, have the organisation administrator enable the relevant module/access before creating records; use the [roles and permissions course](appmint-roles-groups-and-permissions.md) to prepare a colleague rather than borrowing another person's session. No existing paid order is needed. The example contacts are fictional, and the job will have no customer email or linked driver. Use an isolated training organisation with no other active service areas covering the airport test address. Inspect the existing zones before beginning. If a previous run already created your exact `tutorial_lekki_phase1` / `TUT-LEKKI` zone, reopen and verify its boundary and active state instead of creating a duplicate. If the name belongs to another exercise, use a unique suffix for your own zone and keep that same name throughout; do not change unrelated zones. The map preview and the server's address/route calculation are separate services. In the captured organisation, the preview had no available Google Maps key and displayed a service-agreement notice. The server still returned a valid geocoded quote. The numeric zone exercise can proceed without pretending the preview map loaded. ## The story Ife dispatches sample boards for Cedar & Form. Today's practice pickup is **12 Admiralty Way, Lekki, Lagos, Nigeria**; the drop-off is **4 Freedom Way, Lekki, Lagos, Nigeria**. She will compare that local trip with a deliberately unsuitable airport drop-off, then leave an explained training record for the next dispatcher. Use the supplied addresses for this non-dispatched exercise. Replace the fictional contacts and instructions before using a similar job for an actual delivery. ## The route ```mermaid flowchart LR A[Save and activate service area] --> B[Add pickup and drop-off] B --> C[Get System Price] C --> D{Both stops serviceable?} D -- Yes --> E[Save job and inspect timeline] D -- No --> F[Correct address or decline the job] E --> G[Cancel this training job] E -. real dispatch requires more setup .-> H[Linked agent, availability, assignment and proof] ``` ## Part 1 — Define where the business delivers ### 1. Open Logistics In Studio Manager's sidebar, open **Logistics**, then its dashboard entry. The in-page heading is **Logistics Center — Manage deliveries, agents, and zones**. ![The Logistics Center dashboard before setup](assets/appmint-deliveries/01-logistics-dashboard.png) Use the in-page tabs for the rest of the lesson: | Tab | Its role | | --- | --- | | **Dashboard** | Overview of currently loaded zones, agents and jobs | | **Live Map** | Operational map and job/agent view | | **Zones** | Service-area definitions | | **Agents** | Delivery-agent records and their customer identity | | **Jobs** | Pickup/drop-off work, prices, assignment and history | | **Merchants** | Entry point to merchant billing accounts in CRM | | **Config** | Delivery pricing, driver allocation and operating configuration | A new organisation shows no jobs or agents. An empty map is expected before there are operational records to place on it. ### 2. Open a new zone Select **Zones**, then **Add Zone**. The **Create Zone** drawer opens in Modern view on **Details**. Other tabs include **Geography**, **Operations** and **Pricing**. Enter these identity fields: | Field | Value | Why | | --- | --- | --- | | **Name** | `tutorial_lekki_phase1` | Stable internal identifier; no spaces | | **Title** | `Tutorial Lekki sample-board delivery` | Recognisable label in the list | | **Code** | `TUT-LEKKI` | Short reference for the exercise | | **Description** | `Training zone for a four-mile sample-board delivery exercise.` | Makes the record's purpose explicit | ![Zone identity entered in Details](assets/appmint-deliveries/02-zone-details.png) Set **Type** to **Custom** for this deliberately defined training area. The selector also offers City, Region and Postal; the actual service boundary is configured separately in Geography. The title “Lekki” alone does not create a geographic area. > **Watch for:** new zones start **Inactive**. Keep that status while entering the boundary, then explicitly activate the saved zone. An inactive zone is not an operational service area. ### 3. Define the radius Select **Geography** inside the zone drawer. Choose **Radius** from the shape choices. The screen also offers **Polygon**, **Zip Codes**, **Cities**, **States** and **Countries**; this exercise uses a circle so the boundary is easy to test. Enter: | Control | Value | | --- | --- | | **Latitude** | `6.4474` | | **Longitude** | `3.4700` | | **Radius (mi)** | `4` | ![Numeric centre and four-mile radius](assets/appmint-deliveries/03-zone-radius.png) The unit is **miles**, as stated by the control. The radius measures distance from the centre; it is not the length of a driving route. A road trip between two points inside the circle can take a longer route around the streets. If the preview shows **Map Error**, keep the distinction clear: the numeric boundary can be saved while the map display still needs configuration. Do not describe a visible circle if the map did not render. An authorised business administrator should review the actual service agreement and integration setup before enabling that provider for the organisation. ### 4. Set the zone's time zone Select **Operations** in the zone drawer. Enter `Africa/Lagos` in **Timezone**. ![The zone's Operations screen and time zone](assets/appmint-deliveries/04-zone-operations.png) The screen also has a weekly schedule, holidays and restrictions. We do not change them in this exercise. The inspected quote-validation path uses the geographic boundary; do not rely on a saved holiday or schedule alone to refuse an out-of-hours booking. ### 5. Save the inactive zone Press **Create Zone**. The application shows **Zone created successfully**. The saved record remains Inactive. You can activate it in this same drawer; its saved identity is now available to the action. ![Saved inactive zone before activation](../application-fixes/assets/delivery-zone-inactive-fixed.png) This verification still uses the separate `tutorial_lekki_activation_check` record to test a new save without duplicating the main zone. Use your own saved zone throughout your exercise. ### 6. Activate the saved zone In the saved zone drawer, press **Activate**. You should see **Zone activated successfully**, an **Active** badge and a **Deactivate** button. Close the drawer with the upper-right **×**, reopen your saved zone, then select **Geography**. Check that the saved centre and radius still read `6.4474`, `3.47` and `4`. ![The reopened boundary after activation](../application-fixes/assets/delivery-zone-boundary-readback.png) Close the drawer. Press the circular-arrow refresh button beside **Add Zone** if the list still shows the old status. The row should now read **Active**. ![Fresh zone list after activation](assets/appmint-deliveries/08-active-zone-list.png) Activation now refreshes the list as well as the drawer. If a request fails, keep the existing record and retry after resolving the displayed error; do not create a duplicate. **Try it:** close the zone, reopen it, and read the radius without changing it. Can you point to the saved value rather than the draft you remember typing? **Check yourself:** does a saved title or a map pin prove the destination is inside the service area? No. The boundary and the quote's serviceability check decide that. The next part tests the actual addresses. ## Part 2 — Build a useful delivery draft ### 1. Create a job and add both stops Select **Jobs** in the Logistics tab strip, then **Create Job**. ![A new job starts with zero stops](assets/appmint-deliveries/07-empty-job-form.png) The captured form begins with **Stops (0)**. Press **Add Pickup**, then **Add Dropoff**. The heading becomes **Stops (2)**, with separate Pickup and Dropoff panels. Leave the top **Customer** section blank for this internal training job. In an actual customer job, those fields identify the person requesting delivery; the stop contacts identify the people at the doors. They are not necessarily the same person. This exercise supplies no customer email, so it cannot serve as a customer-notification or tracking-login test. ### 2. Enter the pickup In **Pickup**, fill: | Field | Training value | | --- | --- | | **Address** | `12 Admiralty Way, Lekki, Lagos, Nigeria` | | **Contact Name** | `Tutorial Ife` | | **Contact Phone** | `+12025550144` | | **Contact Email** | Leave blank | | **Time Window** | Leave **Asap** selected | | **Instructions** | `Training pickup only. Sample board in reception; do not dispatch.` | The phone is a fictional example for the training form. Use a reachable, agreed pickup contact for real work. Typing a phone into this job is not an instruction to call or message it. Under the pickup's **Items (0)**, press **Add Item**. Set **Description** to `Tutorial oak sample board` and leave **Qty** at `1`. ![Pickup, contact, instructions and sample-board item](assets/appmint-deliveries/09-pickup-details.png) The item fields include weight and handling options such as **Fragile** and **Heavy**. Add actual handling requirements when you know them. Do not invent a weight to make the form look complete. ### 3. Enter the drop-off In **Dropoff**, fill: | Field | Training value | | --- | --- | | **Address** | `4 Freedom Way, Lekki, Lagos, Nigeria` | | **Contact Name** | `Tutorial Maya` | | **Contact Phone** | `+12025550145` | | **Contact Email** | Leave blank | | **Time Window** | Leave **Asap** selected | | **Instructions** | `Training drop-off only. Confirm the recipient before handover.` | ![Local address restored; the previous outside-area error remains until Get System Price is run again](assets/appmint-deliveries/13-dropoff-restored.png) Each stop owns its own contact and instructions. Avoid putting the recipient's directions only in an internal note where a driver may not see them. The new-job form inspected here did not expose the old topic's proposed **Delivery Type: photo_required** or **Source reference** controls. Do not search indefinitely for those fields in this screen; the developer section explains the difference between the model and the actual form. ### 4. Add an internal reference Expand **Notes** near the bottom. In **Internal Notes**, enter: ```text TUTORIAL-SAMPLE-01 — route-planning practice only; no customer order, charge or dispatch. ``` Leave **Customer Notes** blank. The first is for your operators; the second is described as visible to the customer. Use a real order reference in the appropriate operational record when connecting a real sale, and verify that connection instead of assuming the job was created by checkout. ## Part 3 — Quote the route, then test the boundary ### 1. Open Pricing Expand **Pricing**. The text says **Set pricing manually or use system calculation based on stops.** ![Pricing before requesting a system quote](assets/appmint-deliveries/10-pricing-before-quote.png) There are two groups: **Customer Pricing** and **Driver Pay**. The first is the amount calculated for the customer; the second is the driver's allocation. Neither group shows that money has been collected or paid. ### 2. Get the system price Press **Get System Price**. Wait for **Calculating…** to finish and the button label to return. **If the request fails:** a permission error, provider configuration/service-agreement error, timeout or network error is not an outside-area result. Do not save the job or manually enter the example $15 to continue. Record the exact error and ask your organisation administrator to check the configured geocoding/routing integration through Gateway Manager. Retry the unchanged practice addresses after that problem is corrected and require a fresh successful quote. The pictured author's successful quote does not establish provider readiness for your company. ![Successful quote: customer total becomes 15 dollars](../application-fixes/assets/delivery-requote-fixed.png) For the captured route, the actual response was: | Result | Observed value | | --- | --- | | Address validation | Both stops serviceable in `tutorial_lekki_phase1` | | **Customer Pricing → Total (calculated)** | `$15.00` | | **Driver Pay → Total (calculated)** | `$11.25` | | Route distance in the quote response | `3.03` miles | | Route duration in the quote response | `11.12` minutes | | Route source in the quote response | `google` | The UI displays the pricing groups; the distance, duration and route-source values above come from the captured quote response, not from a distance label on this form. Route providers and pricing configuration can change your result. Read your returned result rather than typing these totals over it. > **Watch for:** the map preview still reported a missing key while the server returned this quote. A failed preview and a failed quote are different problems. Check the result of the action you actually requested. ### 3. Try an outside-area drop-off Replace only the drop-off **Address** with: ```text Murtala Muhammed International Airport, Ikeja, Lagos, Nigeria ``` Press **Get System Price** again. The captured response is **Stop 2: Location is outside our delivery service area**. If your quote instead succeeds, inspect your active zones: a different, broader zone can legitimately cover that address. Repeat this boundary test in an isolated practice configuration, without disabling someone else's operational zone. ![Outside-area refusal clears the previous price and disables Create Job](../application-fixes/assets/delivery-outside-area-fixed.png) The pickup remains serviceable; the airport drop-off does not. The response has `valid: false` and identifies stop index `1`, displayed as **Stop 2** to the operator. The earlier base prices are cleared and **Create Job** is disabled. In this exercise, with no tips or adjustments, the displayed totals return to `$0.00`. That zero is an empty calculation, not a free airport delivery. The error means this route cannot be saved as a system-quoted job. > **Watch for:** changing an address also invalidates the quote immediately. Even if you restore the earlier text, press **Get System Price** again. Dismissing an error message does not make the route valid. ### 4. Restore and re-quote the local route Replace the drop-off Address with `4 Freedom Way, Lekki, Lagos, Nigeria` again. Press **Get System Price** and wait for it to finish. The outside-area error disappears, customer pricing returns to `$15.00`, driver pay returns to `$11.25`, and **Create Job** becomes available. Your organisation’s configured rates may differ. Restoring the address alone keeps creation disabled until the fresh quote succeeds. ![Fresh successful re-quote: both totals visible and Create Job enabled](../application-fixes/assets/delivery-requote-fixed.png) **Try it:** identify which stop failed without changing the pickup. Use the error's stop number and the two stop headings. **Check yourself:** the map can find the airport, but the quote refuses it. Can Ife promise this delivery? No. Geocoding found an address; the zone check rejected it. The refusal is the decision that matters. ## Part 4 — Save, reopen and close out the training job ### 1. Create the job With the restored route successfully quoted, press **Create Job** at the bottom of the drawer. **Job created successfully** appears. ![Saved job retains the quoted route](../application-fixes/assets/delivery-route-fixed-created.png) The locally verified job number is `RVK9LPZD77`; yours will be generated separately. The job is **Pending** and **Unassigned**. Its customer price is `$15.00`, and its driver amount is `$11.25` with earnings pending. Save the generated number in your exercise notes. `TUTORIAL-SAMPLE-01` is your internal reference; it does not replace the generated job number. ### 2. Read the history before dispatching Close the drawer and reopen the saved job from **Jobs**. Select **Timeline (1)**. ![The saved job's creation event](../application-fixes/assets/delivery-created-timeline-fixed.png) The timeline contains **Job created**, with a timestamp and the creating training account. This is the beginning of an operational history, not evidence of acceptance, pickup or delivery. Return to **Details**. Check both addresses, Ife and Maya’s contact details, the sample-board item and its quantity. Expand **Notes** and read your internal reference. Under **Live Tracking**, the verified job shows `3.03 mi` and `~11.12 min`, matching the successful quote. The saved record also retains both stop coordinates and the service zone. These are planned route estimates; no driver location or actual journey has been recorded. ![Route distance and duration after reopening the saved job](../application-fixes/assets/delivery-route-fixed-readback.png) > **Watch for:** a successful quote does not mean every geocoded coordinate or tracking field was persisted. Reopen the job and check the fields your dispatch process depends on. ### 3. Cancel the training job Use **Cancel** beside **Broadcast** in the job's upper action area. This is the job action, not a footer button that merely closes a draft. In the observed build, this action cancelled the job without asking for a custom reason. The screen changed to **Cancelled**. The source submits the reason `Cancelled by admin`. Close the drawer and check the row in **Jobs**. ![The retained job after cancellation](../application-fixes/assets/delivery-cancelled-jobs-fixed.png) Cancelling the job does not remove the active training zone or any delivery configuration created during setup. Record those retained settings in your practice notes and reuse/verify your own zone on the next run; do not assume the whole exercise was reset. The cancelled job keeps its quote and history. Its driver amount can still show **pending** because that is a separate earnings field; it does not mean a driver earned or received that amount. ### 4. Check the cancellation timeline Reopen the same job and select **Timeline (2)**. You should now see creation and cancellation events. ![Creation and cancellation remain available for review](../application-fixes/assets/delivery-cancelled-timeline-fixed.png) This is the completed planning exercise: a service-area check, a saved route job and an explained end state. No driver was contacted, no customer payment was taken and no wallet was credited. **Try it:** find the job again from its generated number. Explain why deleting the row would lose a useful record of the exercise. **Check yourself:** does the saved `$11.25` mean a driver should receive a payout? No. There was no assigned driver, completed delivery or earned wallet credit. Follow the actual earning record before creating a payout. ## Part 5 — Prepare real dispatch: identity, availability and the driver app ### Inspect the agent entry point Select **Agents**, then **Add Agent**. The new human-agent form starts on **Details**. ![Human agent setup requires a linked customer](../application-fixes/assets/delivery-agent-identity-review.png) The form's **Select Customer** field says **Agent identity (name, email, phone) comes from the linked customer record**. The agent is not an unrelated name typed into a driver list. Its customer identity is what the driver-side sign-in must represent. The visible tabs are **Details**, **Vehicles**, **Documents** and **Schedule**. Details also has **KYC Verification**, capacity fields and **Assigned Zone**. This capture stops before selecting a real customer or creating an agent; it does not show an approved driver. For a real fleet, prepare the actual driver account and complete the required identity/document review. Verify the saved agent, the linked customer, status, availability, vehicle and service zone before offering work. Do not enter a fabricated customer ID or mark documents verified merely to finish a tutorial. ### Keep the dispatch stages separate The operational sequence to verify with your linked test driver is: | Stage | What the dispatcher needs to establish | | --- | --- | | Agent approved | The review is complete for the correct customer identity | | Agent online | The driver is available in the intended zone | | Offer or assignment | The job is offered to or assigned to the intended agent | | Acceptance | The agent has accepted the work and has capacity | | Pickup progression | Arrival and collection are recorded against the pickup stop | | Drop-off progression | Arrival, recipient handover and required proof belong to the drop-off stop | | Job completion | The job outcome and driver earning agree | | Payout | The separate Finance workflow transfers an eligible wallet balance | The saved job UI shows actions and progression labels, but clicking an administrative status is not a substitute for the real driver-side confirmation. The training exercise did not execute this dispatch sequence. ### Use the correct mobile software The driver implementation reviewed for this workflow is the Flutter project **`appmint_go/dfw_errand`**. It is separate from **Appmint Mobile**, which the other courses use for CRM, softphone and business operations. Installing Appmint Mobile does not demonstrate this driver's accept/pickup/drop-off screens. No verified public `dfw_errand` download link was established for this course. Do not distribute an Appmint Mobile or EventOxygen link as if it installs the delivery driver app. A developer distributing a company driver build must confirm its AppEngine endpoint, organisation, customer sign-in, permissions and notification setup, then test the full route with a controlled driver account. The source references below identify the implementation; the companion guide identifies the missing mobile footage. ## Part 6 — PRO: pricing, merchant accounts and integration gaps ### Read the configuration that produced the quote Select **Config**. After the first successful quote, the captured organisation had one configuration named **Default**. ![Default delivery configuration created by the quote path](assets/appmint-deliveries/19-default-delivery-config.png) Its row shows **Min $15**, **4 tiers**, **75%** and **Min $10**. The source creates a default configuration on first use when none exists. The row displayed Inactive while that configuration was still used for the quote; its missing status and its use as the default are separate in the inspected implementation. Open **Default** and expand **Pricing**. ![The delivery configuration's pricing fields](../application-fixes/assets/delivery-config-pricing-review.png) The initial source defaults, matching the captured 3.03-mile quote, are: | Maximum distance | Flat rate | Per-mile rate | | --- | ---: | ---: | | 5.5 miles | 15 | 0 | | 10.5 miles | 0 | 2.85 | | 20.5 miles | 0 | 3.15 | | No maximum | 0 | 3.45 | Currency is USD and minimum delivery fee is 15. These are software defaults, not a recommendation for your business's prices. Re-quote known short and long routes after changing your configuration. Expand **Driver Payout**. The fields are **Percentage**, **Minimum Pay** and **Tip Share**. ![Driver allocation settings in the delivery configuration](../application-fixes/assets/delivery-config-driver-review.png) The default percentage is `75`, minimum pay `10`, and tip share `100`. For this short route, `15 × 75% = 11.25`, above the minimum. The amount is an allocation in the quote. It only becomes an earning through the appropriate completed-job workflow, and a payout remains a separate operation. Close this editor without saving changes. The lesson inspected the defaults rather than choosing commercial rates for the business. > **Watch for:** the zone editor has its own Pricing and Operations fields. The inspected quote path uses the delivery configuration and its supported zone overrides; do not assume every field visible in the separate zone form changes the quote. Verify a before/after quote when adjusting pricing. ### Merchant accounts live in CRM Select **Merchants** in Logistics. ![The Merchants tab points to CRM merchant account management](../application-fixes/assets/delivery-merchants-review.png) The screen says merchant account management has moved to CRM and offers **Open Merchant Management**. The destination is **CRM → Merchant Accounts**, covering credit, invoicing and authorised users. A merchant billing account is different from the driver identity in Agents. ### Do not confuse a model field with a shipped form control The previous topic proposed putting `ORD-1042` in **Source reference**, selecting **photo_required**, then completing a job with proof. The current create form did not expose those controls. Its submitted job payload contains customer, stops, requirements, scheduling, pricing, driver pay and notes; the backend initially writes `source.type: api`. A delivery integration that needs a source order or a mandatory proof policy must write and verify the fields through the appropriate supported path. Merely naming an order in Internal Notes is a human correlation, not an automatic order relationship. The training job's source and stop-proof requirements were not manufactured through hidden state. ### Match the endpoint to the job you are doing The administrative route prefix is `logistics/delivery`. The UI's quote uses `POST /logistics/delivery/quote`, which validates and geocodes stops before returning a result. A valid result includes serviceable stops, pricing and route information; an invalid one identifies the failed stop. The captured outside-area response was: ```json { "valid": false, "errors": [ { "stopIndex": 1, "type": "dropoff", "errors": ["Location is outside our delivery service area"] } ] } ``` When integrating, stop on `valid: false`, clear any previous quote, and require a fresh valid result after an address changes. Do not infer success from HTTP success alone. The verified UI clears its earlier derived prices and prevents creating a system-quoted job after an invalid response or route edit. The verified Studio create request sends `requireSystemQuote: true` after using **Get System Price**. AppEngine revalidates those stops before saving and preserves server-derived distance, duration and route source. Explicit manually priced API jobs remain a separate supported path; callers should not omit this flag when relying on system validation. Treat the quote-to-create handoff as an integration check: confirm final stop coordinates, zone, quote freshness, route fields, source reference and pricing readback on the saved record. Never rely on a previously valid quote for a changed address. Customer and driver operations use the customer-authenticated `client/logistics` controllers. Verify that a customer can see and act only on the intended job. The inspected tracking path and driver-app cancellation path have known mismatches recorded in research; no customer tracking link or driver cancellation was validated in this lesson. ### Developer rehearsal: complete a fictional job and trace its earning The earlier quoted job was cancelled before dispatch. This separate local exercise follows a normally registered driver through completion and verifies the wallet credit. Use a training organisation and clearly fictional pickup/dropoff records; these actions do not represent a physical delivery. Keep notification delivery directed to your controlled test inbox during the exercise. Prepare two separate sessions: an owner and a normal customer who will register as the driver. [The connected-client course](appengine-build-a-connected-web-or-mobile-client.md) explains customer signup and sign-in. Send `orgid: ` and `Content-Type: application/json` on these requests. Use `Authorization: Bearer ` for driver actions and the owner's bearer for owner actions. Never substitute the owner token for a driver test. 1. **Driver:** call `POST /client/logistics/register` with: ```json {"vehicleType":"car","vehicleMake":"Training","vehicleModel":"Local Fixture","licensePlate":"TRAINING-NO-ROAD"} ``` Save the returned agent `sk`. Registration and approval are separate steps. 2. **Owner:** approve that agent through `PUT /logistics/delivery/agents//approve`, with `{"notes":"Local fictional workflow review only; no real driver onboarding asserted"}`. Keep the generated identifier; do not invent an agent ID or change its status through a generic record editor. 3. **Owner:** create one manual-price training job through `POST /logistics/delivery/jobs`: ```json { "requireSystemQuote": false, "customer": {"firstName":"Fictional","lastName":"Training Sender"}, "stops": [ {"type":"pickup","location":{"address":"TRAINING PICKUP — no physical collection"},"instructions":"Local application lifecycle test; no actual goods"}, {"type":"dropoff","location":{"address":"TRAINING DROPOFF — no physical delivery"},"instructions":"Local application lifecycle test; no actual recipient"} ], "pricing": {"customerPays":9,"driverPays":6,"distance":0}, "notes":"Fictional local delivery rehearsal — no physical delivery or customer charge" } ``` This intentionally bypasses the system quote for a controlled software rehearsal. It does not test route pricing or charge the named customer. Retain both the returned job `sk` and its readable job number. 4. **Owner:** assign this job through `PUT /logistics/delivery/jobs//assign`, with `{"agentId":""}`. 5. **Driver:** work through these requests in order. Each uses `PUT /client/logistics/jobs//`. Read the response before proceeding so you know which stage completed. | Action | JSON body | Expected saved status | | --- | --- | --- | | `start-pickup` | `{}` | `en_route_pickup` | | `arrive-pickup` | `{"stopIndex":0}` | `arrived_pickup` | | `complete-pickup` | `{"stopIndex":0,"proof":{"notes":"Fictional local rehearsal; no physical pickup"}}` | `picked_up` | | `start-dropoff` | `{}` | `en_route_dropoff` | | `arrive-dropoff` | `{"stopIndex":1}` | `arrived_dropoff` | | `complete-dropoff` | `{"stopIndex":1,"proof":{"notes":"Fictional local rehearsal; no physical delivery"}}` | `delivered` | | `complete` | `{}` | `completed`, with persisted earnings `credited` | 6. **Driver:** read `GET /client/finance/wallet`. Confirm the wallet owner is the signed-in customer and find an `earning` credit referencing this job number for **6.00**. Completing the job without finding its earning is an incomplete reconciliation. 7. **Owner:** open **Finance → Wallets**, find the linked driver customer and open **Wallet Details**. Expand **Earnings Breakdown**, then **Transactions**. Compare **Base Earnings** and the individual job references. The actual repeated rehearsal below shows two distinct jobs at 6.00 each, **12.00 Base Earnings**, **0.00 Adjustments**, and two transactions—not a manually credited balance. ![The driver's two actual job-generated credits, each with its source job number.](../application-fixes/assets/finance-local/delivery-earned-transactions.png) **Watch for:** repeating a completion after a timeout must not create another earning. In the local acceptance, repeating the first job kept its wallet at 6.00 with one credit; completing a second distinct job raised it to 12.00. Check the saved job and transaction before retrying a write. A payment to the driver still needs a separate payout request, destination and outcome; follow [customer payments, earnings and payouts](appmint-manage-payments-and-payouts.md). ### Shipping and local delivery remain different workflows Carrier shipping lives under **Storefront → Shipping**. A shipping method or carrier label is not the same record as a Logistics delivery job. A paid order does not automatically create and assign a local job in the paths inspected here. If your business wants that handoff, verify the integration that creates it and preserves the order reference. ## If something goes wrong | Symptom | First check | Practical response | | --- | --- | --- | | Saved zone is Inactive | Has it been explicitly activated? | Check the saved boundary, then Activate and reopen to verify | | Save reports Network Error | Is the application server reachable? | Wait for recovery, check whether the record exists, then retry the failed action without creating a duplicate | | Map preview fails | Is a map key available and the provider setup complete? | Continue numeric setup; handle provider setup separately and verify the quote result | | New job has no pickup/drop-off panels | Does the heading say Stops (0)? | Add Pickup and Add Dropoff explicitly | | Quote refuses Stop 2 and Create Job is disabled | Is the dropoff inside an active zone? | Correct the address and request a fresh valid quote; zero is not a free delivery | | Created job shows 0 mi after a successful quote | Did the create flow retain coordinates and route data? | Inspect the saved job and fix the integration before relying on tracking | | Job did not appear after a store order | Was a delivery-job handoff actually configured? | Create or integrate the job explicitly; keep the order reference traceable | | Cancelled row still has driver earnings pending | Are you reading earnings rather than job status? | No driver was paid in this exercise; inspect actual earned wallet entries separately | | Agent form cannot identify the driver | Is a real customer record linked? | Complete identity setup before creating or approving the agent | | Appmint Mobile does not show this driver flow | Are you using the driver application? | Use the verified company delivery build; do not substitute unrelated apps | ## What happened behind the scenes
Source paths for developers and maintainers - `websitemint/packages/ui/src/components/logistics/app.tsx` defines the seven module tabs. `zone-form.tsx` separates identity, geography, operations and pricing. New zones persist an explicit inactive status. Type/status options have schema fallbacks; activation uses the saved record ID and refreshes the list. - `components/common/map/map-boundary-editor.tsx` defines radius/polygon and regional boundary controls. Radius is in miles. Its numeric fields remain editable when the map preview cannot load. - `logistics/job-create-wizard.tsx` owns stop entry, Get System Price, job creation, timeline and cancellation. On quote success it retains prices and geocoded stops. Route edits invalidate that quote, late responses for earlier routes are ignored, and failed quotes cannot enable creation. The create response retains its saved record identity so subsequent actions target the actual job. Source-reference and delivery-type controls proposed in the old topic were not present in the captured create UI. - `appengine/src/logistics/delivery.service.ts`: `getConfig` creates/promotes a default; `findZoneForLocation` and `validateAndGeocodeStops` check active-zone geometry; `getValidatedPriceQuote` returns validation and pricing; `createJob` saves the job and initial timeline. No active zones means all coordinates are serviceable in the validation path; that is not a carefully configured service area. - The same service separates job state, payment and driver earning. Ordinary creation does not dispatch a driver. Customer notifications read the job's customer email; this training job supplied none and had no assigned agent. - `delivery.controller.ts`, `delivery-client.controller.ts` and `delivery-client.service.ts` separate administrative and customer/driver routes. Track the current ownership and state checks before exposing them in a company app. - `appmint_go/dfw_errand/lib/config/app_config.dart` delegates environment settings; the project's delivery service implements accept/start/arrive/complete calls under `client/logistics`. This source inspection is not a native-device walkthrough.
## Where next - [Follow wallet entries and payouts](appmint-manage-payments-and-payouts.md): distinguish a driver allocation, earned balance and actual payment. - [Automate a business handoff](appmint-automate-a-business-handoff.md): plan the order-to-dispatch responsibility and failure handling. - [Build a connected web or mobile client](appengine-build-a-connected-web-or-mobile-client.md): develop customer-authenticated integrations. The [video production guide](production/appmint-manage-deliveries.md) contains the recording sequence, actual asset list and the separate driver session still to capture.
Evidence and scope — 21 September 2026 Verified in local Studio Manager0.6.2 with a newly created training owner, organisation `learner-recovery-mubo2g1c`. The original active zone `tutorial_lekki_phase1` persisted centre6.4474/3.47, radius4mi and Africa/Lagos. A separate zone `tutorial_lekki_activation_check` verified the repaired new-record flow: Inactive save → Activate in the same drawer → close/reopen active/custom readback → Deactivate. That extra check zone remains inactive; the original training zone remains active. The pre-fix job `UNZEC0D18U` reproduced missing route metadata and was cancelled. After application repairs, job `RVK9LPZD77` passed fresh local quote15USD/11.25USD, airport refusal, restore/re-quote, creation, reopen, stop/item/note checks and cancellation with two timeline events. Its internal note uses `TUTORIAL-SAMPLE-02` to distinguish the repeat verification. The route persisted3.03mi/11.12min/google, geocoded stops and the intended zone. Both jobs remain cancelled. The [repair report](../application-fixes/delivery-quote-and-route.md) records source changes, four passing backend regression tests, successful API build and actual browser evidence. Historical screenshots are retained where their controls still match; current refusal, route and cancellation stills replace the earlier defective behavior. No provider agreement was accepted. The preview map failed with missing-key text while server quote succeeded. No agent was created, approved, notified or dispatched; no native driver build was installed. Physical pickup/dropoff, proof photos, earned wallet credits, charges, payouts, customer tracking and refunds are outside this planning exercise and were not claimed.
Completed-job earning continuation — 24 September 2026 A normally registered and owner-approved driver completed the software lifecycle on fictional jobs V9L8YAVMR1 and CB9N2IKCXN. The first run exposed missing wallet credits; the application repair and repeat/recovery checks passed. Exactly two job-referenced 6.00 earnings persisted, with zero adjustments and no payout. Actual Studio wallet inspection matched the API. This supersedes the earlier planning-only evidence for driver registration, approval, assignment, API stage transitions and wallet earnings, while physical delivery, native driver operation and external transfers remain outside these captures. [Repair and acceptance](../application-fixes/delivery-wallet-earning.md).
--- # Build a consultation campaign you can check before sending > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-run-a-customer-campaign.html # Build a consultation campaign you can check before sending ![Two saved campaign drafts, each with three recipients and zero messages sent.](../learner-review-records/assets/campaign-local-20260919/26-final-two-drafts-after-reload.png) **For:** business owners and marketers. **Time:** 35 minutes for the draft and review; 20 minutes for the optional referral-program setup. **Level:** beginner, followed by advanced branches. **Product:** Appmint Studio Manager. **Build checked:** local Studio Manager, 19 September 2026. A useful campaign gives a particular person a reason to do one thing. Here, Cedar & Form invites previous enquirers to a free design consultation. You will write that invitation, check the audience, save it, reopen it and prepare a separate reminder. The screenshots show actual saved drafts. Sending to inboxes and measuring delivery require a configured sender and a separate controlled test. ## What you will have at the end - An email draft with an offer, a subject and a clear reply action. - Three deliberately fictional practice addresses, with an explanation of why the recipient total needs checking. - An original and a reminder that retain different subjects and categories after reopening. - A saved internal name that helps you identify and reopen the right draft. - An optional draft referral program with 10% commission, a 14-day hold and automatic credit disabled. You will also know which screens prepare messages, which describe delivery and which are unfinished in this build. A saved draft does not contact anyone. ## What you need Use an isolated practice organisation for this exercise and sign in to its Studio Manager account. Keep the named tutorial drafts separate from any real customer campaign. The [welcome course](appmint-welcome.md) explains how to get there. Open the **CRM** sidebar group; this course uses **Broadcast**, **Affiliates** and **Marketing & Ads**. For the draft exercise, you do not need an email provider. Use the three `.invalid` addresses printed below; they are practice text, not real inboxes. For an actual send, replace every practice address with an address you control and configure a working sending account first. Do not use customers as your initial delivery test. Have one offer you can fulfil. In our example, Jordan Morgan runs Cedar & Form, has ten consultation slots and will personally confirm each appointment. Maya, Daniel and Priya are potential clients. Maya is not the company's owner. ## The story and route Jordan wants to invite a small group before announcing the consultations more widely. Rather than promise an online booking flow that has not been set up, the invitation asks people to reply with a preferred time. Jordan will check availability and enter the confirmed reservation. ```mermaid flowchart LR A[Choose offer and audience] --> B[Save and reopen draft] B --> C[Check sender and test inboxes] C --> D[Send and inspect results] D --> E[Handle reply and confirm reservation] ``` This walkthrough completes the first two stages and shows the preparation for the third. The later send and booking handoff have their own acceptance checks below. Do not count a campaign as a sale merely because a message was sent. ## Part 1 — Write and save the invitation ### 1. Find Broadcast and understand its four tabs Open **CRM → Broadcast**. The heading explains the channels: email, SMS, WhatsApp, voice and notifications. The in-page tabs are **Dashboard**, **Broadcasts**, **Channels** and **Analytics**. ![Broadcast dashboard before the exercise.](../learner-review-records/assets/campaign-local-20260919/01-broadcast-dashboard.png) Use **Broadcasts** to find individual messages. **Channels** shows configuration. **Dashboard** summarises activity. **Analytics** currently displays a coming-soon message; it is not where you will find a completed campaign report in this build. The captured training company had no sender and no previous broadcasts. If your practice company already contains earlier exercises, record its starting totals and verify your two named drafts individually; company-wide totals also include those earlier records. ### 2. Open the creator and check the sender area Press **Create Broadcast**. The drawer opens on **Sending Accounts**. Its four tabs are the sequence you will use: **Sending Accounts → Recipients → Message → Settings & Send**. ![Sending Accounts with no configured email account.](../learner-review-records/assets/campaign-local-20260919/02-no-sender.png) Select **Email**. If no account exists, the panel says **No email accounts configured** and directs you to channel setup. You can still prepare and save a draft. You cannot turn that draft into a working email campaign until an actual sending account is available. If your company already has an account, confirm its address belongs to your business and that replies reach a monitored inbox. Do not select an unfamiliar address just to clear the form. Sender rotation is unnecessary for this first exercise: use one known sender for the eventual test. ### 3. Enter three practice recipients Select **Recipients**, then **Manual Entry**. Paste these addresses, one per line: ```text maya@cedar-training.invalid daniel@cedar-training.invalid priya@cedar-training.invalid ``` ![Three practice addresses and the recipient total.](../learner-review-records/assets/campaign-local-20260919/03-three-practice-addresses.png) The small label should say **3 valid emails** and the green panel should say **3 recipients will receive this broadcast**. Here, “valid” means the text fits an email pattern. It does not mean the mailbox exists, accepts mail or has agreed to receive your offer. There are no first-name fields in Manual Entry. Enter addresses alone. Do not paste `Maya Bennett ` or append a name after an address. ### 4. Understand the counting trap before using a real list The form has two counters that do different work. We tested four lines: Maya, Daniel, `not-an-email`, then Maya again. It displayed **2 valid emails** but **4 recipients will receive this broadcast**. ![The syntax counter and total disagree when an invalid address and duplicate are entered.](../learner-review-records/assets/campaign-local-20260919/04-counting-trap.png) The syntax counter now excludes the repeated address and invalid line. The green total still counts all four entries. Check the actual addresses; the total does not establish a clean mailing list. Before a real send, remove invalid entries and duplicate addresses yourself, then read the list line by line. Keep one address per line so the count is easy to audit. For this exercise, leave only the three `.invalid` addresses from step 3. **Check yourself:** the form says “4 recipients,” but there are only two unique, syntactically valid entries. How many usable practice inboxes does this provide? **Zero**: `.invalid` addresses cannot receive mail. Remove malformed/duplicate entries for this draft exercise. Before a separate delivery test, replace them with controlled inboxes and verify each recipient; the displayed count proves neither identity nor deliverability. ### 5. Give the campaign a useful name and subject Select **Message**. At the top, complete **Broadcast Details**: | Field | Enter | Purpose | | --- | --- | --- | | **Broadcast Name** | `Tutorial Consultation slots — October` | Internal label so your team can find this run | | **Email Subject** | `Plan your room with a free 30-minute consultation` | What the recipient sees before opening | | **Category** | **Promotional** | Describes the purpose of this invitation | ![Campaign name, subject and category in Message.](../learner-review-records/assets/campaign-local-20260919/05-invitation-copy.png) The name and subject have different audiences. “October batch 1” may help a colleague, but it gives a customer little reason to open an email. State the useful offer in the subject; keep internal tracking words in the campaign name. ### 6. Write the message around one action Scroll to **Message Content**. Click inside the editor and enter: > Hi there, > > Planning a room refresh? Cedar & Form is opening ten free, 30-minute design consultations in October. Bring one photo of your room and the question you most want answered. > > Reply with your preferred weekday and whether morning or afternoon suits you. Jordan will confirm an available time before anything is booked. > > Jordan Morgan > Cedar & Form ![The saved invitation reopened in the rich-text editor.](../learner-review-records/assets/campaign-local-20260919/08-first-draft-reopened.png) This message answers four practical questions: what is offered, how long it takes, what to bring and how to respond. The final sentence prevents “I replied” from being mistaken for “my appointment is confirmed.” Use the editor's formatting controls for readable paragraphs and emphasis. Keep the message short enough that the action is easy to find. If the **BROADCAST AI** bar covers the lower part of the editor, collapse it; you do not need it to write or save the draft. > **Personalisation gotcha:** do not use `Hi {{firstName}}` with the Manual Entry list. The current delivery code does not look up a first name for each manually entered address. When a name is absent, the token can remain literal. “Hi there” is a complete greeting for every recipient. **Show Template Variables** exposes a broader variable browser. Its presence does not mean every variable is supported by Broadcast delivery. The advanced section explains that difference. The **Attachments** area below the editor offers **Choose Files**. Our invitation needs no attachment: asking for one room photo is sufficient preparation. An attachment selected in the editor should be checked in a controlled delivered message before you rely on it for customer instructions. ### 7. Review the draft without activating it Select **Settings & Send**. Read the **Broadcast Summary**: name, channel, subject, recipient count and selected sender count. ![Review screen with three recipients and zero selected senders.](../learner-review-records/assets/campaign-local-20260919/06-review-draft.png) The practice summary shows **Recipients: 3** and **Sender Accounts: 0 selected**. That is a usable drafting state, not a sending state. Press **Save as Draft**. The **Save Draft** button at the top also saves. Wait for the save to finish; the observed first save displayed **Data inserted**. Do not press **Send Now**: our addresses are fictional and there is no configured sender. **Send Immediately** is a timing choice, not a confirmation that anything has already been sent. **Schedule for Later** reveals date and time fields. The scheduling limitation is covered below; leave the practice campaign as a draft. ### 8. Close, refresh and reopen the saved record Close the drawer with the top-right **×**, then press **Refresh** in **Broadcasts**. Find `Tutorial Consultation slots — October` with **draft** status and **3 recipients**, then open it. Note the creation time before duplicating so you can distinguish the original from its copy. ![The first saved draft retains its internal name.](../learner-review-records/assets/campaign-local-20260919/07-first-save-name-retained.png) Return to **Message**. Check the subject, category and body. Return to **Recipients** and check the three addresses. These checks establish that the useful content survived a fresh read. The internal name now survives the first save and reopening. If an older record is untitled, check its subject before editing it; re-enter the name on the existing record, save and reopen instead of creating a duplicate. **Try it:** change “one photo” to “one or two photos,” save, reopen and check that sentence. Then restore the original wording, press **Save Draft**, close, refresh and reopen again to confirm the restoration before duplicating. You now know how to distinguish editing from merely leaving text in an open drawer. ## Part 2 — Prepare a reminder and save a contact list ### 1. Duplicate the original Close the drawer. In **Broadcasts**, use the copy-shaped action at the right of the original row; its tooltip is **Duplicate**. Wait for **Broadcast duplicated** and a second draft row. ![Two draft records after Duplicate.](../learner-review-records/assets/campaign-local-20260919/11-duplicated-draft.png) Open the new row and inspect the subject and body before changing anything. Duplication retained the content and the three addresses. On this build, the new row initially keeps the original name. Identify the newly created row by its creation time, then rename it in the next step. Do not depend on a “(copy)” suffix. ### 2. Make the reminder distinct and save it In the copy's **Message** tab, set: | Field | Value | | --- | --- | | **Broadcast Name** | `Tutorial Consultation reminder — October` | | **Email Subject** | `Tutorial reminder — choose a consultation time` | | **Category** | **Newsletter** for this category-change exercise | Press **Save Draft**, close, refresh and reopen the copy. ![Reopened copy with the revised subject and Newsletter category.](../learner-review-records/assets/campaign-local-20260919/29-reminder-message-after-reload.png) For a real reminder promoting the same offer, choose the category that accurately describes that message; changing a category is not an unsubscribe mechanism or delivery setting. We use Newsletter here only to make the saved difference visible. Reopen the original too. Its subject should remain `Plan your room with a free 30-minute consultation`, and its category should remain Promotional. Check that **each of your two named tutorial records** is still a draft with three recipients and no sends. In the initially empty captured company, the totals were two drafts, **Messages Sent: 0** and six recipients summed across both records. Existing company-wide totals are not the acceptance check for your exercise. ### 3. Save and verify a contact list In **Recipients → Manual Entry**, confirm the three unique practice addresses. Select **Save as contact list**, enter `Tutorial October list`, then select **Save** once and wait for completion. This saves contact records and a group containing their identifiers; it does not send the campaign. ![The list-name form.](../learner-review-records/assets/campaign-local-20260919/13-contact-list-form.png) After saving, close and reopen the creator, open **Recipients → Email Lists** and find your named group. Check for **3 members**. A list name alone does not prove that its contacts were saved. Return to **Manual Entry** for the campaign exercise: the separate delivery integration limitation in the next section still applies. If saving fails, read the error and keep the entered addresses and list name. Restore the connection before retrying. Existing exact-email contacts are reused; do not delete them to retry. If the result of a group save is uncertain, check for the group before submitting again. ![The saved Tutorial October list shows three members after a full browser reload.](../learner-review-records/assets/campaign-local-20260919/31-contact-list-after-reload.png) The fresh browser walkthrough on 19 September created the three contacts and reopened the group with **3 members**, including after a full page reload. The earlier empty-group failure is fixed. [Local repair and component/backend checks](../application-fixes/broadcast-draft-and-contact-list.md) document the underlying change. ### 4. Keep the other recipient methods in perspective **Email Lists** displays All Contacts, All Leads and customer groups. The current UI sends these selections as list IDs, while the delivery service's list path expects subscriber-list associations. A displayed count is not sufficient confirmation that the chosen CRM group will resolve into recipients. **Upload CSV** lets you choose a file, but the inspected creator stores the chosen file without parsing its rows into the delivery payload. **Filter Contacts** displays **Advanced filtering coming soon**. For this first small campaign, Manual Entry is the path we actually saved and reopened. After inspecting another audience mode, return to **Recipients → Manual Entry**, confirm the three practice addresses, press **Save Draft**, close, refresh and reopen to verify the saved mode and addresses on each tutorial draft. For a larger campaign, validate the chosen audience path with controlled inboxes before using the full customer list. The developer notes below distinguish the server's supported recipient types from what this particular screen sends. ## Part 3 — Readiness checklist for a later delivery rehearsal The practical outcome of this lesson is two persisted, unsent drafts. This section explains the next dependencies; it is not an executable first-time sender-setup lesson. Complete a supported provider-specific sender setup and controlled send/read/reply test before using these drafts for delivery. Neither this course nor the linked conversations course supplies that complete setup yet. Keep both exercise records as drafts. ### Connect a sender before promising delivery Open **Broadcast → Channels** to inspect channel configuration. ![Channels after loading completes: no domains and no email accounts.](../learner-review-records/assets/campaign-local-20260919/19-channels-no-accounts.png) The settled panel shows **No domains yet** and **No email accounts**. Wait for loading to finish before evaluating your own configuration; this exercise does not demonstrate external delivery. Use **CRM → Email Accounts** for your company's email-account setup. You need the actual sending identity and its provider configuration, not simply an address typed into a From field. Reopen **Sending Accounts** afterward and select the intended account. Check where replies arrive before using our reply-based invitation. The [customer conversations course](appmint-run-customer-conversations.md) covers the service inbox workflow; it does not configure email delivery for you. For a controlled delivery rehearsal, replace the three `.invalid` addresses with inboxes you control. Keep the audience small. Review the exact saved subject and body, confirm the selected sender and remove any practice wording that should not reach recipients. The real sender and inbox test were not performed in the captured training company. ### Know what the send controls mean Under **Settings & Send**, **Send Now** activates immediate sending; it is not the same action as **Save as Draft**. The current implementation moves a broadcast through a pending queue and records sending results afterward. The time it takes depends on the running worker and provider, so do not promise recipients a particular minute based on the button click alone. **Schedule for Later** exposes date/time controls, but the reviewed queue job picks up `pending` broadcasts, not `scheduled` ones. Do not depend on that scheduling option for an important announcement on this build. Keep the draft ready and perform your reviewed send at the appropriate time after your provider test succeeds. A cancellation endpoint exists in the backend. The captured list exposes Duplicate, not a dependable stop-sending action. Check the audience before activation; cancellation cannot recall messages already handed to a provider. ### Separate message status from business results For broadcasts in sending or terminal states, the creator includes a **Summary** tab with statistics, **Delivery Report** and **Email Activity**. Drafts do not have that tab, which is why it does not appear in our screenshots. Use three separate checks after your controlled send: 1. **Application result:** find the correct broadcast and inspect its status, errors and available delivery records. 2. **Inbox result:** open each controlled inbox, read the actual subject and body, test the reply destination and any link or attachment. 3. **Business result:** confirm the customer's reply, reservation or lead in the appropriate CRM screen. A provider accepting a send request, a mailbox delivery event, an email open and an appointment booking are different events. Do not substitute one for another. The inspected report UI and API use different shapes for delivery rows; an empty report should be investigated against provider and server records rather than treated as an empty audience. ![Analytics is a placeholder in the captured build.](../learner-review-records/assets/campaign-local-20260919/20-analytics-placeholder.png) For our offer, Jordan checks the reply, agrees a time, then uses **CRM → Reservations**. Follow [Turn an enquiry into a project](appmint-turn-an-enquiry-into-a-project.md) to create the service definition, save the appointment and import a reservation into Leads when appropriate. Sending this invitation does not itself create a reservation or lead. **Check yourself:** three inboxes received the message and two people replied. How many appointments are confirmed? Only the appointments Jordan has actually agreed and recorded. A reply is a conversation to handle, not automatic capacity allocation. ## Part 4 — Advanced: prepare a referral program A broadcast reaches an audience directly. A referral program defines how partners introduce business and earn credit. They can support the same offer, but saving a broadcast does not create an affiliate or calculate commission. ### 1. Open the program editor Open **CRM → Affiliates**, select **Programs**, then **New Program**. ![Affiliate program editor.](../learner-review-records/assets/campaign-local-20260919/21-referral-general.png) Under **General**, enter: | Field | Value | | --- | --- | | **Program Name (ID)** | `tutorial-design-partners` | | **Display Name** | `Tutorial Design Partners` | | **Title** | `Introduce a design client` | | **Status** | **Draft** | | **Type** | **Referral** | | **Tracking Cookie Age (days)** | `30` | | **Contact Name** | `Jordan Morgan` | The ID identifies the program in the system. The title describes it to the person reading its card. In our saved result, the card displayed the Title; do not search only for Display Name if you cannot find it. Leave contact email blank for this private exercise. A production program should have a real monitored contact, agreed terms and rules appropriate to your business before activation. ### 2. Define commission and hold settings In **Commission**, select **Percentage** and enter `10` in **Percentage**. Leave **Max Commission** blank for this draft. ![Ten-percent commission in the program form.](../learner-review-records/assets/campaign-local-20260919/21-referral-general.png) Expand **Payout**. Set **Hold Period (days)** to `14`, **Min Payout** to `0` and **Auto Credit** to **No**. ![Fourteen-day hold and automatic credit disabled.](../learner-review-records/assets/campaign-local-20260919/22-referral-hold.png) The percentage describes how commission is calculated on a qualifying base. The hold delays eligibility for release. Auto Credit concerns wallet credit; it is not permission to transfer funds to a bank. We disable it while learning the program structure. A 10% rule does not mean every order automatically produces a 10% payment. Referral attribution, qualification, exclusions and the eligible amount still matter. Do not assume shipping, tax or every order line belongs in the commission base. ### 3. Create, reopen and read the saved settings Press **Create Program**, wait for the editor to close and reopen the card titled **Introduce a design client**. ![Program Details after saving: draft, 10% commission, fourteen-day hold and Auto Credit No.](../learner-review-records/assets/campaign-local-20260919/24-referral-after-reload.png) The observed details show **draft**, **Percentage**, **10%**, **Cookie Age: 30 days**, **Hold Period: 14 days** and **Auto Credit: No**. They also show **Last Click** attribution, a 30-day window, self-referral blocked, unique email required and paid order required. Read those conditions before activating a real program. A person viewing a link is not automatically a qualified paid referral. Keep this training program as Draft; **Activate** is a separate action on the details screen. ### 4. Plan the remaining partner test accurately The module's **Affiliates** and **Referrals** tabs are where you inspect actual partners and attributed business. This exercise did not enrol a partner, generate a working referral link, place a paid order or release commission. The previous claim that this practice produced a 5.40 affiliate credit has been removed: the separate finance exercise used an explicitly labelled manual ledger adjustment. Before taking a referral program live, rehearse an enrolled test partner, a real generated link, attribution, an eligible order, the hold and the final wallet entry. Match each record to the same partner and order. Continue with [Payments and payouts](appmint-manage-payments-and-payouts.md) to understand wallet credit versus external payment. Do not use **Process Held Commissions** merely to make a dashboard show money. ## Part 5 — Advanced: use the advertising builder as a planning tool Open **CRM → Marketing & Ads**, then **New Campaign**. ![Actual advertising builder with its six stages.](../learner-review-records/assets/campaign-local-20260919/25-advertising-builder-inspection.png) The stages are **Campaign Identity**, **Creative Selection**, **Preview**, **Audience Targeting**, **Campaign Setup** and **Launch & Optimize**. They answer different questions from the Broadcast creator: | Stage | Decision to make | | --- | --- | | Campaign Identity | What is the objective, message and voice? | | Creative Selection | Which image or other creative explains the offer? | | Preview | Does the assembled campaign communicate the intended offer? | | Audience Targeting | Who should discover it through advertising? | | Campaign Setup | What budget, timing and optimisation are intended? | | Launch & Optimize | What remains to review before an actual platform launch? | The first screen offers objectives such as awareness, traffic, lead generation and sales. Demographic advertising audiences are not the same thing as your three manually entered email recipients. The inspected launch step simulates a wait and the builder saves a campaign record as active; this path does not establish that an ad platform accepted or ran an advertisement. Treat its budget and reach displays as planning information until you have a real platform campaign and delivery evidence. No ad was launched and no spend occurred in this walkthrough. ## If something goes wrong | Symptom | Check and next action | | --- | --- | | No email accounts configured | Save the draft; configure the intended sender, then reopen Sending Accounts. | | “Valid emails” and total differ | Remove invalid lines and duplicates. One address per line makes review easier. | | Blank name after first save | Reopen the existing row, check its subject, re-enter the name, save and refresh. | | Duplicate has a generated name | Open the new draft, check copied content, give it a useful name and save. | | Contact-list save fails | Read the error, retain the inputs and check existing group membership before retrying. A zero-member group is not a completed list. | | Email Lists count looks right but sending resolves no recipients | Investigate the CRM-group/subscriber-list mapping; test with a small controlled audience. | | CSV selected but no usable recipients | The inspected upload path does not parse recipients. Enter the small test list manually. | | Literal personalisation token | Use a complete plain greeting; test supported tokens on the actual delivery path. | | Broadcast stays scheduled | Current pickup job handles pending records. Do not rely on this scheduling path. | | Analytics says coming soon | Inspect the individual sent broadcast and provider records instead. | | Reply received but no booking exists | Agree a time and record the reservation. A campaign reply does not create one automatically. | | Network error during a save | Restore the connection and inspect the list before retrying; the first request may have saved. | ## What happens behind the screens
Developer notes, record paths and verification boundaries The creator saves `email_broadcast` records through the generic repository. It writes the name at the top level and message data under `data`. The original capture lost the name on creation. The corrected payload also supplies `data.name`, which the repository uses for the persisted name; local component/backend checks now cover first creation and reopening. The server's recipient resolver supports subscriber associations through `selectedLists` and direct types such as leads/contacts/customers through `recipientType`. The captured creator instead offers synthetic IDs `all-contacts`/`all-leads` and customer-group IDs without setting that direct recipient type. This mismatch is narrower than saying “the backend supports Manual Entry only.” Manual recipient resolution splits commas, semicolons and newlines and deduplicates by lowercased address. Its syntax checks are not the same as the editor's count. Names are not supplied in that path. The personaliser replaces exact `{{firstName}}` and `{{name}}` only when `recipient.name` is present, and uses that whole value for both; it does not split a full name. Email and phone tokens also depend on their values being present. `{{data.…}}` variables inserted by the general browser are outside those replacements. Provider bulk paths need their own delivered-message test. The broadcast queue selects pending records. A five-second minimum record age in that query is not a five-second delivery promise. Worker dispatch, queue state and provider response are separate concerns. Source reviewed on 18 September; repaired draft and contact-list paths exercised in the browser on 19 September 2026: - `websitemint/packages/ui/src/components/crm/broadcast/broadcast-creator.tsx`: tabs, counters, save payload, contact-list shortcut and personalisation browser. - `websitemint/packages/ui/src/components/crm/broadcast/broadcast-list.tsx`, `services.ts`: list, refresh and Duplicate. - `appengine/src/broadcast/broadcast.service.ts`: duplicate, recipient resolution, personalisation, sender sorting and delivery records. - `appengine/src/sync/jobs/broadcast.queue.job.ts`: pending pickup query. - `appengine/src/repositories/repository.crud.service.ts`: customer-creation guard. - `websitemint/packages/ui/src/components/crm/affiliates/affiliate-manager.tsx` and `appengine/src/affiliate/affiliate.service.ts`: program fields and saved conditions. - `websitemint/packages/ui/src/components/crm/marketing/campaign-builder/steps/launch-optimization-step.tsx`: simulated launch step.
## Where next Use [Social media automation](appmint-social-media-automation.md) to prepare the same offer for social channels. Use [Turn an enquiry into a project](appmint-turn-an-enquiry-into-a-project.md) for the reply-to-reservation-to-lead handoff. Use [Business handoffs and automation](appmint-automate-a-business-handoff.md) to organise internal review before customer-facing work. **Evidence:** fresh local walkthrough in isolated learner organisation `ck-local-mu83iwh3`, 19 September 2026. Completed every executable section, including the edit-and-restore exercise, both independently reopened drafts, three-member contact list, return to Manual Entry, referral draft and advertising-builder inspection. A full reload preserved both names, distinct subjects/categories, original message and three manual addresses each; the list showed two drafts and zero messages sent. The referral remained Draft with 10% commission, 30-day cookie, 14-day hold and Auto Credit No after reload. No email/SMS, external post, ad launch, partner enrolment, qualifying order, commission release or payout occurred. Part 3 remains future delivery guidance outside this draft-preparation exercise. [Browser execution record](../learner-review-records/appmint-run-a-customer-campaign.md). [Historical capture ledger](assets/appmint-campaign/evidence.json). [Companion-video guide](production/appmint-run-a-customer-campaign.md). --- # Prepare social posts and track consultation enquiries > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-social-media-automation.html # Prepare social posts and track consultation enquiries ![Two saved social drafts: an Instagram caption with artwork and a separate Facebook post.](../learner-review-records/assets/social-local-20260919/13-two-drafts-after-reload.png) **For:** owners and the people managing their company's social accounts. **Time:** 40 minutes for drafts and a listening rule. **Level:** beginner, with scheduling and integration notes for experienced operators. **Product:** Appmint Studio Manager → CRM → Social Media. **Build checked:** local Studio Manager, 19 September 2026. Prepare a useful offer once, adapt it for two networks and decide which incoming questions deserve attention. You will save a Facebook draft, upload original practice artwork, save a separate Instagram draft and build a listening topic that recognises consultation enquiries. The walkthrough uses an organisation without connected social accounts. Both drafts and the listening topic were saved and reopened. External publishing, provider authentication and replies are a separate handoff; the screenshots do not claim that a post went live. ## What you will have at the end - Two saved drafts with distinct captions, one each for Facebook and Instagram. - A consultation graphic uploaded through File Manager and attached to the Instagram draft. - A listening topic with an include phrase, an exclusion and a defined platform/activity scope. - A positive sample match and an excluded sample, so you understand what the rule actually recognises. - A clear distinction between saved content, a schedule, provider publication and a customer enquiry. ## What you need Sign in to your own company in Studio Manager. Use the [welcome course](appmint-welcome.md) if you have not created the organisation yet. For this exercise, you need access to **CRM → Social Media** and permission to upload the practice artwork. Download the [practice consultation card as PNG](assets/appmint-social/tutorial-consultation-card.png). Its [editable SVG source](assets/appmint-social/tutorial-consultation-card.svg) is also included. This original graphic was made for the course; it is not a product screenshot or a photograph of a real client's home. ![Original consultation artwork supplied for the exercise.](assets/appmint-social/tutorial-consultation-card.png) The graphic is labelled as a fictional tutorial offer. Use it for practice, then replace it with your own accurate offer before publishing for your business. Do not upload private room photos as public marketing material without the appropriate permission. You can complete the draft and listening exercises without a social account connection. Actual publication requires a connected account you control, appropriate provider permissions and a working delivery service. No paid Appmint plan is required for this course. ## The story Jordan runs Cedar & Form and wants to offer ten free 30-minute design consultations. The Facebook post explains the offer directly. The Instagram version pairs a shorter invitation with an image. Both ask people to message the business with a preferred weekday; Jordan will confirm the time afterward. There is no invented booking URL in either caption. If you already have a working public booking page, test its full reservation flow before replacing the message-based action with a link. ```mermaid flowchart LR A[Offer and artwork] --> B[One draft per network] B --> C[Connect account and review] C --> D[Schedule or send] D --> E[Check the real platform post] E --> F[Read replies and record the enquiry] F --> G[Confirm the reservation] ``` The saved message is the content. A schedule is a separate timing record. A provider post is the external result. A reservation is a separate business commitment. ## Part 1 — Find your way around Social Media ### 1. Open the command centre Open **CRM → Social Media**. The page heading is **Social Media Command Center**. Across the top are **Dashboard**, **Conversations**, **Posts** and **Feed**, followed by **More…**. ![Social Media Command Center before connecting any accounts.](../learner-review-records/assets/social-local-20260919/01-dashboard.png) At a narrower window width, some navigation labels collapse. Widen the window if you cannot see the labels used here. The **Dashboard** starts with **No social accounts connected**. Its description explains that platforms report different metrics. Do not interpret this empty state as zero public interest in your business; Appmint has no provider activity to analyse yet. ### 2. Locate account setup Choose **More… → Accounts**. The panel is titled **Linked Accounts** and lists connection states by provider. ![Linked Accounts shows the providers are not connected.](../learner-review-records/assets/social-local-20260919/02-accounts.png) Choose **More… → Integrations**. This opens the setup screen. Scroll to **Social media** to find **Facebook & Instagram**, **TikTok**, **LinkedIn**, **X (Twitter)**, **Pinterest**, **YouTube** and **Google Business**, each with its connection action. ![Integrations opens the setup screen.](../learner-review-records/assets/social-local-20260919/03-integrations.png) Account setup and content creation are different jobs. You can return to **Posts** and write while the person who manages the company's provider accounts completes the connection. Do not select **Reset onboarding** as a way to refresh social accounts; it is unrelated to drafting a post. ## Part 2 — Save the Facebook version ### 1. Start a new post and explicitly choose Facebook Press **Create Post** in the Social Media header. The drawer title is **New social post**. In **Channel**, click **Facebook**, even if it already looks selected. ![Explicit Facebook selection and the Save draft control.](../learner-review-records/assets/social-local-20260919/04-facebook-caption.png) This explicit click matters on the captured build. The first trial appeared to default to Facebook, but its saved message had no channel and did not appear under Social Media → Posts. Choosing Facebook explicitly before saving produced a correctly classified draft. The composer has two columns. The left holds the channel, account, message and optional sections. **LIVE PREVIEW** on the right needs a selected account. With no connection, it says **Select an account to see the preview**. That blank preview does not erase the text you write. ### 2. Write the offer in Message Click the **Message** text area and enter: > Tutorial — Free 30-minute design consultations this October. Bring one photo of your room and the question you most want answered. Message Cedar & Form with your preferred weekday; Jordan will confirm an available time. The character counter appears beside **Message**. The captured Facebook counter uses a 63,206-character limit; that is the form's displayed allowance, not a reason to write a long post. The caption has an offer, preparation and one response action. “Jordan will confirm” makes the availability boundary clear. A request for Tuesday is not a reserved Tuesday appointment. **Post to account** identifies where the message will be published. It is not a list of individual customers. In our training company, it says **No Facebook accounts connected**. Leave it empty for this draft exercise. ### 3. Save without publishing Press **Save draft** at the top of the composer. Wait for **Message saved**. ![The saved Facebook draft before closing the composer.](../learner-review-records/assets/social-local-20260919/05-facebook-saved.png) Do not use **Send now** or the top **Schedule** button for this step. Save draft stores incomplete work; sending and scheduling have additional requirements. Close the drawer with its top-right **×**. Select **Posts**, then use the Refresh control inside that view if needed. Wait for the list to load. ![The Facebook caption appears as a draft in Posts.](../learner-review-records/assets/social-local-20260919/06-facebook-row.png) You should see your caption, **Facebook** and **Draft**. The row's pencil action has the tooltip **Open**. ### 4. Reopen the saved caption Click the row or its **Open** action. Read the caption and channel again. ![Facebook draft reopened from the saved Posts row.](../learner-review-records/assets/social-local-20260919/07-facebook-reopened.png) For an existing message, the inner heading becomes **Edit message**, even though the surrounding Social drawer can still say New social post. Use the actual record contents and delivery state to identify what you opened. If the first saved message is missing from Posts, open **CRM → Comm Center → Messages** and locate its text. In our first trial, the **Channel** column was **—**. Reopen that message, choose the intended social channel explicitly, save and return to Posts. Avoid creating repeated copies before checking the existing record. **Try it:** add a final sentence, save, close and reopen. Verify the exact sentence, then remove it and save again. This small edit tests persistence without contacting anyone. ## Part 3 — Build an Instagram version with artwork ### 1. Start a separate post Close the Facebook draft. Press **Create Post**, then explicitly select **Instagram**. Enter: > Tutorial — A room you love coming home to. Our free 30-minute consultation starts with one photo and your biggest design question. Message Cedar & Form with your preferred weekday; Jordan will confirm a time. #RoomRefresh #InteriorDesign The captured text is 237 characters against the form's displayed 2,200-character allowance. The panel also says **Media required for Instagram**. One message record has one channel. Creating this second record lets you change the Instagram caption without replacing the Facebook version. Within a supported channel, the account selector can accept multiple connected accounts; review those destinations separately before publishing. ### 2. Open Media and File Manager Expand **Media**. Click the folder-shaped **Choose files** button. A floating **File Manager** window opens above the composer. ![File Manager opened from the Instagram Media section.](../learner-review-records/assets/social-local-20260919/10-upload-panel.png) The left side shows folders. The main area shows files for the selected location. The toolbar's upward-arrow button has the tooltip **Upload**. Click it. ### 3. Upload the course card The upload panel offers **Select files** and a visibility checkbox. In the captured panel, an unchecked checkbox displays **Public** and **Files will be publicly accessible**. ![The actual upload panel and visibility setting.](../learner-review-records/assets/social-local-20260919/10-upload-panel.png) For this deliberately public, fictional practice card, keep the checkbox in the captured unchecked state; do not toggle it merely because the label says Public. After upload, verify the saved file card has the **Public** badge before selecting it for the draft. If the badge differs, stop and check the file visibility instead of assuming the label described the saved state. Press **Select files** and choose `tutorial-consultation-card.png` from your computer. Wait for upload progress to finish. The progress bar measures the upload transfer. Confirm the resulting file appears in File Manager too. In our session, closing the upload panel, using **Show new files** and refreshing made the new card visible. ![The uploaded consultation card appears in File Manager with a Public badge.](../learner-review-records/assets/social-local-20260919/11-upload-result.png) Do not use **Make Public** on an unrelated customer file to get through the lesson. The downloadable practice card is already designed to be shared. ### 4. Attach the file to the message On the card's image area, click the checkmark button used to choose that file. File Manager closes and the selected file returns to the composer. Uploading and choosing are separate actions: a file in your library is not automatically attached to the draft. Wait for the thumbnail to load. The **Clear** control appears in Media when a selection is present. Do not treat a blank area during image loading as proof that no file was selected. ### 5. Save and reopen the Instagram draft Press **Save draft**, close the drawer and return to **Posts**. You should now see an Instagram draft and a Facebook draft with their different captions. Reload the page and open the Instagram row again. ![Reopened Instagram draft with its persisted artwork thumbnail.](../learner-review-records/assets/social-local-20260919/31-instagram-image-visible-after-reload.png) Expand **Media** and confirm the image is still attached. The right-side live preview remains unavailable while no Instagram account is connected. The saved thumbnail and a simulated platform preview are different things. **Check yourself:** the image exists in File Manager, but the reopened draft has no image selected. Is the post ready? No. Choose the uploaded file in the draft's Media section, save and reopen again. ## Part 4 — Understand scheduling before relying on it ### 1. Inspect the saved message's Schedule section Open the saved Instagram draft. Expand the lower **Schedule** section. This is separate from the action button named Schedule at the top of the composer. The section shows **Total**, **Active**, **Done**, **Next run**, tabs for **Upcoming**, **Past**, **All** and **Runs**, plus **Add schedule**. With no schedule attached, the counts are zero. **Inspection only:** do not press **Create schedule** in this draft exercise, even with Status set to stop. This lesson only inspects the editor. A real scheduling rehearsal needs a separately controlled account, a future test fixture and cancellation checks. Press **Add schedule** to inspect the editor, then use **Cancel** without saving. It includes a title, **Action**, **Run at**, **Repeat**, **Ends on** and **Status**. ![Schedule editor inspected without creating a timing record.](../learner-review-records/assets/social-local-20260919/15-schedule-inspection.png) The fresh walkthrough opened this editor and chose **Cancel** without entering or saving a publication time. The message remained a draft, with zero attached schedules. ### 2. Understand the earlier save failure In the earlier author rehearsal, pressing **Create schedule** returned the error below. This records that earlier result; it is not an instruction to repeat the click: ```text create => Not a new metrics, please use update or set the new property ``` ![Schedule editor captured during the earlier save attempt; the error itself is visible in the following image.](assets/appmint-social/15-schedule-save-result.png) No schedule was created. Close the schedule editor with **Cancel**, close the composer, then select **More… → Scheduled**. The training organisation still showed **No scheduled posts yet**. ![The red schedule-creation error and empty Scheduled list after the earlier attempt.](assets/appmint-social/16-scheduled-list.png) If you encounter this error, keep your saved message draft and report the failing control with the error text. Repeatedly pressing Create schedule does not turn it into a working schedule. Do not put a time-critical campaign behind this path until it works on your deployed build. The local [schedule repair](../application-fixes/schedules.md) now supplies the creation marker and checks suspended statuses before queuing and execution. Its regression checks cover those contracts. This browser exercise remains inspection-only: no timing record was created, no job ran and no cancellation of a running action was tested. For an unwanted existing schedule, inspect its cancellation/deletion result and owning message; stopping cannot recall an already delivered post. ### 3. Read scheduled results as two separate records Once a working scheduling path and account are available, check both the message and the timing record. The Scheduled view offers **Upcoming**, **Today**, **7d**, **30d** and **All** filters, plus a platform filter. A date shown in that list establishes a stored plan. It does not establish that a provider published the post. The delivery path must still run, call the provider and record its result. Check the actual post on the intended account before telling the team that the campaign is live. The reviewed Scheduled list omits YouTube even though the composer offers a YouTube channel. Absence from that one list is not a reliable test for every possible scheduled message. Inspect the owning message and its schedule as well. ### 4. Use recurrence deliberately The schedule editor offers **Set up recurrence** under Repeat. Recurrence repeats an action against the same message; it does not write a fresh caption every week. A month-long consultation campaign needs an end-of-campaign cleanup decision so an old offer does not keep resurfacing. Do not paste a weekly cron expression without deciding which timezone governs it. The date/time input uses browser-local time before converting to an instant; the inspected recurring scheduler defaults to UTC where no timezone is supplied. A displayed 09:00 and a cron 09:00 are not automatically the same local time. Use the [background-jobs course](appengine-run-reliable-background-jobs.md) for worker, timezone and queue checks. Do not change a deployment's sync settings merely to make a tutorial timer appear to work. ## Part 5 — Create a listening topic for consultation questions Listening helps find patterns in activity that has been synced into your organisation. It is not a search of every public post on the internet. You can prepare and test its matching rules before connecting a provider. ### 1. Create the topic Choose **More… → Listening**, then **New**. Complete: | Field | Value | | --- | --- | | **Name** | `Tutorial Consultation enquiries` | | **Description** | `Find design consultation questions in synced comments and direct messages.` | | **Include patterns** | Type `design consultation`, then press Enter | | **Exclude patterns** | Type `job application`, then press Enter | Each phrase becomes a pill marked **P**. Pressing Enter commits the pattern; leaving words in the input is not the same as adding the rule. You can click a pill to inspect its matching mode and case options. Leave Platforms and Activity types unselected while testing the text. Their labels explain **empty = all**. Keep **Enabled — match during sync** checked. ### 2. Test a question that should match In **Test against a sample**, enter: ```text Can I book a design consultation next Friday? ``` Press **Test**. You should see **Matched: "design consultation" in content**. ![Positive phrase match using the actual topic tester.](../learner-review-records/assets/social-local-20260919/17-listening-positive.png) The match identifies a phrase in the sample. It has not created a customer message, sent a reply or booked a time. ### 3. Test an exclusion Replace the sample with: ```text My job application mentions a design consultation. ``` Press **Test** again. The result should be **No match.** ![The exclusion prevents a match even when the include phrase is present.](../learner-review-records/assets/social-local-20260919/18-listening-excluded.png) An exclusion skips the whole activity row, not just the unwanted words. Use exclusions sparingly: a real customer who says “this is not a job application; I want a design consultation” would also be excluded by this simple phrase rule. ### 4. Set the real scope after the text test Select **Facebook** and **Instagram** under **Platforms**. Select **Comment** and **Message** under **Activity types**. Leave other pills unselected. This scope matches the channels and kinds of conversation Jordan wants to review. A reaction or like alone does not ask for an appointment, so it is outside this particular rule. > **Tester gotcha:** with those filters selected, the same positive text returned No match in our rehearsal. The tester sends content without a sample platform or activity type, so it fails the selected filters. To test phrase behaviour, temporarily clear the scope pills, test, then restore them before saving. Do not remove the intended production filters just because the text-only tester cannot supply that context. ### 5. Save, reload and inspect the topic Press **Create topic**. If editing an existing topic, the button is **Save changes**. Reload the page, return to Listening and select **Tutorial Consultation enquiries**. ![Saved topic after reloading: include/exclude phrases, two platforms and zero real mentions.](../learner-review-records/assets/social-local-20260919/20-topic-after-reload.png) The topic shows its include and exclude phrases and **2 platforms**. Open **Edit** after reloading: check **Facebook** and **Instagram**, **Comment** and **Message**, and **Enabled** are selected, with all other platform/activity pills unselected. The summary count alone cannot verify those specific choices. With no synced provider activity, **Mentions**, **Unique authors** and **Platforms** are zero. That is expected; the sample tester does not manufacture activity for the dashboard. **Backfill** scans existing synced activity against the rule. It is useful after changing rules when historical activity exists. It does not connect a social account or fetch the whole public web. ![Exact platform and activity choices retained after the final exercise and a full reload.](../learner-review-records/assets/social-local-20260919/26-final-topic-editor-after-reload.png) **Try it:** add an include phrase for `room refresh`. Test a matching sentence and an excluded sentence. If you cleared scope for the text-only test, restore **Facebook**, **Instagram**, **Comment** and **Message** and check **Enabled** before saving. Reload and reopen **Edit** to confirm those exact selections survived. Avoid broad keywords such as `design` until you have checked the irrelevant matches they produce. ## Part 6 — Connect accounts, handle replies and measure the right thing ### Connection handoff The remaining connection and publication steps require a provider account you control. They are a separate future handoff; the local draft/listening exercise is complete without connecting or publishing. Return to **More… → Integrations**, scroll to Social media and select **Connect** for the provider you intend to use. Complete provider authentication with the company's authorised account, then return to **Accounts** and verify the intended business identity appears. The external consent sequence was not completed in this rehearsal; provider screens and requested permissions need to be checked during that connection. The previously inspected Facebook connection implementation imports Pages returned for that login and associated Instagram accounts. Do not assume Appmint will offer a second page-selection screen after authentication. Review the provider-side scope carefully, then inspect which identities actually appear in Appmint. Reopen your draft and select the intended **Post to account** destination. Read every selected account pill. A correct caption on the wrong Page is still an incorrect publication. Before the first real post, replace tutorial copy/artwork, verify public media access, confirm the account and rehearse a controlled publication on an account you own. Open the provider's resulting post to inspect caption, image and destination. No provider connection or publication is shown as complete in this course. ### Where incoming work appears | View | What to do there | | --- | --- | | **Conversations** | Open a synced DM/comment thread and read the conversation before responding | | **Activity** | Filter incoming comments, mentions, reactions, shares, follows and messages | | **Engagement** | Review top engagers, needs-reply items and hashtag information for a chosen time range | | **Listening** | Review activity that matches your saved topic rules | | **Feed** | Inspect provider feeds for connected accounts; distinguish actual data from examples | | **Dashboard** | Read each provider's own available metrics and account health | ![Conversations before any provider threads have been synced.](../learner-review-records/assets/social-local-20260919/27-conversations.png) ![Activity filters for interaction type and platform.](../learner-review-records/assets/social-local-20260919/28-activity.png) ![Engagement panels, including the explicit pending sentiment-enrichment message.](../learner-review-records/assets/social-local-20260919/29-engagement.png) In the captured build, Engagement says **Pending sentiment enrichment**. Zero positive/neutral/negative percentages here do not establish that customers have no sentiment. That enrichment work has not populated those results. Feed also displayed example Twitter cards such as **Tech Updates** despite no account being connected. Those cards are sample content in the source, not Cedar & Form's audience or campaign results. Do not use their likes, replies or poll votes as evidence of your own performance. ### Turn an enquiry into business work When a real person asks for a consultation, read the full thread and check availability before confirming. Capture enough context for the next colleague: person, requested room/project, preferred time and the source post or conversation reference. There is no verified one-click conversion from these Social views into a lead in this walkthrough. Follow [Turn an enquiry into a project](appmint-turn-an-enquiry-into-a-project.md) to create the lead or reservation and preserve the source context. The listening topic helps you find the enquiry; it does not complete that CRM handoff. ## If something goes wrong | Symptom | First check and useful next action | | --- | --- | | Message saved but Posts is empty | In Comm Center → Messages, check the row's Channel. Explicitly select the intended channel and save. | | No account preview | Check Post to account and the linked-account connection. Draft saving does not require a preview. | | Upload finished but no attachment | Find the uploaded file, choose its checkmark, save the message and reopen it. | | Artwork appears blank briefly | Wait for the actual thumbnail to load before deciding the file is missing. | | No Instagram media | Expand Media and attach an image; do not treat a text-only draft as publication-ready. | | Schedule create says “Not a new metrics” | Keep the draft, cancel the failed editor and report the error. Verify Scheduled remains empty. | | Schedule says stop | Do not rely on that label alone to cancel a queued action. Use actual deletion/cancellation and verify removal. | | Positive listening sample says No match | Temporarily clear platform/activity scope for the text-only test, then restore the intended scope. | | Topic dashboard has zero mentions | Check linked accounts, synced activity, topic filters and time range. Sample tests do not create mentions. | | Feed contains unfamiliar popular posts | Check whether they are built-in sample cards rather than connected-provider records. | | Schedule completed but post is absent | Inspect the message delivery result and provider response; timing completion and publication are separate. | ## Behind the screens
Developer notes and source pointers Social drafts are `message` records, distinguished by `data.deliveryType`; media is stored in `data.files` and selected social destinations in `data.to`. The composer parent and child both initialise message state. The parent's unspecified default channel can overwrite the apparent social default. Explicit channel selection fixes the saved classification. That unclassified message belongs to the historical rehearsal. The fresh learner run explicitly selected each channel and saved only the two classified drafts. The social compose form permits Save draft without full account/media validation. This is intentional drafting behaviour, not evidence that an incomplete post can be delivered. The uploaded practice asset and its selection survived a fresh read. Its thumbnail loaded asynchronously, so final readback captures waited for actual image completion. The local backend stored this public, fictional tutorial card in its configured development object storage under the learner organisation prefix. The historical ScheduleManager creation failure and missing suspension guards were repaired locally; [98 regression checks](../application-fixes/schedules.md) cover creation and stopped/paused/cancelled dispatch suppression. The fresh browser run only opened and cancelled the editor, then confirmed Scheduled remained empty. It does not establish a provider publication, recurring run or cancellation of an action already underway. The saved-topic update originally constructed a double-slash URL and returned Not Found. The [local request-path repair](../application-fixes/social-topic-paths.md) now lets Save changes reach the actual update route; the final extra phrase and exact scope survived browser reload. The listening tester submits `{sample:{content}}`. Server matching also checks topic platform and activity-type filters, explaining why scoped text samples return no match. Clearing scope for a dry-run phrase test and restoring it before saving worked. No social_activity rows were fabricated to inflate the dashboard. Source reviewed: - `websitemint/packages/ui/src/components/crm/social/modern-social-app.tsx`, `modern-social-posts.tsx`, `modern-social-scheduled.tsx`: navigation, saved-post query and schedule view. - `websitemint/packages/ui/src/components/crm/communications-center/compose-content.tsx`, `modern-compose-form.tsx`, `comm-center-helpers.ts`: initial state, account selection, draft/send differences and media handling. - `websitemint/packages/ui/src/components/content/file-manager/fm-control.tsx`, `views/card-view.tsx`: upload and select controls. - `websitemint/packages/ui/src/components/schedule-management/schedule-manager.tsx`: actual timing editor and creation payload. - `appengine/src/sync/commands/schedule-updated-handler.ts`, `sync/queue-consumer/schedule.consumer.ts`: queue materialisation and actions. - `websitemint/packages/ui/src/components/crm/social/modern-social-listening.tsx`; `appengine/src/community/topics/social-topic.service.ts`, `social-topic.matcher.ts`: rule persistence and sample scope. - `websitemint/packages/ui/src/components/crm/social/feeds/twitter-feed.tsx`: example cards displayed without a connected account.
## Where next Use [Build a consultation campaign](appmint-run-a-customer-campaign.md) for an email invitation. Use [Turn an enquiry into a project](appmint-turn-an-enquiry-into-a-project.md) for reservations and leads. Use [Reliable background jobs](appengine-run-reliable-background-jobs.md) to diagnose scheduling and provider-sync operations. **Evidence:** fresh learner organisation `ck-local-mu83iwh3`, 19 September 2026, local Studio3100/AppEngine3300. Both channel-specific drafts, the Facebook edit/restore exercise, artwork upload/Public-badge/selection/reload, schedule inspection/cancel, original and extra listening phrase tests, and exact saved listening scope were exercised through the actual browser. The final topic includes `design consultation` and `room refresh`, excludes `job application`, and retains only Facebook/Instagram and Comment/Message with Enabled checked after reload. Conversations/Activity/Engagement/Feed were inspected without creating inbound activity. No social account connection, external post, reply, running schedule, commission or payment was made. The public fictional PNG used the local backend's configured development object storage. [Fresh browser execution record](../learner-review-records/appmint-social-media-automation.md). [Historical capture ledger](assets/appmint-social/evidence.json). [Companion-video guide](production/appmint-social-media-automation.md). --- # Turn five project records into a dashboard you can act on > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-build-an-operations-dashboard.html # Turn five project records into a dashboard you can act on ![The saved Monday dashboard shows one overdue project assigned to a user and one still unassigned.](../learner-review-records/assets/operations-dashboard-local-20260919/33-dashboard-fixed-after-reload.png) Your Monday question is simple: **which project decisions are late, and who is responsible?** By the end of this course you will have the records, the chart, the matching project list, and a task that turns one decision into an action. **For:** business owners, operations managers and developers who manage work that does not fit an existing module. **Product:** Appmint Studio Manager. **Allow:** 60–90 minutes for the main exercise; another 20 minutes for recovery and collaboration. **Level:** beginner with a supplied schema and query; explanations and extensions for developers. **Walkthrough:** local Studio Manager 0.6.2, 19 September 2026. ## What you will have at the end - A custom **Design Projects** collection with project, client, owner, budget, stage and decision-date fields. - Five saved projects, including an unassigned project and a completed project that should stay out of the overdue count. - An **Overdue decisions by owner** bar chart and a detail query identifying its two matching projects. - A saved **Projects — Monday view** dashboard. - A private project Workspace and an actionable task visible in **My Tasks** after reloading. - Practice identifying validation problems and restoring a versioned practice record. The batch-import section checks a deliberately invalid row in **Validate only** mode. You will see its row number and required-title error without adding any batch records. ## What you need Use your own training organization from [Welcome to Appmint](appmint-welcome.md). Sign in as its owner for this exercise: you will create a collection, save records and build queries. Appmint is free; this exercise does not require an upgrade. Download these small practice files: | File | What it contains | | --- | --- | | [Design Projects schema](assets/appmint-dashboard/design-projects.schema.json) | The six fields and their form controls. | | [Overdue count pipeline](assets/appmint-dashboard/overdue-by-owner.pipeline.json) | The filter and owner grouping used by the chart. | | [Overdue detail pipeline](assets/appmint-dashboard/overdue-detail.pipeline.json) | The same filter, returning project names and dates. | | [Validation practice CSV](assets/appmint-dashboard/projects-validation.csv) | Three proposed imports, with one deliberately blank project title. | Open the JSON files in a text editor so you can copy their contents. They contain no account credentials. You do not need to edit application code. You can finish the exercise with one user and no customers. Select your own account as **Owner**. Leave **Client** empty if your training organization has no customers yet; connect a real customer later using the [CRM course](appmint-turn-an-enquiry-into-a-project.md). A project title such as “Bennett kitchen” does not create a customer named Bennett. ## The story Cedar & Form has five design projects. A budget spreadsheet tells Jordan what each project costs, but not which decision needs attention today. Jordan needs a repeatable view that excludes completed work, includes projects without an owner, and leads to a task someone can finish. We use **18 September 2026** as the exercise's review date. Keep that date while learning so your results match the screenshots. Later, change the review date deliberately in both queries. ```mermaid flowchart LR A[Collection: define the six fields] --> B[Data Explorer: save five projects] B --> C[Data Studio: count and reconcile] C --> D[Workspace: assign the next action] ``` A **collection** defines the fields. A **record** is one project. A **query** selects or summarizes records. A **dashboard** arranges saved queries. A **Workspace** is the collaboration area for discussions, files and tasks; creating one does not replace your organization or automatically link it to a project record. ## Part 1 — Build a form that fits your projects ### 1. Open Collection Builder In Studio Manager's left sidebar, open **Database → New Collection**. If the sidebar is collapsed, point at the database icon to see its label, then select it to open the menu. The builder has seven tabs: **Schema Builder**, **Notifications**, **Schema Tree**, **JSON Schema**, **Form Preview**, **Collection Info**, and **Live View**. On the right, **Elements** provides controls; **Properties** configures a selected field; **Theme** changes appearance; **Magic** contains interactivity settings. **Save** at the top saves the collection definition. ![The new Collection Builder and its field palette.](../learner-review-records/assets/operations-dashboard-local-20260919/01-collection-builder.png) The new collection initially has a generated name. In this build, its schema also starts empty and the canvas says **empty properties**. That is why simply dragging a field into a new canvas may do nothing. The supplied schema in step 3 gives the canvas its object structure and fields in one operation. ### 2. Give the collection a stable identity Open **Collection Info**. Enter: | Control | Value | Why it matters | | --- | --- | --- | | **Name** | `cf_project` | The identifier used by queries and API clients. | | **Title** | `Design Projects` | The readable name people choose in Studio. | | **Enable Versioning** | On | Retains this collection's history and supports Trash retention. | | **Enable Workflow** | On | Makes the collection available for workflow-related processing; it does not create a workflow by itself. | These last two controls are switches; their default text is **Use setting**. Turn each on. Leave other settings at their existing values for this exercise. Do not change collection roles simply to make the builder work. ![Collection identity and the versioning/workflow switches.](../learner-review-records/assets/operations-dashboard-local-20260919/06-valid-collection-info.png) After editing **Name**, let the builder settle before switching tabs. The header should show **Design Projects** and `cf_project`. If that name already belongs to your earlier practice collection, reopen it instead of creating another one. Before entering the five records, inspect the existing collection in Data Explorer. Reuse a practice record already present under the same title; compare and correct its fields against Part 2 rather than adding it again. For the exact two-project query result, use only the five specified practice records. If the collection contains unrelated projects, create a separate empty practice collection with another unique identifier of 3–20 characters instead of deleting those records, and select that same collection in both queries. Additional matching records legitimately change the totals. Carry your chosen collection identifier through every later query and Trash check. ### 3. Paste the supplied schema Open **JSON Schema**. Click inside the code editor, select all of its existing content, and **paste** the complete contents of [design-projects.schema.json](assets/appmint-dashboard/design-projects.schema.json). Wait a moment before opening **Form Preview**. Pasting is preferable to typing the JSON character by character: code-editor auto-completion can insert quotes and brackets while you type. If the editor reports invalid JSON, replace the whole document with the file contents; do not append the file after an existing closing brace. The schema defines these fields: | Stored field | Form label | Meaning | | --- | --- | --- | | `title` | **Project** | Required project name; at least one character. | | `client` | **Client** | Lookup in the organization's customer collection. | | `owner` | **Owner** | Lookup in the organization's user collection. | | `budget` | **Budget** | A numeric amount; the schema declares a minimum of zero. | | `stage` | **Stage** | One of Brief, Concept, Detail, Build or Complete. | | `nextDecisionDate` | **Next decision date** | The date of the next decision needed on this project. | The two lookups store a record identifier, not a typed person's name. **Owner** displays user email addresses in the search results. In this build, a saved lookup can show its raw identifier when reopened; that is not a second user. ![The Owner lookup loads users belonging to the training organization.](assets/appmint-dashboard/11-owner-lookup.png) ### 4. Understand the visual editor before changing the form Return to **Schema Builder**. With an initialized object schema, drag **Text Field** from **Elements** into the dashed canvas to add a field. Select a field and open **Properties** to edit its **Name**, **Title**, validation options and presentation. You do not need to add more fields to the supplied schema. This is how you extend it later, for example with a site-survey note. Wait after renaming a field before editing its next property or changing selection: field-property updates are delayed in this editor. ![The field Properties panel exposes naming and validation controls.](assets/appmint-dashboard/06-field-properties.png) > **Gotcha — “Single Selection” is a yes/no control.** The palette's **Single Selection** item defaults to a boolean checkbox. It is not the five-stage dropdown this exercise needs. The supplied schema uses the choice-list control with a string value and five explicit options. Do not replace it with the default checkbox. > **Gotcha — lookup Properties can say “No Schema Defined.”** That occurred when selecting a lookup in this build. The supplied JSON configures the lookup's `dataSource` directly. Use **Form Preview** to check that it actually loads the intended collection. ### 5. Save, then find the collection again Select **Save** at the top. Open **Database → Data Explorer**. In **Search collections…**, type `Design`, then select **Design Projects**. **Search by its title.** Typing `cf_project` did not find this collection in the tested search, even though that identifier is displayed beneath its title. An empty search result is not evidence that saving failed. The right side should say **Design Projects — Browse and manage cf_project data**. With no projects yet, choose **Add First Record** or **Add Record**. ![The saved collection opens a project form through Add Record.](assets/appmint-dashboard/15-add-project.png) **Try it:** locate **Schema Tree**, **JSON Schema** and **Form Preview** in the builder. Which one changes the definition, and which one lets you inspect the resulting form? The JSON editor changes the definition; preview is where you check the form people will use. ## Part 2 — Enter five records and understand the stored values ### 1. Test the required field before entering real work In **Add Design Projects**, leave **Project** empty, enter `18000` in **Budget**, and select **Save**. You should see **Missing required fields: title**. No record is added. This is a useful check: a chart full of unnamed projects would be difficult to act on. ![The form rejects a project without its required title.](../learner-review-records/assets/operations-dashboard-local-20260919/08-required-title-rejected.png) Enter `Tutorial Bennett kitchen` in **Project**. The stored name `title` in the error corresponds to the form label **Project**. ### 2. Complete the first project Fill the rest of the form: - **Client:** leave empty for the starter exercise. If you already have the intended customer, open the lookup and choose the actual result. - **Owner:** open the lookup and select your own account's email address. Clicking a result selects it; typing text alone does not establish a link. - **Budget:** `18000`, without a currency symbol. - **Stage:** open the dropdown and choose **Concept**. - **Next decision date:** `2026-09-10`. Select **Save**. The drawer closes and **Tutorial Bennett kitchen** appears in the table. ![First project values before saving.](../learner-review-records/assets/operations-dashboard-local-20260919/09-first-project-values.png) The date input displays a calendar date. The stored value observed later was `2026-09-10T00:00:00.000Z`. Our fixed-date query accounts for that representation; do not mix arbitrary localized date strings such as `10/9/26` into raw records. ### 3. Add the other four projects Select **Add Record** for each row below. Keep **Client** empty unless you have a real matching customer. “Your account” means select the same user you chose for Bennett kitchen. | Project | Owner | Budget | Stage | Next decision date | | --- | --- | ---: | --- | --- | | `Tutorial Reyes loft` | Your account | `42000` | Detail | `2026-09-25` | | `Tutorial Ahmed studio` | Leave empty | `12000` | Brief | `2026-09-08` | | `Tutorial Park terrace` | Your account | Leave empty | Complete | `2026-08-30` | | `Tutorial Okonkwo bathroom` | Your account | `9000` | Build | `2026-09-30` | ![Five saved records, including one unassigned owner and one empty budget.](../learner-review-records/assets/operations-dashboard-local-20260919/13-five-projects-after-reload.png) The empty budget means “not entered,” not a confirmed zero. The empty owner is deliberate: an operational report should expose work that nobody owns. The table displays only a subset of the schema fields. The missing date column does not mean the date was lost. Use a row's **Edit** pencil to read its form or **Edit Raw JSON** to inspect its stored structure. Close the raw editor without saving if you are only inspecting it. ### 4. Predict the answer before building the chart On the exercise review date, **18 September 2026**: | Project | Include? | Reason | | --- | --- | --- | | Bennett kitchen | Yes | September 10 is earlier than the review date; project is not Complete. | | Ahmed studio | Yes | September 8 is earlier; missing owner must not hide it. | | Park terrace | No | Its old date belongs to a Complete project. | | Reyes loft | No | September 25 has not passed. | | Okonkwo bathroom | No | September 30 has not passed. | Your target is **two projects**, grouped into **one assigned** and **one unassigned**. A project due on September 18 itself is not overdue under this exercise's “earlier than” rule. ## Part 3 — Create the chart and prove its numbers ### 1. Open the query editor Choose **App Root → Dashboard Builder** in the sidebar. The application heading is **Data Studio**. Choose **New Query**. The editor has a title at the top, a collection selector, a code area, **Run**, result rows, chart preview, and chart controls. Enter `Tutorial Overdue decisions by owner` in the title and select **Design Projects** in the collection selector. ![Data Studio's query editor.](assets/appmint-dashboard/19-new-query.png) ### 2. Paste and run the count query Paste the [overdue count pipeline](assets/appmint-dashboard/overdue-by-owner.pipeline.json) into the code editor, replacing its starter query. Select **Run**. Its three stages do specific jobs: 1. **Match:** keep a nonempty string decision date before `2026-09-18` and a stage other than `Complete`. 2. **Group:** count matching records by `data.owner`; missing owners become `Unassigned`. 3. **Sort:** put the groups in a predictable order. The result should show **2 rows · 2 fields**, with `count` equal to `1` for your user's identifier and `1` for `Unassigned`. Two result rows are two groups; the sum of their counts is the number of projects. The `data.` prefix matters. Project fields live inside the record's `data` object. Querying `owner` instead of `data.owner` targets a different part of the record. ### 3. Choose and save the chart Under **CHART TYPE**, choose **Bar** explicitly. In **FIELD MAPPING**, set **Category (X-Axis)** to `_id` and **Values (Y-Axis)** to `count`. Leave **Split by (optional)** at **None**. Select **Save**. ![The result rows and two bars agree. Callouts identify the chart type and its field mappings.](../learner-review-records/assets/operations-dashboard-local-20260919/16-explicit-bar-chart.png) The long identifier on one bar is expected: the query groups by the stored user ID. A production dashboard can resolve that identifier to a readable name, but do not replace it with a hard-coded employee name that would mislabel somebody else's data. The **Unassigned** bar is already readable and immediately actionable. ### 4. Save a detail query using the same filter Choose **New Query**. Enter `Tutorial Overdue decision detail`, select **Design Projects**, and paste the [overdue detail pipeline](assets/appmint-dashboard/overdue-detail.pipeline.json). Choose **Run**, then **Results only** in the editor's output-view toolbar. Save the query. You should see **Tutorial Ahmed studio** and **Tutorial Bennett kitchen**, with their owner, stage and decision date. These are the two records represented by the chart. ![The detail query identifies the two projects behind the summary.](../learner-review-records/assets/operations-dashboard-local-20260919/17-two-matching-projects.png) This is the reconciliation: the detail query has two project rows; the grouped query totals two. Both use exactly the same first filter stage. If they disagree after later edits, compare the collection, review date, excluded stage and empty-date handling before blaming the chart. > **Gotcha — the date is fixed, not automatic.** Opening this saved query next month does not change `2026-09-18` to today. For a later review, update the cutoff in both queries. The exercise uses a UTC date boundary; agree on the business timezone before implementing a dynamic daily report. ### 5. Assemble the manager's dashboard Choose **New Dashboard** and enter `Tutorial Projects — Monday view` in **Dashboard title…**. On the right, open **Queries**. Drag **Tutorial Overdue decisions by owner** from the list into the central **Empty Dashboard** area. The heading changes from zero sections to **1 section**. ![An empty dashboard accepts saved queries from the right-hand panel.](assets/appmint-dashboard/22-new-dashboard.png) Select the dashboard's **Save**. A second form titled **Save Dashboard** opens. Set **Name** to `tutorial-projects-monday`, check that **Title** is `Tutorial Projects — Monday view`, leave the generated section settings in place, and select the form's **Save**. ![Saving a dashboard includes a metadata confirmation form.](../learner-review-records/assets/operations-dashboard-local-20260919/19-dashboard-save-form.png) Do not stop after opening that form: the save result in this walkthrough was **Data inserted** after submitting it. ### 6. Reopen the saved result Use the item picker at the top left, choose **Home**, and select **Refresh**. The home list should show your dashboard and queries. Open **Tutorial Projects — Monday view** and check the mode button. If it says **Editing**, select it to switch to **Viewing**; an already reopened dashboard may start in **Viewing**. The saved card displays the result table and the chart. Both show the same two owner groups. ![The reopened dashboard in Viewing mode.](../learner-review-records/assets/operations-dashboard-local-20260919/33-dashboard-fixed-after-reload.png) The table and chart should appear together without a configuration warning. If either result is empty, open the saved query, check its selected collection, and run it again before changing the dashboard layout. **Check yourself:** is an unassigned project a reason to drop a row from the chart? No. It is a reason to assign the next action. ## Part 4 — Turn a finding into a task ### 1. Open Workspace and create a private project space Choose **App Root → Workspace**. The left rail has **Home**, **Inbox / Mentions**, **My Tasks**, **My Calendar**, and **Recent Files**. These are personal views across the spaces you can access. Beside **WORKSPACES**, select the plus button titled **New workspace**. Enter: - **Name:** `Tutorial Bennett kitchen — private`. - **What it is for:** `Decisions and files for the Bennett kitchen project`. - **Private:** checked. - **Ends on (optional):** leave empty for this ongoing practice space. - **Members:** leave empty to start with your own account only. Select **Create**. The heading should say **Workspace · private · 1 member**. ![Creating the private project space.](../learner-review-records/assets/operations-dashboard-local-20260919/22-private-workspace-form.png) Private limits access to people added or invited to the space. Public allows people in the organization to read it; joining allows participation. Use a private space for client-specific project material. An expiration ends access; it is not the same as deleting its contents. ### 2. Write a task with a clear finish line Inside the space select **Tasks → New task**. Fill: | Control | Value | | --- | --- | | **Title** | `Tutorial Confirm lighting plan` | | **Details** | `Compare the two pendant options with the room photograph. Done means the preferred option and reason are recorded in this task.` | | **Assign to** | Search for and select your own account. | | **Due** | Leave empty for this exercise. | | **Agenda** | No agenda. | Leave files and expiration unchanged. Select **Create task**. ![A useful task describes the decision and what finished work looks like.](../learner-review-records/assets/operations-dashboard-local-20260919/23-actionable-task-form.png) The task appears under **To do**. The form explains that assignees are notified; this exercise assigns only your own training account. When using this with colleagues, choose the actual responsible person rather than assigning everyone in the space. ### 3. Check it from the assignee's view Reload Studio, open **Workspace**, and select **My Tasks**. You should see **Tutorial Confirm lighting plan**, its project-space name, and **To do** status. ![The assigned task remains visible in My Tasks after a reload.](../learner-review-records/assets/operations-dashboard-local-20260919/24-my-tasks-after-reload.png) An unassigned task does not appear in a particular person's **My Tasks**. Workspace membership and task assignment are different decisions: membership gives access; assignment identifies responsibility. The workspace also exposes **Activity**, **Files**, **Calendar**, **Agenda**, **Analytics**, and **Members**. Keep project conversation and files together there. The collection remains the structured source for the dashboard; a similarly named workspace is not an automatic database relationship. ## Part 5 — Recover a practice record and inspect an import ### Recover a versioned project Use only the disposable practice project for this exercise. Confirm that **Enable Versioning** was turned on before deleting it; Trash retention is conditional, not universal. 1. Open **Database → Data Explorer → Design Projects**. 2. On **Tutorial Okonkwo bathroom**, select the row's **Delete** trash icon. Read the confirmation and select **Delete**. It leaves the project table. 3. Open the dedicated Trash application at `/app/trash` on the **same Studio address you already use**. On the tested build, the sidebar's **Trash** icon instead opened an older generic table whose columns did not expose a useful restoration action. 4. Locate **Tutorial Okonkwo bathroom**, check **SOURCE DATATYPE** matches your chosen collection identifier (`cf_project` if you used the example name), and select its **Restore** action. 5. Return to **Data Explorer → Design Projects** and confirm the project is back. The successful result matters more than a stale Trash list that has not yet refreshed. ![The dedicated Trash application identifies the project and its original collection.](../learner-review-records/assets/operations-dashboard-local-20260919/26-trash-project.png) ![All five projects are present again after restoration.](../learner-review-records/assets/operations-dashboard-local-20260919/27-five-restored-projects.png) **Delete inside Trash permanently removes the retained copy.** That is different from **Restore**. Restoring the record is also different from restoring an earlier budget value: record-version history and deleted-record recovery solve different problems. This walkthrough verifies the deleted-record path only. ### Inspect a batch before writing it Open the [practice CSV](assets/appmint-dashboard/projects-validation.csv). The middle row deliberately has no title. You already saw the record form reject that error; the batch exercise checks whether the importer catches it too. 1. Open **Database → Import, Export Data → Start New Import**. 2. In **Select Data source**, choose **Paste JSON or CSV**. 3. Paste the complete CSV into the editor and select **Process Data**. Wait for processing, then select **Next**. If the editor overlaps the button, use Tab to focus **Next** and press Enter. 4. In **Preview**, check that three rows appear and the middle title is empty. Choose **Next**. 5. In **Define Collection**, select the existing **Design Projects** collection. Check that `title`, `budget`, `stage` and `nextDecisionDate` map to fields with those same names. Choose **Next**. 6. In **Finish**, check **Validate only**. Leave **Update existing records** at **Always add a new record** for this diagnostic. Choose **Finish - Import Data**. ![The parsed CSV reveals its deliberately empty title before any import.](../learner-review-records/assets/operations-dashboard-local-20260919/28-import-preview.png) ![Validate only is selected on the final import screen.](../learner-review-records/assets/operations-dashboard-local-20260919/34-validate-only-after-fix.png) The report should say **Validation found errors — nothing was written**, with **Rows read 3**, **Created 0**, **Skipped 2**, and **Failed 1**. Under **Rows that failed validation**, row **2** reports `title must have required property 'title'`. The two valid rows are counted as skipped because **Validate only** does not write them. ![The validation report identifies row 2 and its missing required title; no records were written.](../learner-review-records/assets/operations-dashboard-local-20260919/36-validation-report-fixed-wording.png) Keep **Validate only** enabled. Correct the missing title in a copy of the source file and validate that copy before considering a real import. For this exercise, stop after inspecting the error report and return to Data Explorer: the original five projects should still be the only records. The dashboard does not depend on batch import. For a later real import, choose an update-match field only if it uniquely identifies a record in your organization. Matching on a repeated project title can update the wrong project; always adding rows on a retry can create duplicates. Inspect the resulting records as well as the job totals. This walkthrough did not execute the faulty batch or an update-in-place import. ## Part 6 — Extend the model carefully **Make the dashboard current.** Keep the fixed date for practice. For daily operation, define whether “overdue” means earlier than today's business date or earlier than the current instant. Update both queries together and test a decision due today, yesterday, tomorrow, and a completed project. A report's title should explain a fixed cutoff when one is used. **Connect clients.** Create or locate the real customer through the CRM course, then use **Client** to select it. A lookup's stored ID is the link. It does not create a customer account, invite the customer, or grant access to every project. **Expose selected projects in a portal.** Continue with [Create a client dashboard](appmint-create-a-client-dashboard.md). Showing all `cf_project` records on a customer-facing page would expose other clients' projects. Apply customer identity and server-side authorization deliberately; hiding a field on a form is a presentation choice, not access control. **Automate the next action.** Continue with [Automate a business handoff](appmint-automate-a-business-handoff.md) after the collection and records are reliable. Enabling the collection's workflow flag does not define the trigger, responsible person, retry behavior or completion rule. **Field rules and history.** The builder's **Magic** panel is the place to explore field interactivity. If a date field is hidden when Stage becomes Complete, that does not imply the stored date has been cleared. Retain the explicit Complete exclusion in the query. A record-history restore, a field-visibility rule and a deleted-record restore require different checks; do not substitute one for another. ## If something goes wrong | Symptom | First check and next action | | --- | --- | | Dragging a field does nothing | A new schema may be `{}`. Paste the supplied object schema, wait, then return to Schema Builder. | | Field rename or title disappears | Let the property update settle before changing fields or tabs. Confirm the result in JSON Schema. | | Lookup Properties says No Schema Defined | Use the supplied lookup `dataSource` configuration; verify real results in Form Preview. | | Cannot find `cf_project` in collection search | Search **Design**, the display title. | | Project is rejected with missing `title` | Fill the **Project** field. | | Owner shows a long identifier | This is the selected user's stored ID. Use the lookup to select a user; do not replace the ID with a guessed name in raw JSON. | | Chart shows more than two projects | Check the review date, Complete exclusion and fixture values. A later cutoff legitimately includes more work. | | Query says Invalid JSON | Replace the whole editor contents with the downloaded pipeline using paste. | | Saved dashboard is absent from Home | Select **Refresh** in Data Studio before creating it again. | | Dashboard card has no result | Reopen its saved query, select the intended collection and run it; then reopen the dashboard. | | Sidebar Trash has confusing columns | Use the dedicated `/app/trash` route on your existing Studio host. | | Import validation rejects row 2 | Expected for the supplied exercise CSV: its title is empty. Keep Validate only enabled while correcting a copy. | | My Tasks is empty | Verify the task was assigned to your account, not merely created in a space you belong to. | ## What happened behind the scenes
Developer notes and source pointers Paths below are relative to the software repositories. They explain the observed implementation; the screenshots above establish the practiced interface. - `websitemint/packages/ui/src/components/collection-builder/collection-store.tsx`: new collections begin with an empty schema; `updateSchemaItem` performs field renames. - `collection-editor-object.tsx` in the same directory: an object without `properties` returns the empty-properties message before the usable drop area. - `collection-property-form.tsx`: property updates are debounced; lookup controls have no corresponding property schema in the tested registry. - `websitemint/packages/ui/src/components/appmint-form/form-elements/data-lookup-combo.tsx`: lookup collection, label and stored value come from `dataSource`; `sk` is the default stored value. - `select-single-element.tsx` and `select-many-element.tsx` in that directory: the former is a checkbox/radio/switch family; the latter supplies the choice list used with a string stage value. - `websitemint/packages/ui/src/components/data-view/data-explorer.tsx`: collection search, record forms and table actions. Table/Grid/Kanban controls exist in this build; this course practices Table. - `websitemint/packages/ui/src/components/data-viz/workspace/index.tsx`: saves query content, collection and chart mapping to a `dataviz_item`. - `data-viz/layout/layout-card.tsx`: loads the saved item and renders both table and chart. Configuration checks include saved item/query references as well as legacy table/chart fields. - `appengine/src/history/history.service.ts`: `shouldTrack` honors a collection's boolean `enableVersioning`; `trashCreate` retains the record and `trashRestore` restores its original datatype. - `appengine/src/repositories/repository.crud.service.ts`: deletion also affects related history/tasks/schedules. Restoring the base record is not a promise to restore every related object. - `appengine/src/repositories/repository.bulk.service.ts`: mapped CSV rows are coerced to schema types; valid dry-run rows are counted as skipped. `src/util/validatorData.ts` normalizes Studio field-required metadata, validates standard rules/formats and reports schema compilation failures. `data-import/step-report.tsx` distinguishes dry-run validation errors from actual import results. The [local repair report](../application-fixes/dashboard-validation.md) records the reproduced defect and browser recheck.
## Where next Use the [business handoff course](appmint-automate-a-business-handoff.md) to connect project changes to repeatable work. Use the [client dashboard course](appmint-create-a-client-dashboard.md) for a customer-facing view. The [permissions course](appmint-roles-groups-and-permissions.md) explains how to control who can manage the underlying data.
Walkthrough evidence and production boundary Executed independently on local Studio Manager 0.6.2 and AppEngine, 19 September 2026, using the newly created learner owner's account in a separate browser context. The fresh-run collection is `cf_dash_20260919`, titled **Tutorial Design Projects 20260919**; its different title/identifier explains the corresponding screenshots. No author account or historical storage was used. The actual browser sequence created and reopened the collection, rejected a missing Project title, entered all five records through the form and Owner lookup, then read them back after reload. Both supplied pipelines ran against this collection: two owner groups with count 1 each, and Ahmed/Bennett as the two matching detail records. Two queries and the dashboard were saved and reopened; the fixed card shows both table and bar chart without the old configuration warning. The private Workspace contained only the learner account. Its self-assigned task appeared in My Tasks after reload. The disposable Okonkwo record was deleted, located in dedicated Trash with the correct source datatype, restored and found again in the five-record table. The CSV exercise ran only in Validate only mode: after the reproduced validator defect was repaired, the actual report showed 3 read / 0 created / 2 skipped / 1 failed and row 2's missing-title error. Data Explorer still contained exactly the original five projects afterward. [Execution record and evidence index](../learner-review-records/assets/operations-dashboard-local-20260919/progress.md). The actual browser checks establish this lesson's required practical outcome. Optional Part 6 guidance and links do not claim execution of a dynamic-date implementation, customer portal access, workflows, second-member isolation, invitations or a real batch write. Those remain separate lessons or implementation choices. No external messages, providers or deployments were used. Historical 18 September captures remain only where the interface explanation is unchanged; companion-video instructions are in [the production guide](production/appmint-build-an-operations-dashboard.md).
--- # Give every new project an owner, a kickoff stage and a visible finish > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-automate-a-business-handoff.html # Give every new project an owner, a kickoff stage and a visible finish ![Creating a project automatically places its task in Brief received.](../application-fixes/assets/handoff-project-auto-task.png) A signed agreement is exciting. The handoff after it should be predictable: create the project, put the kickoff in somebody's queue, and keep the work visible until it is finished. This course first builds the part you can use immediately: a project record automatically starts a three-stage workflow. Then you inspect a separate automation that creates a project, follow its execution into the same board, and discover what happens when that action runs twice. **For:** owners and operators who already understand the handoff they want to automate. **Product:** Appmint Studio Manager and, in the developer branch, AppEngine. **Allow:** 45–60 minutes for the workflow; another 30–45 minutes for the automation laboratory. **Level:** confident beginner for the board; developer for the JSON configuration exercise. **Build practiced:** Studio Manager 0.6.2, locally rechecked 21 September 2026. ## What you will have at the end - **Tutorial Project kickoff**, attached to your `cf_project` collection. - Three stages: **Brief received → Kickoff scheduled → Done**. - Owner responsibility, a two-day SLA on the middle stage, and an explicit terminal status. - A task created automatically when you save a new project, with an auditable history of its movement. - A completed task archived from the board but retained in **All tasks**. - A manually controlled automation test, its actual backend result, and two separate records demonstrating why retries need a duplicate-prevention design. The signed-agreement event and team-notification extension is an integration checkpoint at the end. This practice verifies project creation, responsibility and execution results. It does not claim that a real signing provider or customer notification has been connected. ## What you need Finish [Build an operations dashboard](appmint-build-an-operations-dashboard.md). You need its **Design Projects** collection, identifier `cf_project`, with **Enable Workflow** on. Use the owner account of your training organization. The starter path uses the real **Owner** role. It does not require a fictional Design Team or a second employee. For a team deployment, create the actual people and groups through [Roles and permissions](appmint-roles-groups-and-permissions.md) before assigning work to them. No external emails, SMS messages, team alerts or customer invitations are part of this practice. The task belongs to your training account. Appmint and its apps are free. ## The story: one handoff, two different tools Jordan at Cedar & Form needs a consistent kickoff process. A project moves through intake, scheduling and completion. Later, a signed agreement may create that project automatically. | Tool | Job | Example | | --- | --- | --- | | **Workflow Center** | Track work through stages and identify who decides next. | A new project waits in Brief received. | | **Smart Automation** | Run configured actions. | Create a project record from an incoming business event. | | **Approvals** | Show decisions waiting on the current person. | The Owner sees the kickoff task in Waiting on you. | | **Schedule Management** | Inspect scheduled work and its execution views. | Time-based work appears separately from an immediate manual run. | Both Workflow Center and Smart Automation use the word “workflow.” In this lesson, **definition** means the staged process in Workflow Center; **automation** means the action sequence in Smart Automation. ```mermaid flowchart LR A[Save a project record] --> B[Brief received] B --> C[Kickoff scheduled: 2-day SLA] C --> D[Done: explicit done status] D --> E[Archive: retained in All tasks] F[Manual automation: Create Record] --> A ``` The `stage` field on a Design Projects record is your design phase: Brief, Concept, Detail, Build or Complete. The workflow stage is the **handoff's** position. Finishing kickoff does not automatically mark the whole design project Complete. ## Part 1 — Define a process people can actually follow ### 1. Open Workflow Center In the left sidebar choose **AI, IVR, Automation → Workflow**. The page heading is **Workflow Center**. Its view selector includes **Dashboard**, **Definitions**, **Analytics**, and **Templates**. Your organization may already have approval and business-process definitions. In this training organization, **Publishing approval** was off and **Access request approval** was on; the Cafe setup also supplied operational workflows. Leave existing processes unchanged. ![The initial board with its seeded approval definitions.](assets/appmint-handoff/01-workflow-center.png) Select **Definitions**, then **New Workflow**. A drawer opens with **Modern** and **JSON** views and a **Save** button. ### 2. Start with your own definition The drawer offers several built-in shapes under **Seed from a template**: reservation check-in, prep, pickup, application processing, service appointments and renewals. These create or reopen actual definitions in your organization; they are not just pictures to preview. For this project exercise, leave the templates unselected and use the blank form. Enter: | Field | Value | | --- | --- | | **Name (slug)** | `tutorial-project-kickoff` | | **Title** | `Tutorial Project kickoff` | | **Description** | `From a design project record to an owned kickoff decision.` | ![A new definition offers templates and a blank Basics section.](assets/appmint-handoff/02-new-definition.png) The slug is the stable machine name; the title is what operators see on the board. Keep the slug lowercase with dashes or underscores. If your earlier practice already created it, edit that definition rather than starting a duplicate. ### 3. Attach it to the project collection Expand **Auto-attach to data types**. In **Collections**, search `cf_project` and select **Design Projects**. A selected collection chip should appear. Click the Name field or another area outside the collection picker to close its options before changing the switches below. Check **Enabled** and **Start on create** are on. Their purposes differ: - **Enabled** permits this workflow to start. - **Start on create** gives each newly created record in the selected collection a task. - The collection's **Enable Workflow** setting permits its records to participate. You need all three decisions to agree. This is not a retroactive instruction to start work for every old project in the database. Leave **Auto-archive done tasks after** at the displayed `7 days` for this exercise. That is a cleanup policy for completed task visibility, not a seven-day deadline for the project. ### 4. Add Brief received Select **Add first stage**. Enter **Stage name** `Brief received`; leave its type at **Start**. Use the chevron titled **Expand stage** to see its settings. Scroll within the expanded stage settings to **Who decides**, below Assign to. Keep **By → People holding a role**. Enter `Owner` in **Roles (comma separated)**. Keep **Decision → First to decide decides**. Enter `Owner` in **If nobody is found, fall back to roles**. Leave notification templates and **Stage inputs** empty. Leave **SLA Escalation Tiers** empty for this first stage. ![Brief received: Owner responsibility, first-to-decide mode and Owner fallback](../application-fixes/assets/handoff-stage-responsibility.png) Before saving, return to the definition controls and verify **Enabled** and **Start on create** are both on, then check the saved definition again after reopening. **Assign to** and **Who decides** are not interchangeable labels. The former provides fixed default assignees; the latter resolves responsibility when the record enters the stage. The role-based setting worked with the real Owner account in this walkthrough. If you switch to a group later, check that the group actually contains the people who should receive the work. With **First to decide decides**, one eligible decision completes the decision step. **Everyone must approve** expresses a different business rule; choose it only when you intend to wait for all required people. ### 5. Add Kickoff scheduled and its two-day SLA Collapse the first stage and select **Add stage**. Name the new stage `Kickoff scheduled`; keep its type **Intermediate**. Expand it and repeat the Owner role and fallback settings. Under **SLA Escalation Tiers**, select **Add tier**. Set: | Control | Value | | --- | --- | | **After** | `2` | | Unit | **days** | | **…OR ANYONE HOLDING A ROLE** | `Owner` | Leave escalation templates empty. The board later displays **48h left** when the task enters this stage. The timer belongs to entry into this stage; it is not the project's `nextDecisionDate`. ![A two-day SLA tier on Kickoff scheduled.](assets/appmint-handoff/04-middle-stage-sla.png) A configured escalation is not evidence that a notification was delivered. This exercise verifies the displayed deadline, not a two-day elapsed escalation run. ### 6. Add Done and set its completion values Add the third stage and name it `Done`. Change its type to **End**. Expand it and set: - **Model state:** `completed`. - **Model status:** `done`. ![The terminal stage explicitly sets completed state and done status.](assets/appmint-handoff/11-end-stage-status.png) > **Do not skip these settings.** In the first test, changing only the stage type to End left its default Model status at `new`. The task moved into Done and acquired a closed timestamp, but still showed `new` and an **Advance** button. After correcting the mapping, a new task showed `done` and **Archive** instead. The state/status settings also affect the source record's workflow-related fields. They do not change the custom Design Projects `stage` field to Complete. ### 7. Save and reopen the definition Select **Save**. In Workflow Center select **Refresh**. **Definitions** should list `tutorial-project-kickoff` with three stages. The definition card can show **Draft** while the board's workflow switch is **On**; check **Enabled**, not the card's generic status badge, when troubleshooting automatic starts. ![The saved definition appears after refreshing the list.](assets/appmint-handoff/06-definition-saved.png) **Check yourself:** should you enable Start on create for a process that begins only when someone requests approval? No. That setting starts on every new record of its attached collection. An intentional request needs its own start action. ## Part 2 — Prove the handoff with one project ### 1. Create the source record Open **Database → Data Explorer**. Search collections for **Design**, choose **Design Projects**, and select **Add Record**. Enter **Project** `Tutorial Okonkwo garden room`, choose your own account in **Owner**, select **Stage → Brief**, and enter **Next decision date** `2026-10-02`. Leave Client and Budget empty in this training record. Save. Return to **AI, IVR, Automation → Workflow**. Use the Workflow Center view selector to choose **Dashboard**, then **Refresh** if needed. The **Tutorial Project kickoff** board should contain **Tutorial Okonkwo garden room** under **Brief received**, with your account waiting to act. ![The task was created by saving a project, not by manually adding a board card.](../application-fixes/assets/handoff-project-auto-task.png) The organization may also show a **waiting on you** notice and **Open Approvals** link. That is another entry into the same responsibility, not another copy of the project. ### 2. Advance into the SLA stage On the task card choose **Advance**. Expect **Moved to Kickoff scheduled** and the card under the middle stage. It should display approximately **48h left**. ![The task has moved and its stage timer is visible.](../application-fixes/assets/handoff-middle-stage.png) In real work, advance after the brief has actually been received and kickoff scheduling is the next responsibility. Moving a card is a business decision; the button does not itself book a calendar appointment. ### 3. Finish and inspect the task Choose **Advance** again. With the completion mappings from Part 1, the card reaches **Done**, shows `done`, and offers **Archive**. Click the task title to inspect its details. The panel identifies the workflow, current stage, waiting person, opened/closed times, and stage history. Use this history to explain where work moved and when. ![Completed test tasks show done and an Archive action.](../application-fixes/assets/handoff-task-done.png) In the local verification, the board changed to **0 in flight**, **done:1** and **100% complete** after the task reached Done. Check the actual task status and history as well as the summary. If a view is stale, Refresh; do not advance a completed task again just to change a counter. ### 4. Archive completed work without deleting its evidence Select **Archive** on the completed garden-room task. It leaves the board. Select **All tasks** beside the workflow's On/Off switch. The retained row should show **Done**, `done`, and an **archived** marker. This is how a clean board can coexist with a useful operational history. ![All tasks retains the archived task and its outcome.](../application-fixes/assets/handoff-task-archived.png) Do not delete the workflow definition as a substitute for finishing its tasks. A definition is the process the tasks refer to; removing it can leave existing work without a usable next transition. ### If you already tested the wrong Done mapping Editing the definition does not rewrite the statuses of tasks that already reached its old terminal stage. On the disposable training task, the practiced recovery was: 1. Correct **Done → Model state completed / Model status done** and save. 2. Drag that task from Done back to **Kickoff scheduled**. 3. Choose **Advance** again. 4. Check that it now says `done` and offers **Archive**. Re-entering stages can repeat stage-entry effects. Use this recovery only on a training task with no notification templates or other side effects; review real task consequences before doing it in business operations. ## Part 3 — Inspect the automation builder before turning anything on ### 1. Open Smart Automation Choose **AI, IVR, Automation → Automation**. The screen is **Smart Automation**, with **Dashboard**, **Workflows**, **Workflow Builder**, **Runs & Logs**, and **Templates**. Choose **New Workflow**. On this build it opens the builder directly. Enter `Tutorial Signed agreement to kickoff` in **Workflow Name** and `tutorial-agreement-kickoff` in **Slug**. ![Smart Automation's management views.](assets/appmint-handoff/10-automation-home.png) ### 2. Inspect the actual trigger configuration Select **Add Trigger**. It inserts a row; open that row's **Select trigger…** control and choose **Record Updated**. Select the row's **Configure** gear. The configuration exposes **Datatype** and **Watch Fields**. For this inspection, select **Design Projects** from Datatype and enter `stage` in Watch Fields. Select the collection result; typing a name without selecting it does not establish the collection. ![The collection picker lists the Design Projects collection created earlier.](../application-fixes/assets/handoff-trigger-picker-fixed.png) `stage` is the field key in the project schema. It restricts this trigger to updates that change that field. It does **not** mean “the agreement was signed” or “the stage is Done”: watching a field and testing its new value are different conditions. This draft is a configuration exercise; do not start it. Select **Save**, open **Workflows**, select the draft's **Edit automation** pencil, and reopen **Configure**. Confirm Datatype displays `cf_project` and Watch Fields displays `stage`. The friendly collection title and its saved datatype are two names for the same collection. ![The reopened draft retains its selected datatype and watched field.](../application-fixes/assets/handoff-trigger-readback.png) **Data** in the toolbar opens a collection-field reference panel; it is not a raw editor for the automation definition. The next section replaces this unstarted trigger draft with a manual-only training action, keeping its record identity. There will be no automatic record trigger or customer message in that test. ## Part 4 — Developer laboratory: test the action and inspect its real result ### 1. Convert the draft into a manual-only test Download [manual-kickoff.data.json](assets/appmint-handoff/manual-kickoff.data.json). Read it before applying it: it contains one `create_data` action and **no trigger**, email, SMS or alert action. Each explicit execution creates one training project. Open **Database → Data Explorer**, search collections for **Automation**, and choose the **Automation** collection, not AutomationExecution or AutomationLog. Find your draft and select its row's **Edit Raw JSON** action. The editor contains the entire record, including `_id`, `pk`, `sk`, `datatype`, `data` and metadata. Preserve your record's identity fields. Replace only the value of **`data`** with the downloaded object's contents. Set the outer `name` to `Tutorial Manual kickoff test` as well. Do not replace your whole record with somebody else's IDs. The downloaded data defines: ```json { "id": "create-project", "type": "action", "name": "create_data", "label": "Create Record", "enabled": true, "order": 1, "config": { "datatype": "cf_project", "data": { "title": "Tutorial Automated kickoff sample", "stage": "Brief", "nextDecisionDate": "2026-10-06T00:00:00.000Z" } } } ``` This is a **step**, inside the downloaded automation's `steps` array. The automation's status remains `draft` until you deliberately start the test. ![The manual-only definition in the raw editor; the existing record identity is retained.](../application-fixes/assets/handoff-manual-raw.png) The raw editor requires **order ≥ 1**. It rejected both a missing order and zero in this walkthrough. This is why copying a visual builder's incomplete step object is not enough. The action handler expects an object at **`config.data`**. Preserve the tested object structure in the file. The repaired visual builder converts its Data Fields JSON editor into this object when saving; raw JSON must already use the backend structure shown here. Select **Save**. Reopen Smart Automation and select **Workflows**. You should find **Tutorial Manual kickoff test**, status `draft`, with one `create_data` step. ### 2. Start, execute once, then stop In **Workflows**, find **Tutorial Manual kickoff test** and confirm it has exactly one `create_data` step. Hover the card's controls to distinguish **Start automation**, **Execute automation once**, **Stop automation**, and **Edit automation**. 1. Select **Start automation**. Wait for the card to show `active`. 2. Select **Execute automation once** exactly once. Wait for the result notice. This action creates a record; it is not a preview. 3. Select **Stop automation**. Confirm the card returns to `inactive`. ![The manual training automation is inactive after execution.](../application-fixes/assets/handoff-manual-stopped.png) Starting enables execution; it does not create the sample project by itself. This definition has no trigger, so the explicit Execute action is what creates the record. Stopping afterward does not undo that record. If a request times out, inspect **Runs & Logs** and **Design Projects** before pressing Execute again. A missing browser response does not prove that the server did nothing. ### 3. Read the action's result Open **Runs & Logs → Execution Runs**. Select your most recent completed run. The list may identify the automation by its record ID; use the execution time to distinguish the two practice runs later. In **Execution Details**, confirm status `completed`. Under **Execution Steps**, find **Step create-project**, also `completed`. Expand **Action result**. Check these fields: | Field | What it tells you | | --- | --- | | `created: true` | The action reports creating a record. | | `datatype: cf_project` | It wrote to your Design Projects collection. | | `recordId` | The identity of the newly created project. | | `record.data.title` | The title should be Tutorial Automated kickoff sample. | | `record.data.stage` | The initial design phase should be Brief. | ![A completed action exposes its actual result and created project ID.](../application-fixes/assets/handoff-run-details-fixed.png) The result shown here comes from the second practice execution after a local application repair. Historical runs written before that repair can lack step results. Do not infer missing details from the workflow's configuration: configuration describes the requested action, while the result describes what happened. For developers, the same operation is `POST /automation//execute`, with the organization's authenticated AppEngine client. It is optional here: the Studio button was tested successfully. Setting up the client and protecting its credentials is covered in [Build a connected client](appengine-build-a-connected-web-or-mobile-client.md). Do not send a second API execution simply to inspect the first UI run. ### 4. Follow the result into your business records Choose **Database → Data Explorer**. Search collections for **Design Projects** and select it. Find **Tutorial Automated kickoff sample**. Use its row's **Edit Raw JSON** action to compare the saved `sk` with the result's `recordId`; close without changing it. This matters when multiple records share a title. Return to **AI, IVR, Automation → Workflow**. Under **Tutorial Project kickoff → Brief received**, find the new task. The automation created a project; the collection's enabled workflow then created the handoff task. The project's Owner field is empty in the supplied manual test. The workflow task is assigned to the training owner through the **Owner role** rule. Record ownership and task responsibility are separate settings; one does not silently fill the other. ### 5. Repeat once to understand duplicate creation Return to **Smart Automation → Workflows**. Repeat **Start automation → Execute automation once → Stop automation** once. Confirm `inactive` again. Open the newest completed run and compare its `recordId` with the first result: they are different. Open **Data Explorer → Design Projects**. There should now be **two** records titled **Tutorial Automated kickoff sample**, in addition to your original garden-room project. Check the two raw record IDs if you need to distinguish them. ![Two explicit executions produced two saved projects with the same title.](../application-fixes/assets/handoff-two-projects.png) Open **Workflow Center** again: **Brief received** now has two corresponding tasks, each assigned to your training owner. ![Both automation-created projects entered the kickoff workflow.](../application-fixes/assets/handoff-two-tasks.png) The [local execution evidence](../application-fixes/assets/handoff-execution-proof.json) contains the two actual response IDs and the saved run details. These are examples, not IDs to reuse in your organization. A repeated title does not make creation idempotent. Each explicit execution created another project and task. Keep the clearly named training records for comparison, and leave the automation stopped. **Check yourself:** does “status equals signed” prevent every duplicate? No. Two updates can both carry that status, and two concurrent runs can both pass a condition before either creates a record. A production integration needs a stable source-agreement identifier and duplicate prevention enforced where the project is created. ## Part 5 — Connect the production business event deliberately The intended extension remains valuable: **a signed agreement creates one project and its kickoff task**. Complete the manual workflow and action tests first, then validate these specific integration points on your organization's signing flow: | Decision | What to establish | | --- | --- | | Trigger scope | The actual signed-document datatype, emitted event and transition that means signing completed. | | Event payload | Where the record, signer/customer identity and title appear in the execution context. | | Condition | The verified signed-state field and whether this is the first transition into that state. | | Project mapping | A real title, actual customer/user identifiers, and an initial design stage. | | Duplicate protection | A stable agreement ID associated with the created project and an enforced once-only creation rule. | | Notification | The real recipient(s), channel and template, tested separately before enabling messages. | | Failure recovery | How an operator finds a failed run, identifies any already-created project and retries without duplicating it. | Do not copy `{{document.title}}` or a person's typed name into a lookup merely because it looks plausible. Those paths and identities must match the actual event data. A successful manual creation with fixed values does not establish the signing event's payload. The template catalogue and AI bar can help draft a sequence. Read the resulting configuration and run its safe training case before enabling it. A generated action name is not evidence that its handler exists or that its configuration matches the backend contract. ## Part 6 — Approvals, schedules and operational checks ### Find work waiting on you Open **App Root → Approvals**, or select **Open Approvals** in the waiting notice. The page has **Waiting on you**, **My requests**, **Notices**, and **Decided**. The two manual-test tasks appear under **Waiting on you**. Open one to inspect **Reassign**, **Escalate**, **Decline**, and **Approve**. Reassign is disabled while **Hand to someone else (email)** is empty. Leave the decision unchanged in this lesson: this visit establishes where the owner finds waiting work. Close Approvals with the panel's × control before returning to the sidebar. ![The current approval tray and its four views.](../application-fixes/assets/handoff-approvals.png) For actual access requests, continue with the permissions course. The seeded access-approval definition and this custom kickoff task have different business consequences, even when both appear in the tray. ### Locate scheduled work Choose **AI, IVR, Automation → Schedule**. The tested screen is **Schedule Management**, with **Overview**, **Upcoming**, **Runs**, and **Logs**, plus Database and Redis Queue counts. ![Schedule Management has its own job and execution views.](../application-fixes/assets/handoff-schedule.png) No schedule was created in this lesson. A manual run is not a recurring schedule. Before scheduling a business handoff, use [Reliable background jobs](appengine-run-reliable-background-jobs.md) to verify time zones, stored schedule identity, cancellation and retry behavior. Do not infer that stopping an automation removes every independently queued job. ### Distinguish the three kinds of stopping - **Workflow Enabled off:** prevents new starts; it does not delete existing tasks. - **Stop automation:** changes the automation's execution state; inspect any in-progress or already-produced effects separately. - **Archive completed task:** removes a finished task from the board while retaining its outcome in All tasks. Those controls solve different problems. Deleting records to make counters look tidy is not a recovery strategy. ## If something goes wrong | Symptom | Check and response | | --- | --- | | No task after creating a project | Confirm collection Enable Workflow, attached collection, definition Enabled, and Start on create. Existing records are not the new-record test. | | Task is unassigned | Check the actual role/group membership and the stage's Who decides settings. | | No deadline in Brief received | Expected: that stage has no SLA. The two-day tier belongs to Kickoff scheduled. | | Card is in Done but status remains new | Configure End, Model state completed and Model status done. Existing task values are not automatically rewritten. | | Counts disagree with cards | Refresh and inspect the task status and archive state. Archived done tasks remain historical work but are no longer in flight. | | Trigger collection picker is empty | Confirm the prerequisite collection exists and that you can view it in Data Explorer. Clear the lookup search and reopen it. Keep an unscoped trigger inactive and report a persistent lookup error. | | Raw automation save rejects order | Give each step a one-based order value, starting at 1. | | Execute fails or times out | Inspect Runs & Logs and actual project records before retrying. Report the error with its execution ID if available. | | Completed run has no step detail | Older runs may lack stored results. Compare any retained response with the actual record; report a new run that lacks results. Do not repeat creation merely to obtain a screenshot. | | Two projects exist for one source | Repeated creation is not deduplicated. Identify source IDs and actual side effects before retrying again. | | A stopped test still has a task | Stopping the automation does not undo records and tasks it already created. | ## What happened behind the scenes
Source checks for developers - `websitemint/packages/ui/src/components/workflow/workflow-form.tsx`: modern definition fields, role assignment, stages, SLA tiers, model state/status, and seed templates. - `appengine/src/workflow/workflow.service.ts`: creates tasks and mirrors the entered stage's `modelStatus` onto task status; entering a terminal stage stamps completion. A terminal type with `modelStatus: new` can therefore produce a closed timestamp with a new status. - `appengine/src/workflow/workflow-escalation.job.ts`: escalation and completed-task archival processing. The two-day elapsed escalation was not exercised. - `websitemint/packages/ui/src/components/automation/automation-schemas.tsx` and `automation-config.ts`: collection lookup, backend datatype/operation/field filters, and conversion of the Fields editor into the Create Record action's data object. - `websitemint/packages/ui/src/utils/request/api-endpoints.ts` and `automation-store.ts`: the execution request appends the automation ID and `/execute` to the automation base path; failed results are reported as errors. - `appengine/src/automation/controllers/automation.controller.ts`: `POST :automationId/execute`, active-status check, supplied variables and execution result. - `appengine/src/automation/services/automation-runner.service.ts`: skips trigger steps during manual execution and records the execution; the UI's Execute action does not ask the operator to pick a signed document. - `appengine/src/automation/actions/create-data.action.ts`: requires `datatype` and an object `data`; creates a new record and returns its ID. Repeating that action creates a distinct record. - `websitemint/packages/ui/src/components/automation/app.tsx`: Import accepts an older `name/trigger/actions` shape. The active runner uses `steps`. No successful round-trip import of this manual automation is claimed.
## Where next Use [Connect a custom business API](appengine-connect-a-custom-business-api.md) for supplier or external-system actions. Use [Reliable background jobs](appengine-run-reliable-background-jobs.md) before putting retries or recurring work into service. The [AI assistant course](appengine-build-an-ai-business-assistant.md) explores assisted actions and their authorization boundaries.
Walkthrough evidence and production boundary Rechecked on local Studio 0.6.2 and AppEngine, 21 September 2026. The definition and six-field prerequisite collection were created through the interface. A project started a role-assigned task, entered its 48-hour SLA stage, finished with done status, and was archived and found in All tasks. The fresh board showed zero in flight and 100% complete before the manual automation tests added two new tasks. The repaired trigger picker saved cf_project/updated/stage correctly. Its unstarted draft was converted to the supplied manual-only action while retaining its record identity. Two UI start/execute/stop sequences created two distinct projects and two tasks. The first run exposed missing stored action results; the application repair was verified on the second run, which retains the completed step and its created record ID. Invalid JSON was also rejected visibly while retaining the editor; the original valid configuration was restored and saved. The automation remains inactive. Approvals and Schedule Management views were inspected without making a new approval decision or creating a schedule. A live signing event, customer notification, concurrent deduplication, recurring execution and elapsed two-day escalation are not claimed. See the [local completion report](../learner-review-records/appmint-automate-a-business-handoff-completed-local.md) and [production guide](production/appmint-automate-a-business-handoff.md).
--- # Open a member community and handle its first moderation report > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-build-a-member-community.html # Open a member community and handle its first moderation report ![Zara’s design question, original joinery sketch, comment count and insightful reaction in Studio](assets/appmint-community/20-post-detail.png) *The result: a conversation attached to the right community page, with enough context for the owner to understand and moderate it.* > **Who this is for:** business owners, community managers and developers connecting a member app. **Time:** 35 minutes for Studio setup and moderation; allow another 45 minutes for the developer lab. **Level:** beginner administration, then PRO integration. **Product:** Appmint Studio Manager → Community and AppEngine’s community APIs. **Build checked:** Studio 0.6.2; public workflow rehearsed 21 September 2026; privacy and hashtag acceptance 24 September 2026. **Example business:** Cedar & Form, an interior-design studio. ## What you will have at the end You will create **Tutorial Cedar & Form Studio Circle**, define what belongs there, inspect a member conversation, and handle an unrelated promotional post without losing the useful discussion. You will understand the difference between a community page, a connection, a direct message and a moderation report. The developer lab supplies the actual requests for joining, posting an image, commenting, reacting, connecting, arranging a meeting and reporting a member. Its results appear in the same Studio screens used in the beginner lessons. **Choose your starting point.** If you manage an existing member app, work through Parts 1–3 with your training members. If you are creating the member experience, complete Part 1, then the PRO lab in Part 4, then return to Parts 2–3. Creating a page in Studio creates its community record; it does not automatically add a member-facing website or a navigation link to your site. ## What you need - Your organization and Studio sign-in from [Welcome to Appmint](appmint-welcome.md). - Access to **Community**. For staff access, use [roles and groups](appmint-roles-groups-and-permissions.md). - A training community containing fictional information. The policy limitations beside the setup steps matter before you use real project details. - For member participation: a connected member app, or a developer completing Part 4. Customer accounts and staff accounts have different purposes; clients do not need Studio access to participate. - For the image exercise: [download the joinery sketch](assets/appmint-community/joinery-sketch.png). It is an original illustration for this lesson, not a construction specification. The screenshots use an organization created for these tutorials. Your organization name and record IDs will differ. Keep your own IDs when following the API examples. ## The story Cedar & Form’s clients often ask similar questions about materials and suppliers. Zara wants advice before attending a joinery session. Kofi has a recommendation. Their discussion should be easy for another client to find next week. The studio also needs a calm way to handle irrelevant promotion. Sam is a fictional training member who posts one deliberately unrelated message. We will review that report, remove the message from the member feed, and practice restoring it if the moderator makes a mistake. The useful outcome is a place where a good answer stays available and a poor contribution can be handled deliberately. ## The route ```mermaid flowchart LR P[Create Studio Circle] --> M[Members join] M --> C[Question + sketch + reply] C --> F[Owner inspects Feed] R[Member reports a person] --> Q[Review in Moderation] Q --> A[Change post status in Feed] A --> V[Check the member result] ``` **Page** means a community space here. It is different from a website page built in Build Studio. **Groups** is another separate tab: it manages group chats. Choosing the community page type **Group** does not itself create a group chat. ## Part 1 — Give the community a clear purpose ### 1. Open Community and find the working tabs Choose **Community** in Studio’s sidebar. If its submenu opens, choose **Dashboard**. The top strip contains **Dashboard, Pages, Feed, Stories, Connections, Follows, Messages, Groups, Meetings, Announcements, Badges, Moderation, Hashtags** and **Notifications**. On narrower screens, move along the strip to reach the later tabs. The dashboard summarizes activity. **Pages** manages the spaces; **Feed** manages posts; **Moderation** manages member reports. You will move between these three rather than trying to do everything from the dashboard counters. ![Community dashboard before the first page](assets/appmint-community/01-community-dashboard.png) *1 — Pages starts the setup. 2 — Moderation is the report queue you will use later.* **You should see:** a dashboard appropriate to your organization. An empty training organization shows zero pages and posts. Existing organizations may already have event pages. ### 2. Add the page’s identity Open **Pages → Add Page**. In the **General** section, enter: | Field | Example | Why it matters | | --- | --- | --- | | **Title \*** | `Tutorial Cedar & Form Studio Circle` | The name members and moderators recognize. | | **Slug** | `tutorial-studio-circle` | A short identifier without spaces. It is not, by itself, a finished website URL. | | **Type** | **Group** | Describes a shared interest space. | | **Short Description** | `Share design questions, useful finishes and session recommendations. Keep client addresses and private project files out of posts.` | Gives newcomers a useful boundary in two sentences. | | **Category** | `Interior design` | Describes the subject. | | **Tags** | `design, materials, studio-circle` | Adds useful organizing terms. | | **Status** | **Active** | Keeps this page in the active set. | The longer **Description** is a rich-text area. Use it for community guidance when you need more than the short description: what to ask, how to make a question useful and where private project discussions belong. You can leave it empty for this first exercise. ![Community page identity fields](assets/appmint-community/02-page-identity.png) *1 — A recognizable title. 2 — A separate, URL-friendly slug. The screenshot shows the actual creation drawer.* **You should see:** your values still in the drawer. The page is not saved yet. ### 3. Choose the settings deliberately Collapse **General** and **Media** if necessary to bring **Settings** into view. Choose: | Control | Exercise value | Meaning to communicate to members | | --- | --- | --- | | **Visibility** | **Public** | Use this space for information suitable for public discussion. | | **Join Policy** | **Open** | Members are intended to join without an approval round. | | **Posting Policy** | **Members** | Join before contributing. | | **Moderation Level** | **Flagged Only** | The community’s intended review process focuses on flagged contributions. | Leave **Feed**, **Stories**, **Announcements** and **Media Gallery** checked and **Chat** unchecked for this exercise. These feature settings do not establish that your member app renders every feature; its interface must support them. ![Page settings with public visibility, open joining and flagged-only moderation](assets/appmint-community/03-page-policies.png) *1 — Visibility. 2 — Join policy. 3 — Moderation level.* > **Policies now have verified effects on the member endpoints.** Private pages are hidden from anonymous, outside and pending members. Approval Required saves a pending membership; Admins Only refuses an ordinary member's post; Pre-Approve saves that member's post as pending. The author can see their pending submission, while other member-facing reads do not expose it. Staff moderation is a separate administrative view. These were checked with separate identities after saving the settings in Studio; keep the same boundary tests when connecting your own member app. ![Actual saved private, approval-required and pre-approval settings.](../application-fixes/assets/community-policy-local/03-settings-reopened.png) The tested page-post endpoint does require page membership. That check is useful, but it does not make the other selectors effective access controls. ### 4. Save, reload and read the row back Choose **Create** at the bottom of the drawer. Wait for the page to appear, then reload Studio, open **Community → Pages**, and find its title again. ![Saved community page after reload](assets/appmint-community/04-page-persisted.png) *1 — The persisted page. 2 — Edit opens its settings again.* **You should see:** **group**, **public**, **active**, and initially **0** members. Studio’s page creation did not automatically add the signed-in owner as a community member. Use the pencil **Edit** action to revise the description later. **View JSON** shows the record, including its `sk` ID for a developer. Do not copy the page’s slug into an API field expecting that record ID. **Try it.** Explain the community in one sentence that names both its subject and its audience. “Ask other Cedar & Form clients about materials and design sessions” is more useful than “Welcome to our community.” **Check yourself.** Does seeing the page in Studio mean your website now has a working Join button? No. The member app is a separate part of the setup. Use its verified community screen or Part 4’s integration lab. ## Part 2 — Read a real conversation without losing its context ### 1. Confirm that members actually joined Have your training members join through your connected app, or perform the join requests in Part 4. In Studio, return to **Pages** and choose **Refresh**. ![Three joined members displayed on the community page](assets/appmint-community/05-page-members.png) *The three members in this exercise are Zara, Kofi and Sam. These are customer accounts used only for practice.* **You should see:** the Studio Circle member count has changed to **3**. Creating a customer account alone did not produce that count; each member joined this specific page. > **Joining a second page:** reuse the same customer identity and join each page separately. The repaired local build supports this; creating another customer is unnecessary. If an older installation returns a duplicate membership-name error, ask its administrator to update it before continuing. ### 2. Find the question in Feed Open **Feed**. Use the **All Pages** selector to choose **Tutorial Cedar & Form Studio Circle**. Keep **All Statuses** and **All Types** while learning the screen. The exercise post reads: > Which session on 3D-printed joinery should I not miss? #materials Kofi’s answer is: > The Friday 11:00 one — bring the sketch. ![Zara’s post and engagement counts in the admin Feed](assets/appmint-community/06-first-conversation.png) The row tells you the **Author**, **Content**, **Type**, **Page**, **Media**, **Engagement** and **Status**. Under Engagement, the heart is reactions, the speech bubble is comments and the share icon is shares. Our first conversation has one reaction, one comment and no shares. **You should see:** Zara’s name, the correct community page, and the saved counts. A post made without a page ID is a global post; do not assume every post belongs to the page you happened to be viewing elsewhere. ### 3. Open the post to inspect the details Select the post’s text. The **Post Detail** drawer shows the full question, **Posted in**, the attachment, engagement totals, **Reaction Breakdown**, hashtags and visibility. ![Post Detail after the image has loaded](assets/appmint-community/20-post-detail.png) Our image is a discussion prompt, not decoration. It gives Kofi something specific to answer: ![Original concept sketch of a timber panel and printed connector](assets/appmint-community/joinery-sketch.png) **You should see:** **Media (1)**, **1 Reactions**, **1 Comments**, and **insightful 1**. The current admin drawer shows the comment count; it does not display the full conversation thread. Read the comment through your member app or the comments request in Part 4. > **Image loading:** wait for the thumbnail to load before judging the attachment or capturing a tutorial. The first capture showed an empty media tile while the image was still loading; the later capture contains the actual sketch. ### 4. Use a poll when the answer should be a choice The developer exercise creates a second post: **Tutorial: Which material should we compare next?** Its two options are **Timber** and **Recycled composite**. Open that post in **Feed** to inspect the result. ![Poll showing one vote for Timber](assets/appmint-community/23-poll-result.png) **You should see:** one vote, with Timber at 100%. The poll allows one choice. Kofi’s second vote returned **Already voted** rather than adding another vote. A poll helps choose a subject; a comment explains a reason. Use both when you need a decision and the context behind it. **Try it.** Ask one specific question about the sketch, then compare it with “Any thoughts?” Which would help a new member write a useful reply? **Check yourself.** Does a reaction count of one mean one new reaction for every tap? No. Repeating the same reaction toggles it off; the next tap adds it again. The API exercise verifies that behavior. ## Part 3 — Review the person, then moderate the post ### 1. Open the pending report Sam’s practice post says: > Tutorial moderation sample: unrelated promotion. This is a fictional practice post. Zara reports Sam with reason **spam** and includes the post ID as context. Open **Moderation**. A pending-report notice appears above **Moderation & Reports**. ![Pending report identifying the reporter and reported member](assets/appmint-community/10-pending-report.png) Read the **Reporter**, **Reported**, **Reason**, **Details** and **Status** columns. This report concerns a person. The offending post is separate content that you must identify before acting. Select the row’s eye icon, **View Details**. ![Report details with reason and post context](assets/appmint-community/11-report-details.png) **You should see:** Zara as reporter, Sam as the reported member, the unrelated-promotion explanation and the saved context. The reporter’s display name may be blank while their email is visible; use that email to identify them. ### 2. Understand the review choices before selecting one The drawer offers **Dismiss**, **Record Warning**, **Record Ban Decision** and **Remove Content**. Warning and ban choices record decisions; they do not send a warning or suspend an account. For this exercise, select **Remove Content**, then close the drawer, reload Studio and return to **Moderation**. ![Report review choices](assets/appmint-community/12-review-actions.png) ![Action Taken persisted after reviewing the report](assets/appmint-community/13-report-reviewed.png) **You should see:** **Action Taken**, with `content_removed` beneath it. > **Verify the effect:** the repaired Remove Content action checks the report’s post ID and author, removes that post, then saves the review. Its member link should return **404 — Post not found** and the page feed should exclude it. If the report has no valid post reference, correct the context or locate the post in Feed; the report stays open when removal fails. The screenshot above is historical; the current result is shown below. ![Report review after the verified local repair](../application-fixes/assets/community-report-fixed.png) ### 3. Find the post in Feed and mark it for review Open **Feed**, choose Studio Circle in the page filter and find Sam’s exact practice text. After the repaired report action it should be **removed**. For this fictional rehearsal only, select **Restore** once so you can practice the separate Flag and Remove controls below. The older screenshot below shows the pre-repair behavior. ![Post still active after Remove Content in the report drawer](assets/appmint-community/14-post-still-active.png) *1 — The reported post. 2 — The Feed action that changes the post itself.* Select the row’s flag icon, **Flag**. The status becomes **flagged**. ![Flagged post in Feed](assets/appmint-community/15-post-flagged.png) **You should see:** the flagged status. Members can still retrieve a flagged post. Use Flag to draw moderation attention, not to hide content. ### 4. Remove the post from the member feed On that same row, select **Remove**, the trash-shaped icon. This action changes the post status to **removed**; the row offers **Restore** afterwards. ![Removed post with the Restore action available](assets/appmint-community/16-post-removed.png) *1 — Removed status. 2 — Restore is available if the decision was wrong.* **You should see:** the row remains available to the moderator under **All Statuses**, while the member feed no longer includes it. In the integration test, fetching its direct post endpoint returned **404 — Post not found**. This is the member-visible result that the report’s review status alone did not achieve. ### 5. Practice undoing a mistake Select **Restore** on the practice post. Its status returns to **active**. The member API can retrieve it again. Then select **Remove** once more to leave the exercise in its moderated state. ![Restored practice post](assets/appmint-community/17-post-restored.png) Restoration is useful when a moderator selects the wrong row. It is not a reason to skip reading the author, content and page first. ### 6. Keep reports, blocks and content removal distinct | Action | Result observed in this exercise | | --- | --- | | Zara reports Sam | A pending report is created; Sam can no longer send Zara a direct message. | | Remove Content in the report | The linked post is removed before the successful review is recorded. | | Flag Sam’s post | Post is marked flagged and remains readable. | | Remove Sam’s post in Feed | Member feed excludes it; direct post request returns 404. | | Restore Sam’s post | Member access to the post returns. | A block or report does not hide all of that person’s posts from the feed. Content removal is a separate moderation action. > **Another report:** an unresolved duplicate is rejected as **Report already pending**. After the earlier report is reviewed, a new report can be submitted; this was verified after the local status repair. Do not repeatedly submit an unresolved report. **Try it.** Tell another moderator what you checked before removing the post: the author, page, text, report reason and member-visible result. **Check yourself.** The report says `content_removed`, but the Feed row says **active**. Which one determines whether members can still read the post? The post status. Complete the Feed action and verify the member result. ## Part 4 — PRO: connect the member experience with AppEngine This lab uses real endpoints exercised against the tutorial organization. It is for developers using a private API client. It does not describe buttons that are missing from your website. Keep the API base URL in an environment variable in your API client. Use the AppEngine origin configured for your organization’s deployment. In Studio’s browser developer tools, **Network** shows that origin on its API requests. Do not substitute the Studio website URL for the API URL. ### 1. Prepare identities and authorization For this private training lab, sign in as the organization owner with: ```http POST /profile/user/signin Content-Type: application/json orgid: YOUR_ORGANIZATION_ID ``` ```json {"email":"YOUR_OWNER_EMAIL","password":"YOUR_OWNER_PASSWORD"} ``` Keep the returned `token` in the API client’s private environment. Do not put an owner token in a member website, a mobile app, screenshots or this Markdown file. A deployed client needs the application-authentication setup covered in [the connected-client course](appengine-build-a-connected-web-or-mobile-client.md). Register three fictional customers in the training organization using **POST `/profile/customer/signup`**, the same `orgid` header and JSON bodies in this shape: ```json { "firstName": "Zara", "lastName": "Tutorial", "email": "zara.community@example.invalid", "password": "YOUR_UNIQUE_TRAINING_PASSWORD" } ``` Repeat for Kofi and Sam. Use unique passwords. These `.invalid` addresses are deliberate training identities, not addresses for invitation delivery. Registration can invoke the organization’s welcome-notification setup; use your isolated training organization. For an existing account, use **POST `/profile/customer/signin`** with `email` and `password`. Save each returned `customer.sk` and `token` privately. Do not sign up repeatedly when an account already exists. For the member requests below, the private lab sends: ```http Content-Type: application/json orgid: YOUR_ORGANIZATION_ID Authorization: Bearer YOUR_PRIVATE_LAB_OWNER_TOKEN x-client-authorization: CURRENT_MEMBER_TOKEN ``` The current member changes between Zara, Kofi and Sam. Keeping Zara’s token selected while testing “as Kofi” would attribute the action to the wrong person. In the practiced responses, the post author was Zara and the comment author was Kofi; inspect those identities in your own results too. ### 2. Join the page before posting Obtain the Studio Circle record’s `sk` through **Pages → View JSON**. Call it `PAGE_ID` below. For Zara, send: ```http POST /client/community/pages/PAGE_ID/join ``` Send `{}` as the JSON body. Repeat with Kofi’s and Sam’s member tokens. The response contains the membership’s page, email, role and active status. Read the memberships back with **GET `/client/community/pages/PAGE_ID/members`**. Return to Studio and refresh **Pages**. This is the join operation that produces the count shown in Part 2. Before joining, Zara’s attempted page post returned **403 — You must be a member of this page to post**. If you see it, check both the page ID and the member token before retrying. ### 3. Create the question and upload its sketch As Zara, send **POST `/client/community/posts`**: ```json { "page": "PAGE_ID", "content": "Which session on 3D-printed joinery should I not miss? #materials", "contentType": "text" } ``` Replace `PAGE_ID` with the actual record ID. Save the returned post `sk` as `POST_ID`. Confirm `data.author`, `data.page` and `data.pageName` before moving on. Upload [the PNG sketch](assets/appmint-community/joinery-sketch.png) using **POST `/client/community/media/upload`**. Change this request’s body to **multipart form-data**, add a file field named **`file`**, and select the PNG. Let your client set the multipart boundary rather than retaining the JSON Content-Type header. The successful response supplies `path` and `signedUrl`. Attach those returned values with **PUT `/client/community/posts/POST_ID`**: ```json { "media": [ {"type":"image","path":"RETURNED_PATH","url":"RETURNED_SIGNED_URL"} ] } ``` Use the returned media values in the request, but keep signed URLs out of public logs and video captions. The course screenshot displays the image itself. Refresh Studio’s Feed and reopen the post to check the attachment after it loads. ### 4. Add a reply and a reaction as Kofi Switch `x-client-authorization` to Kofi’s token. Send **POST `/client/community/posts/POST_ID/comments`**: ```json {"content":"The Friday 11:00 one — bring the sketch."} ``` Then send **POST `/client/community/react`**: ```json {"target":"POST_ID","targetType":"post","type":"insightful"} ``` The response is `{"action":"added","type":"insightful"}`. Repeat the same request once and it returns `removed`; repeat again to leave the reaction present. Read **GET `/client/community/posts/POST_ID/comments`**. The stored comment includes Kofi’s author identity and the complete reply. Read **GET `/client/community/feed?page=PAGE_ID`** to confirm the page-scoped post and counts. Studio’s Feed reflects those same saved records after **Refresh**. ### 5. Connect two people before arranging their meeting As Kofi, first attempt **POST `/community/meetings`** with: ```json { "title":"Tutorial Coffee at the atrium", "participantIds":["ZARA_CUSTOMER_ID"], "startTime":"2026-10-02T15:30:00.000Z", "duration":30, "timezone":"America/Chicago", "locationType":"in_person", "location":"Atrium" } ``` Use a suitable future date for your own exercise. The example instant is 10:30 a.m. in Chicago on that date. A timestamp ending in `Z` is UTC; do not type a local time and append `Z` without converting it. Before the connection exists, the response is **403 — You must be connected with all participants**. Create it with **POST `/community/connections/request`** as Kofi: ```json {"targetId":"ZARA_CUSTOMER_ID","message":"Tutorial: let us compare session notes."} ``` Save the connection `sk`. Switch to Zara and send **PUT `/community/connections/CONNECTION_ID/respond`**: ```json {"action":"accept"} ``` Switch back to Kofi and repeat the meeting request. It now succeeds. In Studio, **Connections** shows the accepted relationship; **Meetings** shows the proposed meeting. ![Accepted connection between the two training members](assets/appmint-community/07-accepted-connection.png) ![Meeting created after the connection was accepted](assets/appmint-community/08-meeting-request.png) A created meeting is not the same as every participant having accepted it. Inspect its status and participant responses before treating it as a confirmed appointment. ### 6. Send a direct message and understand its boundary As Zara, send **POST `/community/messages`**: ```json { "recipientId":"KOFI_CUSTOMER_ID", "content":"Tutorial: Shall we compare joinery sketches before the session?" } ``` This message also succeeded before the connection existed in our exercise. Direct messages and meetings have different prerequisites. ![The community message in Studio](assets/appmint-community/09-member-messages.png) Open **Community → Messages** to inspect the resulting conversation. This is separate from **CRM → Chat**, which handles customer-facing support, and from staff collaboration in **Workspace**. ### 7. Create the moderation exercise As Sam, send **POST `/client/community/posts`** with Studio Circle’s page ID and the fictional promotional text from Part 3. Save its post ID. As Zara, send **POST `/community/reports`**: ```json { "reportedId":"SAM_CUSTOMER_ID", "reason":"spam", "details":"Unrelated promotion in the Studio Circle. Tutorial moderation exercise.", "context":{"source":"community","postId":"SAM_POST_ID"} } ``` The report targets Sam’s customer ID; the post ID is supporting context. A repeated report returns **Report already pending**. As Sam, a message to Zara now returns **403 — Cannot send message to this user**. Zara’s feed still includes Sam’s post until the moderator removes it. Complete Part 3 in Studio, then check: | Request | After Flag | After Remove | After Restore | | --- | --- | --- | --- | | GET `/client/community/posts/SAM_POST_ID` | 200; post readable | 404; Post not found | 200; post readable | | GET `/client/community/feed?page=PAGE_ID` | Includes Sam’s post | Excludes Sam’s post | Includes Sam’s post | ### 8. Add the single-choice poll As Zara, send **POST `/client/community/posts`**: ```json { "page":"PAGE_ID", "content":"Tutorial: Which material should we compare next?", "contentType":"poll", "poll":{ "question":"Which material should we compare next?", "allowMultiple":false, "totalVotes":0, "options":[ {"id":"timber","text":"Timber","votes":0,"voters":[]}, {"id":"composite","text":"Recycled composite","votes":0,"voters":[]} ] } } ``` Save the new post’s ID. As Kofi, send **POST `/client/community/posts/POLL_POST_ID/vote`** with `{"optionId":"timber"}`. Repeat with `composite`: the single-choice poll returns **Already voted**. Reopen the poll in Studio to see the saved total. **Try it.** Follow Zara as Kofi with **POST `/client/community/follow`**, body `{"following":"ZARA_EMAIL","followingType":"user"}`. Inspect **Follows** in Studio. A one-way follow does not replace the accepted connection required for meetings. **Check yourself.** Can you explain which token selects the acting member, which ID selects the page and which ID selects the post? Keep these three decisions explicit in your client code. Stop a dependent request if the preceding response failed or returned no ID; omitting a page can accidentally create a global post. ## Part 5 — Grow the community deliberately ### Announcements: prepare a clear instruction Open **Announcements → New Announcement**. The drawer contains **Page**, **Title**, **Content**, **Media**, **Priority**, **Target Audience**, **Target Roles**, **Pinned**, **Expires At** and **Status**. A useful first announcement would be: - **Title:** `Tutorial Bring one material question` - **Content:** `Bring one material question and a sketch. We will compare practical options before the session.` - **Priority:** **Normal** - **Target Audience:** **All** - **Status:** **Draft** while preparing it. ![Announcement draft and the page selector](assets/appmint-community/21-announcement-draft-form.png) Click **Page**, type `Cedar`, and select **Tutorial Cedar & Form Studio Circle** from the results. Confirm the selected title stays in the field; typing a search term alone does not select a page. Enter the title and content above, keep **Normal**, **All** and **Draft**, then choose **Create**. Reopen the saved row with **Edit**. Check the page title, announcement title, content and **Draft** status. The repaired picker and draft readback passed locally. Leave it as a draft in this training exercise; saving is not evidence of member publication or push delivery. ![Saved announcement draft attached to Studio Circle](../application-fixes/assets/community-announcement-saved.png) ### Use the other tabs for a specific purpose | Tab | What to use it for | Current lesson boundary | | --- | --- | --- | | **Stories** | Short-lived member updates. The empty state says **Stories are created by users**. | Source defines 24-hour expiry; creation and expiry were not timed in this walkthrough. | | **Groups** | Group-chat records, with **New Group Chat** and types such as group, page chat and session chat. | This is separate from a page of type Group. A working member chat screen still needs verification. | | **Badges** | Recognition; **New Badge**, categories, rarity and active/retired filters. | Creating a badge definition and awarding it to a member are separate operations. No award was made here. | | **Hashtags** | Inspect tags extracted from posts. | New tags now display their normalized text. The local two-post check displayed `policytrainingfixed` with usage count 2; search the tag text to find it. Older generated-name records may remain from before the repair. | | **Notifications** | Inspect notification records, read/unread counts and push totals. | The test screen showed zero despite the preceding interactions. Saved community actions are not evidence that push alerts were delivered. | ![Stories empty state explains where stories originate](assets/appmint-community/22-stories.png) ![Group Chats is separate from community Pages](assets/appmint-community/22-groups.png) ![Badge management and its categories](assets/appmint-community/22-badges.png) For an event community, continue with [running an event](appmint-run-an-event.md). AppEngine contains hooks for creating event pages and adding ticket holders. Check the resulting page and membership. The earlier event rehearsal hit a duplicate membership-name defect. Explicit membership in a second page now passes after the local repair; automatic ticket-to-membership still needs its event rehearsal. A ticket did not guarantee membership in that second page. For the member phone experience, use [EventOxygen](eventoxygen-attend-and-connect.md). The stock app serves its configured organization; creating a Studio Circle in your own organization does not redirect that stock app to your data. A branded build needs the organization and application configuration described in the event courses. **Try it.** Choose one next feature based on a member need: an announcement for instructions, a poll for a choice or a discussion for explanation. Avoid adding all the surfaces before you can support the first one. **Check yourself.** Does the presence of a management tab mean your phone app has the matching member screen? No. Verify the client screen and its result before advertising it to members. ![The actual repaired hashtag and its two recorded uses.](../application-fixes/assets/community-policy-local/04-hashtag-fixed.png) ## If something goes wrong | Symptom | First check | Next action | | --- | --- | --- | | Post creation says membership is required | Acting member token and page ID | Join that page with that member, then retry once. | | The page exists in Studio but nobody can find a website link | Whether a member-facing app was actually configured | Connect the member surface; a community record alone does not build one. | | Second-page join returns `E11000 ... data.name_1` | Whether the installation includes the membership-name repair | Keep the same customer; ask the administrator to update the older build. | | Page creation reports a membership error | Whether the page itself was already saved | Search Pages before retrying creation. Our API-created policy page remained saved even though owner membership failed. | | A supposedly private page is readable | Acting identity, saved visibility and installed build | Retest signed out and as a nonmember; report any unexpected 200 before adding private material. | | Meeting creation returns a connection error | Connection is accepted, not merely pending or followed | Send and accept the connection, then create the meeting. | | Report says Action Taken but content remains | Post status in Feed | Remove the specific post in Feed and verify the member result. | | Report still says already pending after review | Whether another pending report exists and whether the installation includes the status repair | Resolve the existing pending report; avoid repeated submissions. | | Reporting stops DMs but posts remain | Separate messaging and feed behavior | Moderate the offending content separately. | | Image tile appears blank | Whether the attachment finished loading | Wait, reload the post, then verify its media URL and attachment data privately. | | Announcement Page field has no options | Page exists and is saved | Cancel the unsaved draft and report the picker failure. | | Admin Feed text overlaps adjacent columns | Current table layout | Open Post Detail to read the complete text. | ## What happened behind the scenes
For developers: records, defects and source locations Source paths below are relative to `/Users/imzee/projects` in the reviewed checkout. - `websitemint/packages/ui/src/components/community/pages-list.tsx`: real page form, generic save and member-count display. Page defaults include active status; the member API does not apply the same defaults. - `appengine/src/community/community-page.service.ts`: join requires a page/email membership lookup; creation may save a page before adding its owner. The practiced second membership collided with the collection’s unique `data.name` index. Public page details do not enforce visibility. Page list filters active status and does not use the supplied text query in the checked implementation. - `appengine/src/community/community-post.service.ts`: page-membership check before posting; page/global feed filtering; comments, reaction toggling, polls and soft removal. A missing page produces a public/global post. A removed post returns 404 through the member single-post method. - `websitemint/packages/ui/src/components/community/posts-list.tsx`: the Feed actions write `data.status`, with Restore returning it to active. The detail drawer shows counts and reaction breakdown, not the comment thread. - `appengine/src/community/community-connection.service.ts`, `community-meeting.service.ts`, `community-messaging.service.ts`: connections, meeting prerequisites and messaging block checks. - `appengine/src/community/community-block.service.ts`: reports are stored as block records with flat `reported` and `reportStatus` fields. Messaging checks those block records. The pending-report check still reads the flat status. - `websitemint/packages/ui/src/components/community/block-list.tsx`: review writes nested `data.report.status` and action labels. This explains both the status mismatch and why Remove Content in the report drawer did not remove the post. - `community-client.controller.ts` and `community.controller.ts`: the two endpoint prefixes used in the private lab. Member identity is supplied through `CurrentCustomerOrUser`. - `websitemint/packages/ui/src/components/community/announcements-list.tsx`: the inspected announcement editor and Page DataSelector. `appengine/src/community/community-announcement.service.ts` requires further access-control review before relying on audience labels as restrictions. - `appmint_go/event_app/lib/services/api_service.dart` and the customer screens: member-app integration source. This course does not present its API exercises as native-app footage.
## Where next - [Run an event](appmint-run-an-event.md): connect the community to sessions, tickets and entry operations. - [EventOxygen attendee experience](eventoxygen-attend-and-connect.md): the member-facing phone journey. - [Roles and permissions](appmint-roles-groups-and-permissions.md): staff access to management tools. - [Customer inbox](appmint-run-customer-conversations.md): customer support conversations, separate from member DMs. - [Companion-video production guide](production/appmint-build-a-member-community.md): narration, shots, demonstrations and retest requirements.
Evidence and current coverage Practiced 18 September 2026 on local Studio 0.6.2 with the tutorial organization `learnmu4qn1ha`. Browser interaction created and reloaded Studio Circle, inspected members and posts, reviewed a report, flagged/removed/restored the fictional promotional post, and opened the remaining management screens. Real customer API sessions performed joins, post creation, original-image upload and attachment, commenting, reaction toggling, following, a poll and duplicate-vote rejection, messaging before connection, connection acceptance, meeting prerequisite failure and success, reporting and duplicate-report rejection. No real customers were contacted. Historical pre-fix policy test: a separate fictional private page was readable anonymously; a fresh member joined despite approval-required and posted despite admins-only/pre-approve. An existing member’s second-page join failed with a unique-name error. API page creation saved the page before its owner-membership operation failed. A test script then continued with a missing ID and created one global practice post; that is a harness error, not a successful page-policy test. The corrected test used the persisted page ID and a fresh member. Both traces are retained so the failure is not hidden. Captures and timestamped screen evidence: `assets/appmint-community/evidence.json`. API results: `member-api-evidence.json`, `member-relations-evidence.json`, `moderation-readback-flagged.json`, `moderation-readback-removed.json`, `moderation-readback-restored.json`, `policy-final-evidence.json`, `poll-follow-evidence.json`, and the media result files. Signed media URLs were removed from the public evidence. Record IDs are in `fixture-ids.json`; passwords and tokens are not in these assets. Member actions in this course were exercised through API requests, not a verified member website or native EventOxygen screen. Announcement submission was blocked in the original 18 September run. The 21 September repair and independent rehearsal successfully saved and reopened a draft. Group-chat creation, badge awards, push delivery, story creation/expiry and event auto-joining were not completed here. These limits are reflected in the instructions and the video guide rather than filled with assumed clicks.
### Independent local rehearsal — 21 September 2026 [Current repair report and live evidence](../application-fixes/community-workflow.md) supersede the historical defects where explicitly stated. The current run used a separate isolated organization and three new training identities. Public conversation, the developer lab, moderation and announcement draft readback were exercised. The 24 September continuation verifies privacy, approval and posting enforcement and fixes canonical hashtag storage. [Current policy and hashtag acceptance](../application-fixes/community-policy-local.md). Older images remain labeled as historical, not proof of repaired behavior. --- # Set up a conference and rehearse the door with Appmint Mobile > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-run-an-event.html # Set up a conference and rehearse the door with Appmint Mobile ![The saved event, ticket types and operating areas](../application-fixes/assets/events-local/15-event-overview.png) *One event, two ticket types, a scheduled talk and an admission rehearsal. Movement attempts include refusals and repeated guests; they are not a count of unique people.* > **Who this is for:** event organizers, registration teams and developers building an attendee experience. **Time:** 60–90 minutes, plus the developer setup where indicated. **Level:** beginner operations, then PRO integration. **Products:** Appmint Studio Manager, Appmint Mobile and AppEngine; EventOxygen is the attendee companion. **Build checked:** local Studio, AppEngine and Android source builds, 24 September 2026. Physical camera scanning, iOS and printing remain separate device rehearsals. ## What you will have at the end You will define a two-day conference, distinguish its main hall from its workshop area, create a free admission type and a priced workshop type, schedule a talk, and understand what a speaker record does. You will then use a staff phone to admit a fictional guest, refuse the wrong zone, record an exit, test re-entry and fulfill another guest’s ticket at registration. The result is a rehearsed operating procedure you can adapt to your event company. Appmint and its apps are free. The `$25` in this exercise is the example price charged to an attendee for a workshop, not an Appmint subscription. **Your route:** create and publish the event in Studio, configure the ticket types and programme, then rehearse admission on Appmint Mobile. Part 6 provides the supported API setup for zones, checkpoints and an attributed registration desk. Event creation and publication were repaired and verified locally; the earlier failure screenshots are historical evidence. Do this rehearsal before inviting guests or taking money. ## What you need - Your organization from [Welcome to Appmint](appmint-welcome.md), with access to **Events** in Studio. - [Appmint Mobile installed and signed in](appmint-mobile-welcome-and-daily-work.md) on the staff phone. That lesson includes the iPhone and Android download routes. Appmint Mobile is the staff app; EventOxygen is the attendee app. - A connection that works at the entrance and registration desk. The practiced scanner sends each admission request to the server; it has no offline admission queue. - Fictional attendees for rehearsal. We use Zara Tutorial, `zara.community@example.invalid`, and Kofi Tutorial, `kofi.community@example.invalid`. These deliberately non-deliverable addresses are for training only. Use guests’ actual addresses for a real event. - A developer with authorized server-side API access for Part 6 on the checked build. Never put an owner token into an attendee app. ## The story Cedar & Form is organizing **Tutorial Makers Conference 2026**: talks in a main hall and a smaller practical workshop. Zara has General Admission. She may enter the main hall but has not purchased access to the workshop lab. Kofi has also registered and needs his ticket handed over at the registration desk. Nia is presenting a talk; listing her as a speaker does not give her an admission ticket. We use 14–15 November 2026, **America/Chicago**, at **Tutorial Makers Hall** in Chicago. If those dates have passed when you take the course, choose future dates and change the event, days, session and sale window together. ## The route ```mermaid flowchart LR E[Event: dates + venue + zones] --> T[Ticket types: price + access] T --> B[Booking: transaction] B --> K[Ticket: one holder and admission right] K --> D[Door: in / out / refused] K --> A[Registration: fulfill ticket] E --> S[Programme: session + speaker] ``` Keep these concepts separate: | Item | What it answers | Example | | --- | --- | --- | | Event | What is happening, where and when? | Tutorial Makers Conference 2026 | | Ticket type | What may someone buy or register for? | General Admission, capacity 300 | | Booking | Which purchase or registration produced the tickets? | One free registration with a booking reference | | Ticket | Who holds an individual admission right? | Zara’s confirmed admission ticket | | Zone | Which physical area is this? | `main-hall` or `workshop-lab` | | Scan point | Which configured checkpoint applies additional rules? | `entrance` or `lab-door`; a client must actually send it | | Participant | Who has a programme role? | Nia, speaker | | Session | What happens within the programme? | Joinery talk, 11:00–12:00 | ## Part 1 — Define the event and its operating areas ### 1. Open event management In Studio, choose **Events** in the sidebar. Use the in-page tabs **Dashboard, Events, Sessions, Ticket Types, Tickets, Bookings, Badges, Participants, Check-ins** and **Credentials**. A sidebar child can land on the dashboard; the in-page strip is the reliable way to reach the list you need. Choose **Create Event** on the dashboard. If this opens the Events list, choose **Create Event** there to open the form. ![Events dashboard and creation entry](assets/appmint-events/01-events-dashboard.png) **You should see:** the event form. The dashboard is a summary; the form creates the actual event record. ### 2. Give the event an identity people can recognize In **Event Details**, fill the following fields: | Field | Value | Purpose | | --- | --- | --- | | **Event Name** | `tutorial-makers-2026` | A stable internal name for this exercise. | | **Title** | `Tutorial Makers Conference 2026` | The readable name guests and staff recognize. | | **Slug** | `tutorial-makers-2026` | A URL-friendly identifier for a connected attendee experience. | | **Type** | **Conference** | Describes the event format. | | **Status** | **Draft** | Keeps planning distinguishable from a published event. | | **Category** | `Design and fabrication` | Describes its subject. | A slug does not create a public registration website by itself. Your site or attendee app must render the event and connect its registration flow. ![Event identity in the real creation form](assets/appmint-events/02-event-identity.png) ### 3. Set the dates, timezone and location together Expand **Date & Time**. Set **Start Date** to `2026-11-14`, **End Date** to `2026-11-15`, and **Timezone** to **America/Chicago**. For this conference, opening is 09:00 and closing is 17:00. The form’s **Start Time** and **End Time** controls include a date as well as a time; select the corresponding start and end dates. Under **Venue & Location**, use **Tutorial Makers Hall**, city **Chicago**, region/state **Illinois**, country **United States**. Review generated **Days** separately: each day should have the hours you intend. New days use the configured event hours; existing custom day hours are preserved when dates change. Check every day rather than assuming a two-day event has the same closing schedule on both days. ![Dates and timezone controls](assets/appmint-events/03-dates-timezone.png) **Check the calendar day:** Studio and the phone should both show November 14–15. The corrected date display retains the chosen calendar day, and calendar invitations use America/Chicago when converting the event times. Keep the selected dates and full date/time values; no clock-normalization workaround is required. ### 4. Add the two zones Expand **Zones** and add two entries: | ID | Name | Capacity | | --- | --- | ---: | | `main-hall` | `Main hall` | 300 | | `workshop-lab` | `Workshop lab` | 40 | Use IDs consistently. A ticket type’s zone-access field stores `main-hall`, not the display name `Main hall`. ![Two configured event zones](assets/appmint-events/04-two-zones.png) **You should see:** both zones in the form. Their capacities describe the areas; do not treat the existence of a capacity field as a complete crowd-control procedure. Ticket limits, actual movement and physical venue requirements still need checking. ### 5. Describe the checkpoints and registration desk Under **Scan Points**, add **Entrance**, ID `entrance`, zone `main-hall`, scan type `entrance`, match field `ticket_code`, active. Add **Lab door**, ID `lab-door`, zone `workshop-lab`, scan type `eligibility`, also active. **Accepted Ticket Types** is a comma-separated field for ticket-type record IDs. After creating the types in Part 2, configure Entrance for both types and Lab door for Workshop Pass. It is not a selector where typing the title automatically resolves its ID. ![Scan point settings](assets/appmint-events/05-scan-point.png) > **Phone behavior matters:** the practiced Appmint Mobile scanner offered zone chips, not these scan-point names. Its request included the zone but omitted the checkpoint. Consequently our door rehearsal verifies zone access, not scan-point filtering. A custom scanner must send the checkpoint if your operating procedure depends on its accepted types or automatic perk claims. Under **Accreditation Points**, add ID `desk-1`, name **Tutorial Registration Desk**, zone `main-hall`, active, with online pickup allowed. This is the desk that hands over an existing registration. Walk-in sales are a separate branch; leave them out of the first rehearsal. ![Registration desk configuration](assets/appmint-events/06-accreditation-point.png) ### 6. Save and check the event record Choose **Save**. The corrected form sends the event through the dedicated event service and closes after a successful save. If a request fails, inspect the event list for your exact title/slug before retrying; do not create duplicates to recover from a display problem. After creation, return to **Events** and open the event by title. You should see its details, zones and status. Reload and find the same event again before adding dependent records. ![Event visible after saving](assets/appmint-events/08-event-persisted.png) **Try it:** explain to a colleague why `main-hall` appears in an access rule while **Main hall** appears on the phone. **Check yourself:** does an event slug create a registration page? No. It identifies the event for a site or app that supplies that experience. ## Part 2 — Create tickets with meaningful limits ### 1. Create General Admission Open **Ticket Types → Add Ticket Type**. In the event selector, search `Makers`, then select the actual **Tutorial Makers Conference 2026** result. Typing a search term without selecting the result does not attach the record. Set **Title** to `Tutorial General Admission`, **Slug** to `tutorial-general-admission`, and leave **Active** checked. Set **Price** to `0`, **Currency** to `USD`, **Capacity** to `300`, and **Max Per Customer** to `1`. ![Free ticket identity and pricing](assets/appmint-events/09-free-ticket-type.png) Expand **Rules**. Check **Allow re-entry** and set **Zone Access** to `main-hall`. Leave the re-entry limit empty for this rehearsal. Leave transfers off; the transfer branch needs a separate test before use. ![General Admission access and re-entry rules](assets/appmint-events/10-free-ticket-rules.png) Choose **Create** and find the new type in the list. Reload and confirm its title, free price, active status and capacity. ![Saved General Admission type](assets/appmint-events/11-ticket-type-saved.png) > **Set the customer limit deliberately.** Free ticket types default to one per customer in the purchase service. An empty limit is not a reliable way to promise unlimited free tickets. Our exercise stores `1` explicitly so the rule is clear. ### 2. Create Workshop Pass Choose **Add Ticket Type** again and select the same event. Enter: | Field | Value | | --- | --- | | **Title** / **Slug** | `Tutorial Workshop Pass` / `tutorial-workshop-pass` | | **Active** | Checked | | **Price** / **Currency** | `25` / `USD` | | **Capacity** | `40` | | **Max Per Order** | `2` | | **Sale Closes** | `2026-11-13 23:59` | | **Allow re-entry** | Checked | | **Zone Access** | `main-hall,workshop-lab` | ![Workshop pricing and capacity](assets/appmint-events/12-workshop-price-capacity.png) The workshop pass includes access to the main hall as well as the workshop. This is an access decision, not just a higher price. ![Workshop access to both zones](assets/appmint-events/13-workshop-access.png) Choose **Create**, reload and compare the two rows. ![Both saved ticket types](assets/appmint-events/14-two-ticket-types.png) **You should see:** one free type and one `$25` type, with capacities 300 and 40. This course creates the priced type but does not take a paid transaction. ### 3. Move from planning to publication Open the event and choose **Publish**. Studio confirms **Event published**. Reopen the event and confirm **Published** before sharing registration. The local repair was verified on 24 September: publication persisted successfully. Older screenshots below record the original failure, not an instruction to bypass publication. ![Publish action error](assets/appmint-events/16-publish-result.png) > **Publication is not a sales lock.** The current purchase service does not use event publication as its admission to checkout. Likewise, do not assume that a visible inactive label is an effective stop-sale control without testing the purchase endpoint in your build. Keep a rehearsal’s registration entry private and test launch and stop-sale behavior before distributing it. ### 4. Register both rehearsal guests and inspect their records Complete the event and checkpoint setup in Part 6.1–2 before registration, including ticket-type ID assignment. Studio’s full date/time fields are accepted by the corrected calendar generator; clock normalization is no longer required. Then run Part 6.3 once for **Zara** and once for **Kofi**, keeping each returned booking ID and ticket ID. Neither guest needs an existing customer account for this public registration route. An attendee app supplies the interface for this transaction; the [EventOxygen course](eventoxygen-attend-and-connect.md) covers that interface. In Studio, open **Bookings** and **Tickets** and search each holder email. Each free registration should have a **paid** booking with total `0` and one **confirmed** ticket for this event and General Admission. Before Part 4, Zara must have no admission history; before Part 5, Kofi must have no fulfillment timestamp. Inspect the saved records, not just the dashboard counters. ![Historical Zara paid-zero booking, not Kofi’s booking](assets/appmint-events/17-free-purchase-booking.png) ![Historical single Zara confirmed ticket; Kofi had not yet been registered in this frame](assets/appmint-events/18-free-purchase-tickets.png) **Read both records:** the corrected booking list shows the event, one General Admission ticket, Paid and USD0.00 for each guest. **Tickets** shows the separate admission state. Do not register again merely to change a summary counter. ![Both paid-zero bookings with their event and ticket counts](../application-fixes/assets/events-local/10-bookings-fixed.png) ![Later historical two-ticket readback: Zara already checked in and Kofi still confirmed](assets/appmint-events/19-native-checkin-persisted.png) *This later frame establishes that both tickets existed in the author’s run. It is not the fresh baseline required above: Zara had already entered.* The original rehearsal exposed a calendar error after a booking and ticket had already been saved. That application defect is now fixed locally: fresh Zara and Kofi registrations both returned success with paid-zero bookings, confirmed tickets and empty admission histories while retaining Studio’s full date/time fields. After any unexpected request error, inspect the saved records before retrying; an HTTP error does not guarantee that nothing was saved. **Repeating the lab:** inspect the existing tickets, check-in log and fulfillment fields first. Resume from the actual state, or create a new training event with a unique name/slug and new dependent types/tickets for another first-admission rehearsal. Do not delete movement history, bypass the one-ticket limit or register a guest again to reset the exercise. **Try it:** point to the Workshop Pass price, its capacity and its zone rule. Which one decides whether General Admission may enter the lab? The zone-access rule. **Check yourself:** is a paid-zero booking the same thing as a checked-in guest? No. Registration creates the ticket; the entrance records admission later. ## Part 3 — Put the programme and speaker in place ### 1. Add the speaker’s role Open **Participants → Add Participant**. Select the event, then the existing customer who will speak. Choose **Type: Speaker**, role `Tutorial joinery presenter`, and **Status: Confirmed** once you have their agreement. A useful short bio describes what attendees will learn from this person. Our example uses a separate fictional customer, **Nia Tutorial**, `nia.community@example.invalid`. Create or locate her with Part 6.4 first; the community course does not create Nia. In **Customer**, enter her exact email and choose the matching result. The repaired selector displays her email even when the customer has no combined name field. Fill the role and status, expand **Bio & Profile**, add the short bio and choose **Save**. Reopen **Participants**: Nia Tutorial, her email, Speaker and Confirmed should appear together. ![Saved speaker identity and role](../application-fixes/assets/events-local/11-speaker-fixed.png) ![Customer selector without the existing speaker result](assets/appmint-events/21-speaker-customer-picker.png) ![Speaker record persisted in Participants](assets/appmint-events/31-speaker-persisted.png) **You should see:** one speaker participant attached to the event. This request does not itself send an invitation or issue a ticket. Arrange those separately. ### 2. Create a session people can plan around Open **Sessions → Add Session**. Select the event and enter: | Field | Value | | --- | --- | | **Title** | `Tutorial 3D-printed joinery: test before you build` | | **Type** / **Status** | **Talk** / **Scheduled** | | **Short Description** | `Compare a timber joint and a printed connector, then discuss the tests each needs.` | | **Day** | `2026-11-14` | | **Start Time** / **End Time** | `2026-11-14 11:00` / `2026-11-14 12:00` | | **Duration** | `60` minutes | | **Zone** / **Room** | `main-hall` / `Main hall stage` | ![Session identity and description](assets/appmint-events/23-session-details.png) ![Session date, time and location](assets/appmint-events/24-session-schedule.png) Keep **Visible to attendees** enabled for this public talk. Under **Participants → Add Participants**, search `joinery` and select **speaker Tutorial joinery presenter**, the role you assigned Nia. The selected participant appears below the search field. The current model stores this link in `participants`; a separate historical `speakers` field is not the current UI contract. Save and reopen the session to confirm that public visibility and the participant link remain. The local save repair persists the enabled Public control even if you did not toggle it first. Choose **Save**. Reload **Sessions** and locate the talk by title. Confirm 11:00–12:00, the room and **Scheduled**. Reopen the session to confirm its participant and Public setting. ![Saved scheduled session](assets/appmint-events/32-session-persisted.png) **Try it:** write a session description that helps someone choose between two talks. State the subject and practical outcome instead of repeating the title. **Check yourself:** Nia appears in Participants. Can she enter with that record alone? No. Check her admission ticket separately. ## Part 4 — Rehearse the entrance on Appmint Mobile ### 1. Open the same event on the staff phone From Home, open **Events** or its **View All** entry, then select **Tutorial Makers Conference 2026**. The event shell has **Overview, Scanner, Tickets, Schedule, People** and **More** tabs; move along the strip to reach later tabs. ![Earlier native event list showing Draft and zero tickets](assets/appmint-events/m01-native-event-list.png) ![Earlier native overview with zero counts and the original full date-time fields](assets/appmint-events/m02-native-event-overview.png) *These two images predate publication and registrations. Reopen your event and verify its current saved state; do not use these earlier counts as the Part 4 baseline.* The corrected native overview displays **Free · 298 available** for General Admission after the two registrations, and **USD 25.00 · 40 available** for Workshop Pass. These are separate inventory pools; a guest with General Admission does not acquire workshop access. ![Corrected mobile ticket prices and remaining places](../application-fixes/assets/mobile-local/event-price-fixed.png) ### 2. Set the mode and zone before admitting anyone Open **Scanner**. Allow camera access when the operating system requests it. Choose **Check In**, then the **Main hall** zone chip. **No zone** sends no zone restriction; it is unsuitable for testing the workshop boundary. ![Historical scanner before zone selection, showing No zone and Manual Lookup](assets/appmint-events/m03-native-scanner.png) The camera is for a guest’s ticket QR. **Manual Lookup** is the fallback when a screen is damaged, the camera cannot focus or you need to find a ticket by email. Both paths still need a server response. ### 3. Admit Zara through Manual Lookup Choose **Manual Lookup**. In **Ticket code or holder email**, enter `zara.community@example.invalid`. Choose **Check in**. If the address has several tickets, select the intended ticket rather than assuming the first result is correct. ![Historical manual check-in using Zara’s email with No zone selected](assets/appmint-events/m04-manual-checkin-email.png) ![Historical successful first admission with No zone selected](assets/appmint-events/m05-first-checkin-result.png) The older frames above show an unzoned rehearsal. The fresh local walkthrough selected **Main hall** before Zara’s first admission: ![First admission with Main hall selected](../application-fixes/assets/mobile-local/event-first-main-hall.png) **You should see:** **CHECKED IN**. In this build the success card is blue. A decoded QR or found email is only a lookup; the admission decision is the result card. In Studio, open **Tickets** and find Zara’s **checked_in** status. Open **Check-ins**, or the event’s **Check-in Log**, and inspect the movement. Reload to confirm it was saved. ![Staff admission persisted in Studio](assets/appmint-events/19-native-checkin-persisted.png) ### 4. Learn what a repeated attempt actually does With **Allow re-entry** enabled, repeat the lookup at Main hall. In our rehearsal the server returned **CHECKED IN** again even without an intervening check-out. A successful scan count can therefore include the same person more than once. ![Repeat admission after re-entry is restored](../application-fixes/assets/mobile-local/event-repeat-enabled.png) Do not train door staff to promise that every repeat will be refused. The ticket type’s re-entry configuration changes that outcome. ### 5. Test the workshop boundary Select **Workshop lab** and look up Zara again in **Check In** mode. Her General Admission type allows only `main-hall`. ![General Admission refused at Workshop lab](../application-fixes/assets/mobile-local/event-workshop-denied.png) **You should see:** **DENIED — Access denied to this zone**. Keep the guest outside the restricted area while the desk checks their entitlement. Repeatedly scanning the same ticket will not add workshop access. ### 6. Record an exit and return Select **Main hall**, change mode to **Check Out**, and use Manual Lookup for Zara. Expect **CHECKED OUT**. Then change back to **Check In** and look her up again. With re-entry enabled, expect **CHECKED IN**. ![Successful check-out](../application-fixes/assets/mobile-local/event-main-hall-checkout.png) ![Successful re-entry](../application-fixes/assets/mobile-local/event-reentry-fixed.png) The ticket’s lifecycle status can remain `checked_in`; the movement log distinguishes the last entry from the exit. Use the log when answering “did this person leave?” ### 7. Test the refusal rule, then restore the exercise setting In Studio, open General Admission’s edit form, expand **Rules**, clear **Allow re-entry**, and choose **Save Changes**. On the phone, attempt another check-in for Zara. ![Re-entry turned off for the refusal test](assets/appmint-events/22-disable-reentry.png) ![The actual already-checked-in refusal](../application-fixes/assets/mobile-local/event-reentry-refused.png) **You should see:** **ALREADY CHECKED IN — This ticket was already scanned**. Return to the ticket type, check **Allow re-entry**, save, and reopen it to confirm the exercise setting is restored. ![Saved movement and refusal history](assets/appmint-events/29-door-rehearsal-log.png) Failed attempts can also be logged. Count successful admissions separately from denied attempts and from unique people. **Try it:** have a colleague name the mode, zone and expected result before you press the action. This catches an entrance phone left in Check Out mode. **Check yourself:** why select the correct event as well as the zone? The corrected staff scan request includes the active event ID. The API refuses a ticket from another event before changing its admission state. This mismatch was verified for both entry and exit; a matching zone name alone does not authorize admission. ## Part 5 — Handle pickup at the registration desk ### 1. Find Kofi’s existing ticket on the phone From the event **Overview**, choose **Accredit**, or use **More → Accreditation Desk**. In **Desk / Point ID**, enter `desk-1`, the configured registration desk. In **Email, booking ID, or code**, enter `kofi.community@example.invalid`, then use the search button. Confirm the holder name and ticket type before handing anything over. ![Kofi found with desk-1 selected](../application-fixes/assets/mobile-local/event-desk-kofi-found.png) **You should see:** **FOUND TICKETS**, **Kofi Tutorial**, **Tutorial General Admission · confirmed**, and the **Fulfill** and **Badge** actions. ### 2. Fulfill the ticket Choose **Fulfill** once. The app displays **Ticket fulfilled** and refreshes the result. ![Native fulfillment at the configured registration desk](../application-fixes/assets/mobile-local/event-desk-kofi-fulfilled.png) The saved ticket now has a fulfillment timestamp and the staff identity. Because Kofi’s free registration already issued a confirmed ticket, its status stays **confirmed**. Fulfillment is the pickup operation; it is not an entrance check-in. **Desk attribution:** the current phone form sends **Desk / Point ID** with fulfillment. The local readback confirmed `desk-1`, the signed-in staff identity and a fulfillment timestamp. Leave the field empty only when you deliberately do not need desk attribution. A made-up or inactive desk ID is not a substitute for configuring the point in the event. ### 3. Find the same registration in Studio Open the event → **Accreditation**. Set **Desk / Point ID** to `desk-1`, keep **Search By: Email**, enter Kofi’s email and choose **Search**. Confirm his name and ticket type. The repaired web desk reads the returned ticket list and finds the same record as the phone. Do not choose Fulfill again after pickup merely to repeat the demonstration; inspect the saved fulfillment fields instead. ![The working Studio ticket lookup, with ticket code concealed](../application-fixes/assets/events-local/12-desk-lookup-fixed.png) **Venue Tickets → Generate Tickets** is for preparing ticket stock. Its ticket-type picker returned no results in this build, so no batch was generated. Leave that branch out of the operating procedure until a one-ticket rehearsal succeeds. A default quantity of 100 is not a sensible first test. ![Venue-ticket selector without a matching type](assets/appmint-events/26-venue-ticket-picker.png) The **Badge** action is separate again. The native implementation displays badge data and says phone printing is unavailable. A badge template, a ticket, a fulfilled pickup and a physical printout are four different things; complete a real printer test before promising onsite badge production. **Try it:** explain the difference between **confirmed**, **fulfilled** and **checked_in** using Kofi and Zara’s records. **Check yourself:** should Kofi register again because the Studio desk shows no result? No. Check the native lookup and saved ticket first. ## Part 6 — PRO: the supported API path used in this rehearsal This lab is for an authorized developer working server-side. `API_BASE` below means your configured AppEngine API origin, without a trailing slash; `ORG_ID` is your organization ID. Obtain `STAFF_TOKEN` by the normal authorized sign-in flow. Keep it in memory or your secure development environment. These snippets contain no working credentials and must not be copied into a public page or mobile bundle. Use an isolated training organization with notification delivery routed to a test sink. Signup and ticket purchase can enqueue messages, including a copy to the organization owner; non-deliverable attendee addresses alone do not prevent that copy. Do not run this fixture against an existing customer organization. Run these steps in order: create the draft event; create both ticket types in Part 2; resolve their IDs and finish the two-stage event update; register both guests; prepare Nia and the session. Use one private JavaScript session for the snippets so the variables remain available. | Variable | Source of the value | | --- | --- | | `eventId` | `created.sk` from event creation below | | `generalAdmissionId`, `workshopPassId` | `sk` of the exact matching rows from the event’s ticket-type list | | `zaraBookingId`, `kofiBookingId` | Each successful purchase response’s `booking.sk` | | `zaraTicketId`, `kofiTicketId` | Each successful purchase response’s sole `tickets[0].sk` | | `niaCustomerId` | Exact-email customer lookup row’s `sk`, or signup response’s `customer.sk` | | `participantId` | Created or previously matched speaker participant’s `sk` | | `sessionId` | Exact-title session lookup row’s `sk`, or session creation response’s `sk` | ### 1. Create the event through the event service Use `POST /crm/events/create` with staff authorization and the BaseModel envelope. The required `isNew: true` matters; omitting it returned **Not a new metrics...** in the rehearsal. Assign this JSON to `eventRequest`. It provides an API alternative to the Studio form with the same location, zones, checkpoints and desk. Skip creation if you already saved the event in Part 1; use the exact lookup below to recover its ID. Change all dates together if necessary. ```json { "isNew": true, "datatype": "event", "data": { "name": "tutorial-makers-2026", "title": "Tutorial Makers Conference 2026", "slug": "tutorial-makers-2026", "type": "conference", "status": "draft", "category": "Design and fabrication", "startDate": "2026-11-14", "endDate": "2026-11-15", "startTime": "2026-11-14T09:00:00", "endTime": "2026-11-15T17:00:00", "timezone": "America/Chicago", "venue": "Tutorial Makers Hall", "address": {"city": "Chicago", "region": "Illinois", "country": "United States"}, "days": [ {"date": "2026-11-14", "label": "Day 1", "startTime": "09:00", "endTime": "17:00"}, {"date": "2026-11-15", "label": "Day 2", "startTime": "09:00", "endTime": "17:00"} ], "zones": [ {"id": "main-hall", "name": "Main hall", "capacity": 300}, {"id": "workshop-lab", "name": "Workshop lab", "capacity": 40} ], "scanPoints": [ {"id": "entrance", "name": "Entrance", "zone": "main-hall", "scanType": "entrance", "matchField": "ticket_code", "acceptedTicketTypes": [], "isActive": true}, {"id": "lab-door", "name": "Lab door", "zone": "workshop-lab", "scanType": "eligibility", "matchField": "ticket_code", "acceptedTicketTypes": [], "isActive": true} ], "accreditationPoints": [ {"id": "desk-1", "name": "Tutorial Registration Desk", "zone": "main-hall", "isActive": true, "allowWalkIn": false, "allowOnlinePickup": true} ] } } ``` ```js const headers = { 'content-type': 'application/json', orgid: ORG_ID, authorization: `Bearer ${STAFF_TOKEN}` }; async function request(path, options = {}) { const response = await fetch(`${API_BASE}${path}`, { ...options, headers }); const result = await response.json(); if (!response.ok) throw new Error(`Request failed (${response.status}); inspect privately`); return result; } function exactlyOne(rows, predicate, label) { const matches = rows.filter(predicate); if (matches.length !== 1) throw new Error(`Expected exactly one ${label}; inspect before continuing`); return matches[0]; } async function findRows(datatype, query) { const result = await request(`/repository/find/${datatype}`, { method: 'POST', body: JSON.stringify({ query, options: { pageSize: 100 } }) }); return result.data; } const existingEvents = await findRows('event', { 'data.slug': eventRequest.data.slug }); if (existingEvents.length) throw new Error('This event already exists; inspect it or choose a new rehearsal slug'); const created = await request('/crm/events/create', { method: 'POST', body: JSON.stringify(eventRequest) }); const eventId = created.sk; if (!eventId) throw new Error('Missing created event ID'); ``` If creation errors, perform the same slug lookup before retrying: a response failure may follow persistence. For an intentional continuation, verify that the single matching record is your training event and assign its `sk` to `eventId` in a new private session; skip creation. Never substitute a historical evidence ID. The [captured creation request](assets/appmint-events/conference-request.json) preserves the original payload for diagnosis. It is historical evidence, not the recommended fixture. Do not add its `attendees: []`: the practiced native model expected a number or null and failed to parse that array. The new fixture also explicitly disables walk-in sales. Empty accepted-type arrays are temporary; finish the next step before opening a checkpoint. ### 2. Resolve ticket types, configure checkpoints and verify publication Create the two types in Part 2 first. Resolve their record IDs from this event’s list; titles/slugs themselves are not ticket IDs: ```js const types = (await request(`/events/${eventId}/ticket-types`)).data; const generalAdmission = exactlyOne(types, t => t.data.event === eventId && t.data.slug === 'tutorial-general-admission', 'General Admission type'); const workshopPass = exactlyOne(types, t => t.data.event === eventId && t.data.slug === 'tutorial-workshop-pass', 'Workshop Pass type'); const generalAdmissionId = generalAdmission.sk; const workshopPassId = workshopPass.sk; ``` The dedicated update accepts a full current **raw** record. Fetch it immediately before each update; an enriched display record or stale version is unsuitable. Keep the start/end times entered in Studio. Configure the days, zones and desk together before registration, because later edits to a published event may notify ticket holders. ```js const configured = await request(`/repository/get/event/${eventId}`); configured.data.days = structuredClone(eventRequest.data.days); configured.data.address = structuredClone(eventRequest.data.address); configured.data.zones = structuredClone(eventRequest.data.zones); configured.data.scanPoints = structuredClone(eventRequest.data.scanPoints); configured.data.scanPoints[0].acceptedTicketTypes = [generalAdmissionId, workshopPassId]; configured.data.scanPoints[1].acceptedTicketTypes = [workshopPassId]; configured.data.accreditationPoints = structuredClone(eventRequest.data.accreditationPoints); configured.data.status = 'published'; await request('/crm/events/update', { method: 'POST', body: JSON.stringify(configured) }); const readyEvent = await request(`/repository/get/event/${eventId}`); for (const key of ['days', 'address', 'zones', 'scanPoints', 'accreditationPoints']) { if (JSON.stringify(readyEvent.data[key]) !== JSON.stringify(configured.data[key])) { throw new Error(`Event ${key} did not persist; stop before registration`); } } if (readyEvent.data.status !== 'published') { throw new Error('Event is not ready'); } ``` Reload Studio and check both days end at 17:00, both checkpoint lists contain the intended record IDs, and `desk-1` is active with online pickup on and walk-in off. On **Inconsistent state mutation**, re-read the current raw record and reapply only your intended changes. Do not reuse a stale object. The corrected lifecycle handler preserves existing day hours; a second clock-normalization update is unnecessary. ### 3. Register Zara and Kofi separately Use the actual General Admission record ID. The public purchase endpoint needs the organization header, not the staff bearer token. Preflight both holder emails against this event before creating anything: ```js async function registerGuest(email, name) { const existing = await findRows('event_ticket', { 'data.event': eventId, 'data.holderEmail': email }); if (existing.length) throw new Error('Guest already has a ticket; inspect history before any retry'); const response = await fetch(`${API_BASE}/client/events/tickets/purchase`, { method: 'POST', headers: { 'content-type': 'application/json', orgid: ORG_ID }, body: JSON.stringify({ eventId, email, name, items: [{ ticketTypeId: generalAdmissionId, quantity: 1 }] }) }); const result = await response.json(); if (!response.ok) throw new Error(`Registration failed (${response.status}); inspect saved booking/tickets before retrying`); if (result.booking?.data.status !== 'paid' || result.booking.data.total !== 0 || result.tickets?.length !== 1) { throw new Error('Unexpected booking/ticket result; inspect privately'); } const ticket = result.tickets[0]; if (ticket.data.event !== eventId || ticket.data.ticketType !== generalAdmissionId || ticket.data.holderEmail !== email || ticket.data.status !== 'confirmed') { throw new Error('Unexpected ticket identity or status'); } return { bookingId: result.booking.sk, ticketId: ticket.sk }; } const { bookingId: zaraBookingId, ticketId: zaraTicketId } = await registerGuest('zara.community@example.invalid', 'Zara Tutorial'); const { bookingId: kofiBookingId, ticketId: kofiTicketId } = await registerGuest('kofi.community@example.invalid', 'Kofi Tutorial'); ``` Keep these four IDs privately. Read `GET /repository/get/event_ticket/:ticketId` for each returned ticket and verify the Part 2.4 baseline before using the phone: correct event/type/email, confirmed, empty `data.checkIns`, and no `data.fulfilledAt`. Do not log QR secrets or the full ticket response. After any failure, inspect **both** Bookings and Tickets before a retry. To resume an already completed registration, recover its ticket’s `sk` and `data.purchase.bookingId` from the scoped lookup instead of calling purchase again. A successful request alone is not an attendee registration website. ### 4. Prepare Nia, add her speaker role and connect the session Nia is an additional fixture, not a customer created by the community course. First look up her exact training email with the staff helper: ```js const niaEmail = 'nia.community@example.invalid'; const niaRows = await findRows('customer', { 'data.email': niaEmail }); if (niaRows.length > 1) throw new Error('Ambiguous Nia customer; inspect before continuing'); let niaCustomerId = niaRows[0]?.sk; ``` Only if no record exists, use the community course’s customer-signup route in this isolated organization. Set `NIA_TRAINING_PASSWORD` to a fresh private password in your development environment; never publish it or the returned tokens. This step may enqueue a welcome message: ```js if (!niaCustomerId) { const response = await fetch(`${API_BASE}/profile/customer/signup`, { method: 'POST', headers: { 'content-type': 'application/json', orgid: ORG_ID }, body: JSON.stringify({ firstName: 'Nia', lastName: 'Tutorial', email: niaEmail, password: NIA_TRAINING_PASSWORD }) }); const signup = await response.json(); if (!response.ok || !signup.customer?.sk) throw new Error('Signup incomplete; look up Nia before retrying'); niaCustomerId = signup.customer.sk; } const verifiedNia = exactlyOne(await findRows('customer', { 'data.email': niaEmail }), c => c.sk === niaCustomerId && c.data.email === niaEmail, 'Nia customer'); ``` Resolve or create her speaker participant without duplicating a prior run: ```js const speakers = (await request(`/events/${eventId}/participants`)).data .filter(p => p.data.event === eventId && p.data.customer === niaCustomerId && p.data.type === 'speaker'); if (speakers.length > 1) throw new Error('Duplicate speaker records; inspect before continuing'); const participant = speakers[0] || await request(`/events/${eventId}/participants`, { method: 'POST', body: JSON.stringify({ customer: niaCustomerId, type: 'speaker', status: 'confirmed', role: 'Tutorial joinery presenter', shortBio: 'Design researcher comparing timber joints and printed connectors.' }) }); const participantId = participant.sk; if (participant.data.status !== 'confirmed') throw new Error('Confirm the speaker agreement before continuing'); ``` If you already saved the talk in Part 3, use its exact-title match. Otherwise create it with the same fields. Do not take the first unrelated session in a list: ```js const sessionTitle = 'Tutorial 3D-printed joinery: test before you build'; const matches = (await request(`/events/${eventId}/sessions`)).data .filter(s => s.data.event === eventId && s.data.title === sessionTitle); if (matches.length > 1) throw new Error('Duplicate sessions; inspect before continuing'); const session = matches[0] || await request(`/events/${eventId}/sessions`, { method: 'POST', body: JSON.stringify({ title: sessionTitle, type: 'talk', status: 'scheduled', shortDescription: 'Compare a timber joint and a printed connector, then discuss the tests each needs.', day: '2026-11-14', startTime: '2026-11-14T11:00:00', endTime: '2026-11-14T12:00:00', duration: 60, zone: 'main-hall', room: 'Main hall stage', isPublic: true }) }); const sessionId = session.sk; await request(`/events/sessions/${sessionId}`, { method: 'PUT', body: JSON.stringify({ isPublic: true, participants: [participantId] }) }); const linked = exactlyOne((await request(`/events/${eventId}/sessions`)).data, s => s.sk === sessionId, 'saved session'); if (linked.data.isPublic !== true || !linked.data.participants?.includes(participantId)) { throw new Error('Speaker/public state did not persist'); } ``` Read the participants list again and verify the participant still references `niaCustomerId` and this event. Neither participant creation nor this session update sends an invitation. Signup is separate and can send a welcome message. This fixture gives Nia a programme role, not an admission ticket. ### 5. Supply a desk ID when fulfillment needs attribution The supported request shape is: ```json { "eventId": "YOUR_EVENT_ID", "ticketId": "YOUR_TICKET_RECORD_ID", "accreditationPointId": "desk-1" } ``` Send it to `POST /events/tickets/fulfill` with staff authorization. Use an active point that permits online pickup. The phone now supplies this point from its Desk / Point ID field; the live pickup persisted `desk-1`. Fulfillment rotates ticket code material, so refresh a displayed ticket after pickup rather than relying on an old image. **Try it:** read the session and ticket records after your operations colleague uses the UI. Can you identify the scheduled session, the fulfilled ticket and the admitted guest without exposing a ticket secret? **Check yourself:** should a mobile developer embed `STAFF_TOKEN` to make the attendee endpoints work? No. Use the attendee/customer authentication flow taught in the connected-client course. ## Part 7 — Extend the business without confusing promises with completed workflows ### Paid tickets and refunds A priced ticket type is ready for a payment integration rehearsal, not automatically for taking real money. The current confirmation service trusts a client payment reference; establish server-side payment verification before launch. Test interruption, duplicate confirmation and reconciliation with the payment provider. Ticket **Refund** is bookkeeping in the checked implementation; it does not call a payment provider to move money. A real refund needs the provider refund and the corresponding Appmint record to agree. **Cancel booking** deletes its tickets in the checked service. Keep an audit trail and do not use cancellation as a substitute for refunding money. ### Perks, transfers and credentials Perks can represent a toolkit or other entitlement. Configuration includes quantities and automatic claims at a scan point, but the practiced mobile scanner omitted that point. Do not train staff to promise a kit was claimed simply because zone entry succeeded. Rehearse claim, fulfillment and duplicate refusal separately. Transfers change the holder and code material. The older admin form and service use mismatched fields; use a verified attendee transfer path and test the old and new holder views before offering transfers. A badge or wristband credential also needs assignment, lookup and physical-use tests of its own. ### Your attendee app and event community [EventOxygen](eventoxygen-attend-and-connect.md) covers the guest’s side: registration, tickets, the programme and community. The stock production app is bound to the `eventos` organization. It will not start showing your organization merely because you created an Appmint account. A build configured for your organization is required for your own event business. The source is present in `appmint_go/event_app`; organization and API configuration live in its environment settings. A public source-distribution URL and license were not established in this rehearsal, so use the source provided by the project owner and confirm the applicable license before redistributing a customized build. Branding, authentication configuration, signing and store distribution are part of that project. Do not copy a committed application credential into a new organization's app. Creating the event also created a community page. In this rehearsal, automatically adding existing community members as ticket holders failed with a duplicate membership-name error. Verify actual page membership before promising ticket buyers automatic community access. The [community course](appmint-build-a-member-community.md) explains joining, posting and moderation. **Try it:** pick one extension—paid tickets, perks, transfers, credentials or a branded attendee app—and write its full rehearsal: setup, successful action, failure case, saved result and recovery. **Check yourself:** is opening a template or seeing an action button enough to advertise the service? Complete its end-to-end rehearsal first. ## If something goes wrong | Symptom | First check | Next action | | --- | --- | --- | | Event Save fails | Search the event list for the exact title/slug | Inspect persistence before retrying; retain the error for support. The corrected form uses the dedicated event service. | | Publish fails | Reopen the event and inspect its status | Do not assume publication succeeded; report the action and error. The local enriched-record defect is fixed. | | **Inconsistent state mutation** | A different action saved a newer version | Read the record again; apply only the intended changes. | | Card is one day earlier than the form | Stored date and native list | Keep the intended dates; report the display mismatch. | | Registration errors after saving a booking | Ticket list and calendar-generation error | Inspect both bookings and tickets before retrying. The corrected calendar accepts the form values. | | Native list says a list is not a subtype of `int?` | `attendees` field in a custom API payload | Correct the field type; the empty training array was cleared to null. | | Workshop looks free on native Overview | Studio ticket type and saved price | Compare the saved price/capacity and refresh the app. The repaired overview reads the actual ticket-type fields. | | General Admission enters twice | **Allow re-entry** | Choose the intended policy; use the movement log, not scan count, for attendance. | | Lab entry denied | Selected zone and type’s **Zone Access** | Confirm the guest owns workshop access; do not change a rule just to clear a queue. | | Check-out says not currently checked in | Last movement | Confirm there is an entry before recording an exit. | | Web desk finds nothing | Native lookup and saved ticket | Confirm the selected event and exact holder email; avoid duplicate registration. The corrected web desk consumes the returned ticket list. | | Fulfilled ticket has no desk | Request omitted the point ID | Enter the configured Desk / Point ID before fulfillment when desk reporting is required. | | Speaker/session pickers show no results | Existing customer/participant records | Search the exact customer email or the participant role, select a result, then reopen the saved record. | | Venue-ticket type cannot be selected | Picker results | Do not generate a large batch; retest one ticket after the picker is repaired. | | Event community missing its ticket holders | Actual page membership | Inspect membership creation; this rehearsal hit a duplicate-name error. | | Badge data appears but nothing prints | Printing implementation and printer connection | Run a separate real print test; the phone currently reports printing unavailable. | ## What happened behind the scenes
Implementation and evidence for developers and editors Source paths are relative to `/Users/imzee/projects`: - `websitemint/packages/ui/src/components/events/`: event form, ticket-type list, session form, event detail, accreditation desk and venue-ticket controls. The desktop desk consumes the actual `tickets` result; event saving uses the dedicated CRM endpoints. - `appengine/src/crm/events.service.ts`: dedicated event creation/update and the required CRM service caller context. - `appengine/src/events/event.service.ts`: lifecycle, enrichment, generated days, calendar content and event community creation. - `appengine/src/events/event-ticket.service.ts`: ticket issuance, free limits, fulfillment, QR rotation, perks, refund/cancellation behavior. - `appengine/src/events/events-client.service.ts`: public purchase/confirmation; payment-reference trust requires attention before real checkout. - `appengine/src/events/event-checkin.service.ts`: re-entry, zone/checkpoint rules, movement and refusal logging. - `appengine/src/events/event-session.service.ts`, `event-participant.service.ts`, `events.controller.ts`: programme records and supported routes. - `appmint_go/appmint_mobile/lib/screens/events/scanner_screen.dart`: zone selection, manual lookup and scan request; no active-event/checkpoint field in the practiced request path. - `appmint_go/appmint_mobile/lib/screens/events/accreditation_screen.dart`: ticket lookup, fulfillment without point ID, badge-data dialog. - `appmint_go/event_app/lib/config/environment.dart`: attendee application organization and environment binding. Do not reproduce credential values. [Capture ledger](assets/appmint-events/evidence.json), [final session](assets/appmint-events/sessions-readback.json), [speaker](assets/appmint-events/participants-readback.json), [fulfilled ticket, secret fields removed](assets/appmint-events/kofi-ticket-readback.json), [restored re-entry rule](assets/appmint-events/general-admission-readback.json), [successful free registration, redacted](assets/appmint-events/free-ticket-purchase-kofi.json).
## Where next - [Attend and connect in EventOxygen](eventoxygen-attend-and-connect.md) — the guest-facing companion. - [Build and moderate a member community](appmint-build-a-member-community.md) — make the conversations useful after the event. - [Manage roles and groups](appmint-roles-groups-and-permissions.md) — organize staff access. **Evidence:** the fresh local walkthrough on 24 September 2026 created and edited the event in Studio, published it, created both ticket types, registered Zara/Kofi, created Nia and the public session, and verified the saved links. Appmint Mobile performed Main hall entry, workshop refusal, checkout, return, no-reentry refusal and an enabled repeat. Kofi's pickup persisted the staff identity, timestamp and desk-1 while retaining confirmed status. Wrong-event entry/exit were refused without mutation. Studio booking, customer selector, registration lookup, denied-result display and scan counters were corrected and exercised. See the [local repair and verification report](../application-fixes/event-live-walkthrough.md). Older screenshots are historical training captures; the fresh captures linked above show the repaired behavior. No paid transaction, provider refund, physical QR camera decode, physical print or iOS run is claimed. The camera background is the emulator scene; admission used Manual Lookup. [Companion-video guide](production/appmint-run-an-event.md). --- # Build and test a client-dashboard prototype in Vibe Studio > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appmint/appmint-vibe-build-a-client-app.html # Build and test a client-dashboard prototype in Vibe Studio ![The actual Cedar and Form dashboard running inside Vibe's static preview.](../application-fixes/assets/vibe-local/generated-maya-preview.png) *A useful first prototype: Maya can see the project's stage and next decision. The banner clearly identifies fictional sample data.* **Who:** business owners shaping a custom app and developers taking it toward production. **Time:** 60–90 minutes. **Level:** beginner prototype, then developer integration. **Product:** Vibe Studio. **Checked:** 24 September 2026, current local source with actual model generation and preview acceptance; hosted sign-in and earlier editor captures are labelled separately. **Story:** Cedar & Form's interior-design client dashboard. ## What you will have at the end You will understand Vibe's project brief, creation form, assistant, files, code, preview, device layouts and launch drawer. You will test a generated dashboard's normal, loading, empty and error states; enter a sample change request; and distinguish a working prototype from a connected customer application. The practical result is a working **sample-data prototype**. The developer chapter supplies a concrete integration contract and acceptance checks based on the tested AppEngine course. It does not pretend the generated client selector is authentication or that its local form sends a real request. ## What you need - Your own production Appmint account from [Welcome to Appmint](appmint-welcome.md). Use the same company ID, email and password for Studio Manager and Vibe Studio. - [Vibe Studio](https://vibe-studio.appmint.io/login). The sign-in screenshots below use the hosted Vibe service; the latest generated-dashboard screenshots use the local developer build; an account created only on a local development installation will not sign in to that hosted service. Developers can run the current Vibe source against their own local AppEngine, but must configure the separate session manager before project creation. - A concise business outcome, fictional examples and a name available for your project. - For the developer chapter, the [connected-client course](appengine-build-a-connected-web-or-mobile-client.md) and two customer accounts you control in a training organization. Appmint and its apps are free. The original hosted tutorial project is `cedar-client-learning`. The fresh local generation uses `tutorial-client-dashboard-20260924`. Use your own name; you do not need this organization or its credentials. Reopen an existing project when continuing a lesson instead of creating duplicate environments. ## The story Cedar & Form's clients repeatedly ask three questions: “Where is my project?”, “What do I need to decide?” and “Where are the documents?” The prototype puts those answers together and gives clients a place to describe a change. Maya Bennett and Jordan Lee are fictional examples with different project stages, so the design must work for more than one happy-path screenshot. ## The route ```text Business brief → project and generated files → interactive sample preview ↓ state tests → mobile check → data contract ↓ server ownership checks → connected app → release ``` ## Part 1 — Sign in and create a deliberate project ### 1. Use your production Appmint identity On Vibe's login page, enter your **Email**, then select **Continue with Password**. On the next step, enter your company ID in **Site Name**, enter your password and select **Sign In**. ![The real password step; Site Name identifies the organization.](assets/appmint-vibe/05-project-account-password.png) Here **Site Name** is the organization identifier used for authentication, not the name of a website you created in Build Studio. If a saved account chooser appears, select your company; an expired session needs sign-in again. **Sign in to another organization** opens the email form when the needed account is not listed. Wait for **Build your ideas with Vibe**. The page contains **Start**, **Gallery**, **Dev Environments**, **FAQ**, the brief box and **Build**. The sidebar also lists existing projects. ![The authenticated Start screen and existing tutorial project.](assets/appmint-vibe/07-signin-session-result.png) > Gotcha: “Signing in…” is a transition, not the finished result. Wait for the Start screen and your project navigation. Do not create another Appmint account merely because a saved session expired. **Local developer check, 24 September:** the current Vibe frontend also accepted the review organisation’s ordinary local email/company/password login and reached Start. A page reload retained the session. This is a separate local identity, not access to the original hosted project. The full brief below carried unchanged into Create New Project. The continuation created its AppEngine record and session-manager project, opened the editor and reopened it after reload. The actual model then generated the three requested files; both fictional clients, all four states, retry, the sample request and reload reset passed in Preview. ![Current local Start after ordinary staff sign-in](../application-fixes/assets/vibe-local/start-authenticated.png) ### 2. Write what the client needs to accomplish In **Describe your idea...**, enter: > Create a client project dashboard for Cedar & Form interior design as a static prototype using `index.html`, `styles.css` and `app.js`. Put fictional fixtures and behaviour in `app.js`. Do not use a backend, real accounts, network calls, analytics or deployment. > > Show a persistent banner: “Demo dashboard — all data is fictional sample data.” Add a dropdown labelled “Viewing project for” with “Maya Bennett (sample)” and “Jordan Lee (sample)”. This is a demo fixture selector, not authentication. > > Maya's fixture: status “In progress”, phase “Material selection”, progress 55%, next decision “Choose the shelving wood tone”, and four clearly labelled sample documents. Jordan's fixture: project “Lee Garden Apartment — Full Redesign”, status “Awaiting your input”, phase “Concept review”, progress 30%, next decision “Approve the concept direction”, and three sample documents. Use explicit fictional decision dates and label any countdown as sample data. > > Add a “Preview state” control with Loaded, Loading, Empty and Error buttons. Loading shows “Loading project… (simulated)” and a skeleton. Empty shows “No project yet”. Error shows “We couldn't load this project”, explains that it is simulated and offers “Try again”; that button briefly shows Loading then returns to the selected client's Loaded state. Switching the client also shows Loading before that client's values. > > Add “Request a change” with “Area / room” including Living room, “Priority” including Soon, and a “What would you like changed?” text field. “Send request” must only echo the entered values under “Request captured (demo)” and “Nothing was actually sent. Here's what you entered”. “Send another” returns to the form. Keep the request in memory only: a full reload removes the acknowledgement. No actual message, ticket or server write. > > Make the controls usable at mobile, tablet and desktop widths. Keep all fixture controls visibly labelled as demo tools; do not claim customer isolation or production readiness. ![Historical Start-box capture showing the shorter original brief; use the expanded brief above for this rehearsal.](assets/appmint-vibe/21-project-brief.png) This brief supplies audience, tasks, data boundary, alternate states and device needs. Those details give you something specific to evaluate. “Make a dashboard” leaves all of those decisions implicit. You can adapt the story to a repair shop, consultancy or event business. Keep the same discipline: who uses it, which decision it helps, which information it needs, and whether the first version uses sample or connected data. ### 3. Inspect the creation form before provisioning Select **Build**. **Create New Project** opens with **App** and **Design**, the carried description under **What do you want to build?**, and **Dev Environment Name**. ![The current Create New Project form, inspected without creating a duplicate.](assets/appmint-vibe/22-create-project-form.png) Choose **App** for this dashboard. Enter an available project name in **Dev Environment Name**, such as `cedar-client-learning` if it is available in your organization. The form permits letters, numbers, dashes and underscores. Keep the name recognizable; it identifies a development project, not your legal company name. Select **Create Dev Environment** once when creating your own project. Our original creation produced the existing project used throughout this course. The fresh screenshot shows the form reopened for inspection; we cancelled it and reused that project. > Gotcha: **Design** is the parametric 3D branch, described as exporting GLB/STL. It is not a website-theme selector. If a project already exists, open it through **Dev Environments** or the sidebar instead of submitting the same creation again. ### 4. Let the carried brief finish The editor opens with **AI Assistant**, **Files**, **Preview**, **Code** and **Terminal**. The original empty editor showed **No files yet — Create or open a file to start** before generation created `app.js`, `index.html` and `styles.css`. ![The original empty project during the creation walkthrough.](assets/story-research/review-vibe-editor-empty.png) *Historical capture from the actual 17 September generation. The following captures show that same project reopened on 18 September.* Watch the assistant's progress before sending a second build request. When the turn completes, the Files tree refreshes automatically. Inspect the new files and the actual preview. Before Part 2, check that the three named files, both sample clients, four Preview state buttons, Try again and the three request fields exist. If a required item is missing, send a targeted refinement in **AI Assistant**: ‘Keep the current design. Add the following missing items from the brief: [list the missing labels/behaviours]. Keep all data fictional and do not add network calls or publish.’ Wait for that change and check again. Do not skip a later acceptance test because the generated version omitted its control. The expanded brief was independently generated and exercised on 24 September. It produced all required controls without a refinement request. Your visual design may differ; compare the requested behaviour rather than matching a screenshot pixel for pixel. **Try it:** before generation, write down one question your client should answer from the page and one action they should be able to take. After generation, locate both in the preview. ## Part 2 — Learn the editor by checking its output ### 1. Recognize what each area controls | Area | Use it for | Check before moving on | | --- | --- | --- | | **AI Assistant** | Brief, proposed changes and generation output | Read errors and inspect the resulting files | | **Files** | Project files such as app.js, index.html and styles.css | Files remain available after reopening the project | | **Code** | Read/edit the selected file | Understand where sample data and actions live | | **Preview** | Exercise the generated interface | Test actual controls and alternate states | | Device buttons | Mobile, tablet and desktop preview modes | Content and actions remain usable at narrow widths | | **Terminal** | Runtime/build output panel | Read emitted errors; the checked panel is output-only | | **Launch** | Hosting URLs, domains and deployment progress | A listed URL is not the same as a verified deployment | The **Connected** badge describes the connection to the project server for files/preview. It does not mean the generated dashboard is connected to your CRM. The **AI Assistant** header button can hide its panel to give the preview more room. An empty dock area is also part of the editor layout, not a missing dashboard section. ### If generation fails before files appear Read the assistant error before creating another project. A created project can exist even when its model runner fails: project provisioning and generation are separate stages. In the local rehearsal, both creation requests succeeded, but an unavailable executable returned **ENOENT** and no application files were generated. The repaired chat displays that failure and retains it after reload. ![The local project remains open with the failed runner's diagnostic](../application-fixes/assets/vibe-local/runner-error-after-reload.png) If you operate the local backend, check its configured `CLAUDE_EXECUTABLE_PATH` and the runner's authentication under the service account. For hosted Vibe, include the project name and displayed error in your support request. Once the runner is available, reopen the existing project and resend the brief once. Confirm that the required files and controls exist before beginning the preview checks below. **Connected** alone does not prove generation succeeded. ### Optional: verify the file editor before generation If you run your own local Vibe installation, this small check separates file-service problems from model-runner problems: 1. In the empty Files area, select **Create a file**. 2. Enter `tutorial-editor-check.txt` in **File name**, leave **Empty file** selected, and select **Create File**. 3. Click the editing area and type `Local editor persistence check. Fictional tutorial note.` 4. Select **Save**, or press **Cmd+S** on macOS / **Ctrl+S** on Windows or Linux. Wait for the Save indicator to clear. 5. Reload the page, select the file in **Files**, and confirm the same sentence appears. ![A manually created file reopened after save and page reload](../application-fixes/assets/vibe-local/manual-file-reopened.png) This proves that your project can save and reopen a file. It does not prove that AI generation or the dashboard preview works. The check file can remain separate from `index.html`, `styles.css` and `app.js`. ### 2. Open the generated data file Choose **Code**, then select `app.js` in **Files**. Read the fixture objects and request handler. Check that the client names, progress and decisions come from sample data, and that the form only echoes input. The current generated file below explicitly identifies fictional fixtures and a fixed sample date. ![The freshly generated app.js: sample date, Maya and Jordan fixtures, and the preserved manual check file.](../application-fixes/assets/vibe-local/generated-code.png) The first sample has Maya's name, project title, material-selection phase and 55% progress. Jordan's sample uses concept review and 30%. These values were written into the generated file. Their presence does not mean Vibe queried existing Cedar & Form customer records. The other files have distinct jobs: `index.html` provides structure, `styles.css` presentation, and `app.js` sample data and behavior. A later framework-based project may have a different file structure; inspect what your actual generation produces. ### 3. Switch from selecting elements to using the app Select **Preview**. The static project renders inside Vibe without a successful public deployment. If the toolbar shows **Select**, the element selector may intercept clicks for editing. Switch to **Browse** to use links and controls normally; the selector's tooltip explicitly says clicks select elements and links are blocked. ![The working preview with Browse active.](../application-fixes/assets/vibe-local/generated-maya-preview.png) Read the banner first: **Demo dashboard — all data is fictional sample data.** Maya's page shows **In progress**, **Material selection**, **55%** and the next decision **Choose the shelving wood tone**. The current example explicitly labels its date fictional and its countdown as calculated from a fixed sample date. Replace those fixtures only when you connect a real source of deadlines. ### 4. Exercise another client example In **Viewing project for**, choose **Jordan Lee (sample)**. Wait through **Loading project… (simulated)** until **CLIENT: JORDAN LEE (SAMPLE)** and Jordan’s project appear. ![Jordan's actual loaded sample, showing different project information.](../application-fixes/assets/vibe-local/generated-jordan-preview.png) The page now shows **Lee Garden Apartment — Full Redesign**, **Awaiting your input**, **Concept review**, **30%** and **Approve the concept direction**. Jordan has three sample documents; Maya has four. > Gotcha: this dropdown changes fixtures inside the browser. It is not customer sign-in or an access-control test. A real customer must not be able to choose another customer's identity from such a selector. ### 5. Test loading, empty and error states Use the generated **Preview state** buttons: | Button | Actual result | Why it matters | | --- | --- | --- | | **Loaded** | Selected sample's project | Normal daily experience | | **Loading** | Simulated loading message and skeleton | Makes waiting understandable | | **Empty** | **No project yet**, with an explanation | A new client should not mistake no data for a crash | | **Error** | **We couldn't load this project**, simulated-error explanation and **Try again** | Gives a failed load a visible recovery path | ![The actual simulated loading state.](../application-fixes/assets/vibe-local/generated-loading-preview.png) ![The actual no-project state.](../application-fixes/assets/vibe-local/generated-empty-preview.png) ![The actual error state and Try again control.](../application-fixes/assets/vibe-local/generated-error-preview.png) Select **Try again** and wait for the sample to load. These controls test the design of each state; they do not simulate every backend failure or establish that a real retry policy is correct. ### 6. Complete the sample change request Return to **Loaded** and **Maya Bennett (sample)**. Scroll to **Request a change**. Enter: | Field | Example | | --- | --- | | **Area / room** | Living room | | **Priority** | Soon | | **What would you like changed?** | Tutorial: Please compare the warmer oak swatch for the shelving. | Select **Send request**. The result is **Request captured (demo)** followed by **Nothing was actually sent. Here's what you entered**, then the area, priority and details. **Send another** returns to entry. ![The actual demo acknowledgement at mobile width, explicitly saying nothing was sent.](../application-fixes/assets/vibe-local/generated-mobile-request.png) This is a useful prototype behavior: you can evaluate the wording and interaction without notifying anyone. It must keep that explanation until a real request workflow exists. ### 7. Check mobile layout and persistence separately Select **Mobile (375px)** in Vibe's device controls. The current UI displays a phone frame; its device label may show its own emulated dimensions. Scroll inside that frame to review status, documents and the request result. Use **Tablet (768px)** and **Desktop (1280px)** to compare available space. ![Actual mobile preview of the generated dashboard.](../application-fixes/assets/vibe-local/generated-mobile-preview.png) Now reload the full browser page, reopen **Preview** if needed, and inspect the form. In our rehearsal it returned to the initial Maya sample with an empty request form. The generated files remained, but the demo request did not persist. ![The same project reopened after a full browser reload.](../application-fixes/assets/vibe-local/generated-reopened-preview.png) > Gotcha: in the earlier hosted rehearsal, **Reload preview** left the acknowledgement visible alongside **Files changed / Reload**. Use a full browser reload for this check. The new local rehearsal returned to Maya with no acknowledgement. Saved source files and temporary form state are different things. ### 8. Inspect output when a build reports trouble Select the **Terminal** bar at the bottom of the editor to expand the runtime/build output panel. The checked `TerminalPanel` is configured with input disabled and listens for output events. Do not assume you can type shell commands into this particular panel. ![The project's actual Terminal output panel.](../application-fixes/assets/vibe-local/generated-terminal.png) Read the assistant's error alongside the output and the affected stage. Ask for a targeted explanation or a syntax check when needed. For a developer's local export, `node --check app.js` checks this JavaScript file's syntax; it does not verify UI behavior, ownership or deployment. The original assistant reported that syntax check, while our fresh rehearsal tested the UI itself. **Check yourself:** what survived reopening? The generated project files. What did not? The sample request. That distinction defines the integration work next. ## Part 3 — Give the prototype a real data contract This chapter is the developer handoff. The generated project above remains sample-only; the following contract uses endpoints exercised separately in the connected-client course. Implement and retest it before presenting the prototype as a customer service. ### 1. Separate the three identities The staff account signing into Vibe is the builder. The customer signing into the finished dashboard is a different identity. A server-side integration may also use an app credential. Do not paste the builder's staff bearer into `app.js` or use it as a shared customer session. For customer password sign-in, the tested route is `POST /profile/customer/signin` with `orgid` and a JSON email/password body. Protect session material and use the actual returned token fields. Keep live secrets out of prompts and generated source. ### 2. Define what each component reads | Prototype component | Connected source or work required | | --- | --- | | Customer name | `GET /client-data/profile`, fields under `data` | | Consultation list | `GET /client-data/reservations`, rows under `data` | | Summary | `GET /client-data/dashboard`; verify individual summary fields before displaying | | Project stage / next decision | Requires an explicitly designed customer-owned project contract; do not invent a datatype or route | | Documents | Requires authorized file listing/download behavior; prototype View links are inert | | Request a change | Requires a chosen persisted request/ticket workflow and ownership checks | | Staff edits | Separate authenticated staff workflow with permitted fields and readback | The tested dashboard summary returned zero upcoming reservations even when the reservation list contained a future booking. Render the authoritative list or fix the summary before using it to tell a customer they have nothing scheduled. Profile fields are not all top-level, and list envelopes are not bare arrays. ### 3. Add one connected slice before broad changes Use this implementation brief as a starting point: > Replace only the sample customer-name and consultation-list data with the documented customer sign-in, profile and reservations requests. Keep organization and API origin configurable. Preserve loading, empty and error states. Parse the actual record/list envelopes. Show errors without claiming success. Do not add project updates, profile saves, real sends or deployment in this change. Do not embed staff credentials. Explain every changed file and how to test it. After implementation, sign in with a training customer, inspect that customer's response, and compare the screen with a fresh API read. Then use a second training customer. Do not remove the demo banner until every displayed field is clearly identified as connected or sample. ### 4. Use the complete booking contract The tested booking request needs a reservation definition, a service, start and end timestamps with offsets, a customer and party size. It is not the old topic's incomplete `{ email, date, time }` example: ```json { "reservationDefinitionId": "YOUR_TRAINING_DEFINITION_ID", "service": "Design consultation", "startTime": "2026-10-05T10:00:00-05:00", "endTime": "2026-10-05T10:30:00-05:00", "customer": { "email": "YOUR_CUSTOMER_EMAIL", "name": "YOUR_CUSTOMER_NAME" }, "partySize": 1 } ``` Choose a future slot from your own definition; these dates are the historical training example. Submit once to `POST /client-data/reservations`, retain the returned reference, then read the customer's list and the staff reservation view. Our repeated identical POST created a second booking, so do not automatically retry a create after an ambiguous timeout. ### 5. Verify persistence and customer boundaries The later connected-client rehearsal verified a flat `PUT /client-data/profile` followed by a fresh GET: the changed name and phone persisted for the signed-in customer, while the second customer's profile stayed unchanged. Follow [the connected-client course](appengine-build-a-connected-web-or-mobile-client.md) for the tested request shape rather than sending an invented nested payload or treating a 200 response as sufficient proof. The same rehearsal verified reservation ownership through both direct customer requests and delegated staff-plus-customer requests. The owner could read the reservation; the other customer received **404** from the customer single-record route and **403** from the generic repository route, without the reservation data. These later local passes supersede the earlier failed profile and cross-customer-read observations. They cover those named routes, not every future project, document or ticket route. Repeat two-customer checks for each connected feature you add. Hiding a staff button does not establish server-side ownership. [Exact requests and local results](../application-fixes/connected-client-local-walkthrough.md). For session renewal, the tested customer refresh returned a new access token without a replacement refresh token in one request shape. Retain the current refresh token when no new one is returned. Retry an authenticated read at most once after renewal; do not repeat writes blindly. The complete example and failure responses are in the connected-client course. ## Part 4 — Inspect launch readiness without confusing it with preview Select the rocket control titled **Launch**. The drawer shows **SpinForge**, **Project**, **Live URLs**, **Owner**, **Org**, **Custom domains**, **Attach**, **Sync domains to hosting** and **Deploy now**. When a deployment runs, its stages are **Build**, **Configure hosting**, **Upload** and **Go live**. This walkthrough stops at inspecting the drawer. ![The local project’s Launch drawer, inspected without deploying or attaching a domain.](../application-fixes/assets/vibe-local/generated-launch-inspection.png) The fresh local drawer shows a default **Live URLs** entry even though this prototype has not been deployed. Use Preview to review the static app; inspect the deployment result and open the published address only when you intentionally release it. A proposed hosting address is not a finished release. The earlier hosted project had a missing-partner-key deployment error; that historical failure does not describe this newly generated local preview. We did not press **Deploy now**, attach a domain or send a collaboration invitation during this continuation. When your implementation is ready and you intend to publish, verify the build stages, open the resulting URL in a fresh session, and repeat the customer, booking, ownership and reload checks there. A deployment success message alone does not validate business data. **Share** and session-invitation flows can carry an authentication token in the generated URL. Treat such invitations as credentials; do not put them in course footage, public issues or example code. ## If something goes wrong | Symptom | First check | What to do | | --- | --- | --- | | Production sign-in fails with a local account | Where the account was created | Use your production Appmint identity | | Saved account says session expired | Normal login flow | Sign in again; reuse the project | | Preview clicks select elements | Select/Browse mode | Choose Browse for interactive testing | | Another sample client still shows loading | Wait for the selected client’s name and project | Do not capture the transition as the final result | | Request acknowledged, no staff record | Demo notice and generated handler | Implement persistence before promising delivery | | Internal reload leaves old state | Files changed / Reload notice | Use a full reload for the persistence check | | Connected badge, no CRM data | What Connected describes | Inspect the generated data layer and API requests | | Static preview works, Launch failed | Separate preview and hosting stages | Keep the project and diagnose the failed deployment stage | | 200 profile update, old value remains | Request shape and fresh profile GET | Compare the tested flat fields with the connected-client example; do not show a saved confirmation until readback matches | | Customer list is empty | Identity, organization and envelope | Use the intended customer's session and inspect `data` | ## What happened behind the scenes
Source and capture evidence Vibe's `src/components/auth/LoginPage.tsx` handles the email/password steps and saved sessions. `components/dialogs/CreateProjectDialog.tsx` defines App/Design and the environment form. `services/dev-env-service.ts` creates the AppEngine environment before attaching the session-manager project; files/generation/preview are not stored as CRM records merely because that environment exists. `components/editor/TerminalPanel.tsx` configures output-only terminal rendering. `components/drawers/DeployDrawer.tsx` owns deployment jobs and stage display. AppEngine environment routes are in `src/site/dev-environment.controller.ts`; customer routes/services are in `src/client-account`. Evidence: [fresh capture ledger](assets/appmint-vibe/evidence.json), [normal sign-in response statuses](assets/appmint-vibe/signin-response-statuses.json), [correct-owner environment reads](assets/appmint-vibe/review-account-environment-readback.json), and the original generated-file screenshots in `assets/story-research`. The old topic attributed the project to a different training organization; the actual owning review account and fresh successful sign-in corrected that attribution.
## Where next [Connect a client to AppEngine](appengine-build-a-connected-web-or-mobile-client.md) supplies the full tested request sequence. [Create a client dashboard](appmint-create-a-client-dashboard.md) covers the Studio website account area. [Operate and extend AppEngine](appengine-operate-and-extend-the-platform.md) follows a request through source and operating signals. **Evidence:** earlier hosted login/editor checks plus the new local project provisioning, actual generation, code inspection, both clients, loading/empty/error/retry, request echo/reset, all three device modes, full reload, Terminal and read-only Launch inspection passed. The prototype remains sample-only; connecting customer data and publishing it are separate next steps. No real change request or invitation was sent. [Video production guide](production/appmint-vibe-build-a-client-app.md).
Fresh generated-prototype acceptance — 24 September 2026 The existing local project received the exact expanded brief through AI Assistant. Actual generation created index.html, styles.css and app.js while preserving tutorial-editor-check.txt. Both client fixtures, Loading/Empty/Error/Loaded, retry to the selected client, request echo, Send another, mobile form and full-page reload reset passed. The iframe made no network requests during the request submission. Mobile/tablet/desktop modes had no horizontal overflow at their actual rendered widths (393, 820 and 1003 pixels in this editor viewport); toolbar presets are not a claim of identical iframe dimensions or native-device testing. No backend, customer request, deployment or invitation was created. [Acceptance readback](../application-fixes/assets/vibe-local/generation-acceptance.json) and [application report](../application-fixes/vibe-local-prerequisites.md).
--- # Set up Appmint Mobile and take your CRM with you > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-mobile/appmint-mobile-welcome-and-daily-work.html # Set up Appmint Mobile and take your CRM with you ![Maya’s saved preference in the native Android lead editor](assets/appmint-mobile/25-mobile-notes-readback.png) *Maya’s preference is now in her lead record. You will open the same record in Studio Manager and read the note back there.* **Who this is for:** business owners and colleagues taking their first Appmint workflow onto a phone. **Time:** 30–40 minutes once the app is installed. **Level:** beginner, with shared-device and developer notes afterwards. **Product:** Appmint Mobile; Studio Manager for the desktop comparison. **Checked:** installation evidence dated 18 September 2026; local Android password sign-in and Notes save/reopen rechecked 24 September 2026. The installation/login-entry capture below shows the publicly distributed Android package 1.0.2+9 on a fresh Android 36 emulator. Later signed-in captures show a newer build compiled from the reviewed source and connected to the local training system. They are labelled separately; neither set is an iPhone walkthrough. > **Build note:** the current app signs you in with your email first. An older Android release asks for **Site Name**. Follow the instructions matching your screen. Use the Android download link in the installation section; the public package and local training build have different login screens. ## What you will have at the end You will know where each part of the mobile app fits, personalise Home for your role, open an existing CRM lead and compare its details with Studio Manager. In a training record, you will save a customer preference and check it on both screens. You will also learn where the current task and Calendar limitations interrupt a follow-up, so you can keep the work moving without losing it. This course uses **Jordan Morgan**, working for Cedar & Form, and **Maya Bennett**, a prospective client with a $4,800 interior-design enquiry. Jordan is the signed-in colleague; Maya is the customer. They are different people and different record types. Appmint and Appmint Mobile are free. A business phone number is not required for the CRM exercise. ## What you need - Your own Appmint staff sign-in. Owners can use the account created in [Welcome to Appmint](appmint-welcome.md). Colleagues need an accepted invitation and appropriate access; use [Roles, groups and permissions](appmint-roles-groups-and-permissions.md). An invitation prepared in a drawer is not an active colleague account. - Appmint Mobile on an Android phone or iPhone. Use the installation routes below. Keep an internet connection available for fetching and saving records. - A browser signed in to Studio Manager for the same company. - A **training lead**, not a valuable live prospect, for the edit exercise. [Turn an enquiry into a project](appmint-turn-an-enquiry-into-a-project.md) explains lead creation. Use Maya Bennett, `maya.bennett@example.com`, source **Referral**, value `4800`. If it already exists, reuse it rather than create a duplicate. > **Practise on a training lead first.** An older build overwrote fields outside the editor. The local repair now patches only changed fields and has eight passing regression checks. The 24 September native check also preserved the visible name, email, source, status, temperature, priority and value when saving Notes. A fresh Studio comparison confirmed the note, source, value and score50. The phone editor does not display every field, so still check any additional metadata important to your process. ## The story Jordan has just finished a consultation. Maya prefers linen to velvet. That sounds easy to remember—until Jordan visits three more clients that afternoon. A useful CRM habit is to record the preference while it is fresh, with enough context for someone else to act on it. The goal is not to create another address book on the phone. Appmint Mobile opens records belonging to the same organisation as Studio Manager. Jordan saves a preference once, then the colleague at the desk can read it. ![Appmint Mobile and Studio Manager use the same organisation’s lead through AppEngine](assets/appmint-mobile/one-lead-two-screens.svg) *Concept diagram. A Notes field holds shared context; a task tracks work to do; a call log records a phone call. They are not interchangeable.* ## The route **Install and sign in → make Home useful → open a lead → save a training note → check it at the desk.** ## Part 1 — Install the app and sign in ### 1. Open the official download entry On your phone, open [appmint.io](https://appmint.io/) and scroll to **GET APPMINT MOBILE**. This is the staff app for CRM, business operations and event administration. **EventOxygen**, further down the page, is a different app for the attendee experience. ![Appmint’s public mobile download section](assets/appmint-mobile/03-public-downloads.png) *Use the Appmint Mobile section’s App Store or Setup instructions entry. Do not accidentally install EventOxygen for this course.* **On iPhone:** tap **App Store**, which opens the [Appmint Mobile listing](https://apps.apple.com/us/app/appmint-mobile/id6769989899). Review the compatibility information shown for your device, select **Get** or the download icon, and complete Apple’s installation confirmation. Open **Appmint Mobile** from the Home screen afterwards. This course does not require creating a new company from the app. **On Android:** tap [Download Appmint Mobile APK](https://web.appmint.space/apps/appmint_mobile/android/latest/appmint_mobile.apk). This is the staff app, not the EventOxygen attendee app. The canonical download and release metadata were checked on 18 September 2026; the published metadata identifies Appmint Mobile version 1.0.2+9. The product-page download button may still be awaiting release, so use this direct link if it is not visible. When the official APK downloads successfully, open it from your browser’s Downloads list. Android may ask whether that browser can install apps. Allow that source for this installation, return to the package and select **Install**, then **Open**. You can turn that browser’s install permission off again afterwards. If Android reports incompatibility, check the device requirements supplied with that release; the current source targets Android 8.0/API26 or later. The official package was downloaded, its size/hash checked against the release metadata, installed on a fresh Android 36 emulator and opened successfully. The package requires Android 8.0/API26 or later. [Installation evidence](../application-fixes/staff-android-install.md) records exactly what was tested. Signed-in screenshots later in this course use the separately compiled training build. ### 2. Match the sign-in entry to your installed release Open Appmint Mobile and stay on **Login**. On the public Android package **1.0.2+9**, first enter your company's organisation ID in **Site Name**, to the left of `.appmint.app`. Enter only the ID, not `https://`, the suffix, the company's display name or a page address. Use the organisation ID supplied by your company administrator; do not copy a training organisation from a screenshot. Then enter the email used for your staff account in **Email Address** and tap **Continue with Password**. **Send Magic Code** and **POS Quick Sign-In** are separate methods, not additional password-sign-in steps. ![Public Android 1.0.2+9: Site Name and Email Address on first launch](../application-fixes/assets/staff-android-install/01-launch.png) *This is a fresh installation of the actual downloadable package. No account was submitted during this installation check.* **Newer email-first build:** if your screen has Email Address but no Site Name, enter your staff email, then tap **Continue with Password**. This checks which organisations that staff account belongs to; if it offers an **Organization** choice, select the company you intend to work for. The following image and signed-in exercise use that separate training build, not the public 1.0.2+9 binary. ![Newer training build's email-first entry](assets/appmint-mobile/10-current-login.png) > **No account found?** Check the email against your accepted invitation. A customer account for a website's client portal is not a staff account. Ask the company owner to check the invitation and user entry instead of creating another company through **Sign Up**. The other buttons start different workflows: **Send Magic Code** opens a six-digit code dialog; **POS Quick Sign-In** is a separate entry for POS access. Neither is needed for this password walkthrough. If a development build shows **Fast Login (Dev)**, leave it alone and practise the normal staff sign-in. ### 3. Enter your password Enter the password for that staff account and tap **Sign In**. **Back** returns to the email step if you need to correct it. ![Native password step](assets/appmint-mobile/11-current-password.png) *The password remains concealed. There is no reason to expose it while following or recording this lesson.* If the app presents **Verification required**, read the method named in the dialog. For **authenticator app**, open the authenticator entry for this Appmint account, enter its current six-digit code, then tap **Verify**. The code changes regularly; if it expires before submission, use the next one. **Cancel** leaves the challenge without completing sign-in. Do not treat the challenge as failed account creation. ![Actual authenticator challenge before entering the code](../application-fixes/assets/mobile-local/24-authenticator-challenge.png) *Local check: enabled an authenticator through the staff profile’s Security tab, signed in fresh on Android, entered a real time-based code and reached Ada’s Home. The secret and codes are intentionally absent from this capture.* You should arrive at Home with your name in the header. In the capture it is **Jordan Morgan**. Your company’s data and your own name will differ. ### 4. Recover a forgotten password 1. Enter your staff email and tap **Continue with Password**. Select your organisation if offered. 2. Tap **Forgot Password?** once. Wait for the request result, then check that account’s inbox. A request confirmation alone does not prove delivery. 3. Open the reset link in the newest email. The corrected app directs staff recovery to Studio’s **Reset Password** page. Check that the page names your email. 4. Enter a new password in **New Password**, repeat it in **Confirm Password**, and select **Reset Password**. Wait for **Your password has been reset successfully.** 5. Return to Appmint Mobile, enter that new password, and select **Sign In**. If your account requires verification, complete its challenge too. Confirm your name on Home before opening business records. ![Studio confirms the password reset completed](../application-fixes/assets/mobile-local/27-reset-success.png) *Local acceptance on 24 September: the phone requested the email, a controlled local mail receiver captured it, the real reset form accepted a new password, and the phone signed in with it. The local link targeted the local Studio preview; the app’s default destination is Studio Appmint. No reset token or password appears in this screenshot.* If the request fails, check the email and organisation before retrying. If the link has expired, request a new one and use the newest email; do not repeatedly submit the expired form. Keep the email private because it contains account-recovery access. [Recovery repair and verification](../application-fixes/mobile-recovery-live.md). **Try it:** identify your name on Home before opening a customer record. **Check yourself:** should Maya Bennett’s customer email be used for Jordan’s staff login? No. Jordan signs in as a colleague, then opens Maya’s CRM record. ## Part 2 — Make Home useful for your job ### 1. Read the navigation before changing it ![Native Home with the account header, summary cards, apps and bottom navigation](assets/appmint-mobile/12-home.png) Home has several distinct areas: | Area | What to use it for | | --- | --- | | Your name and device entry | Recognise the signed-in colleague; reach device management. | | Location selector | Choose the venue for location-specific BusinessMade work. It is not an organisation switcher. | | Leads, Contacts and Tickets cards | A quick summary of CRM records. **Tickets** here means support tickets. | | Events panel | Reach event work; it may remain visible when you hide the Event app group. | | Apps | Open a working screen such as Leads, POS or Devices. | | Bottom navigation | **Home**, **Inbox**, the centre phone button, **Calendar**, **More**. | The Apps area groups features by purpose: - **BusinessMade:** POS, Tabs, Floor, Reservations, Check-in and Take payment. - **CRM:** Pipelines, Tasks, Contacts, Leads, Support, Live Chat and SMS. - **Event:** Events, Scan, Info, Stats, Badge, Tickets, Schedule, People, Manage and Book. - **System:** Devices. A list and a grid are two presentations of these entries. The captured phone uses the list presentation; follow the labels rather than expecting a particular tile position. ### 2. Find Home apps & groups Tap **More** in the bottom bar. Scroll past Quick Actions, Hardware and Quick Login until you see the **Settings** heading. Tap **Home apps & groups**. ![More scrolled to Home apps & groups](assets/appmint-mobile/14-more-settings.png) *Settings is a heading in More. You do not first open a separate Settings page.* ### 3. Hide the groups unrelated to this role For Jordan’s CRM exercise, turn off the switch beside **BusinessMade**. Scroll down and turn off **Event**. Leave **CRM** and **System** on. The group label changes between **Shown** and **Hidden**. ![Home apps & groups with separate group and app switches](assets/appmint-mobile/15-home-groups.png) *The switch beside the group name hides the entire group. The switches below it control individual apps.* Use the back arrow, then **Home**. Scroll to Apps: CRM and System remain. The separate Events panel can still appear above Apps; hiding a group does not remove every event-related element from Home. ![CRM and System app entries after the layout change](assets/appmint-mobile/18-crm-apps.png) > **This is layout, not permission management.** Hiding Leads does not remove access to lead data. Use company access controls for that. These preferences are stored on the device; do not describe them as a company-wide layout or a guaranteed personal profile that follows you to every handset. ### 4. Reopen the app and check the result Close Appmint Mobile fully and open it again. Allow startup to finish. On the tested build, Jordan remained signed in and the CRM-focused layout returned. ![Home after the app restarted and finished loading](assets/appmint-mobile/27-home-after-restart.png) ![CRM-focused Home after a full restart in the current training build](../application-fixes/assets/mobile-local/22-crm-layout-after-restart.png) *24 September recheck: hiding BusinessMade and Event retained the CRM-focused launcher after restarting. This changes the phone layout, not company permissions.* To restore the default launcher, return to **More → Home apps & groups** and tap **Reset**. Logging out is different from closing the app: logout clears settings from storage in the inspected implementation, so check the layout again after signing back in. **Try it:** hide just **SMS**, return to Home and confirm the entry is gone. Restore it afterwards. **Check yourself:** does hiding SMS disable texting for the company? No; it changes this device’s launcher. ## Part 3 — Find the lead and save a useful preference ### 1. Open Leads, not Contacts On Home, scroll down to **Apps → CRM**, then tap the **Leads** app row. The Leads number near the top is a summary; tapping its label did not open the list in the 24 September check. Keep **All** selected while finding the training record. The filter chips include **New**, **Contacted**, **Qualified**, **Converted** and **Lost**; those refer to the lead’s status. ![Native Leads list showing Maya Bennett](assets/appmint-mobile/20-mobile-leads.png) Find **Maya Bennett** and compare the email and value with Studio Manager. The example is `maya.bennett@example.com`, `$4800`, **NEW**. A matching name alone is not enough when two customers share a name. > **Status is not a pipeline stage.** The phone’s status filters do not reproduce the whole desktop pipeline board. If a lead is missing under Contacted, return to All before deciding it has disappeared. ### 2. Open the existing record Tap Maya’s row. The current screen is **Edit Lead**, with **Save** in the top-right corner. It is an editor, not an activity timeline. ![Native Edit Lead form](assets/appmint-mobile/21-edit-lead-top.png) Read the existing details before typing. The form includes: | Fields | How to use them in this exercise | | --- | --- | | First Name, Last Name, Email | Confirm you opened the correct person. Leave them unchanged. | | Phone, Company, Job Title | Context for the prospect. Do not fill unknown facts with guesses. | | Status and Source | Keep **New** and **Referral**. Source describes where the enquiry came from. | | Temperature and Priority | Keep **Warm** and **Medium** for this practice. | | Deal Value | Keep `4800`; a preference update does not change the deal estimate. | | Notes | Add the customer context you want a colleague to read. | There is no **Add Activity** action on this route in the current build. Older instructions referring to it should not send you looking for a missing button. ### 3. Write the preference in Notes Scroll to **Notes** if necessary. For a blank training record, enter: > Maya prefers linen samples ![The actual note entered before Save](assets/appmint-mobile/23-follow-up-before-save.png) For a real conversation, add a useful next action and date, for example “Maya prefers linen samples. Jordan to prepare the sample pack before Monday’s consultation.” If Notes already contains information, preserve it and append the new context. This field is an editable text block, not a series of automatically dated activity entries. Read the text on screen before saving. During this review, an emulator input attempt produced an empty field; reopening the app resolved it. An empty field followed by Save is not a saved note. ### 4. Save and reopen Tap **Save**. When the Leads list returns, open Maya again. Confirm that **Notes** contains the exact text and that the visible name, email, source and value are still correct. ![The note persisted after reopening Maya’s record](assets/appmint-mobile/25-mobile-notes-readback.png) > **Check other fields at the desk too.** The earlier captured build replaced hidden fields during a Notes save, changing score50 to the display fallback0. The local editor now updates only changed fields; eight Flutter regression checks verify preservation of score, custom fields and concurrent untouched edits. The screenshots below remain historical. In your practice record, compare the saved note and unchanged score after reload; if another field changes, stop and record the before/after result. [Local repair evidence](../application-fixes/mobile-lead-preservation.md). ![Reopened Notes after a real local Android save](../application-fixes/assets/mobile-local/12-notes-reopened.png) *24 September local recheck: Ada Learner opened the existing Maya training record, preserved “Training lead for the mobile course.” and appended the preference. The record uses `maya.bennett@example.invalid`; this is a separate training organisation from Jordan’s earlier captures. Reopening retained Notes and the visible fields. This image verifies the phone result, not a new desktop comparison.* ### 5. Read it back in Studio Manager At the desk, reload Studio Manager. Open **CRM → Leads**, choose the in-page **Lead Manager** tab, then open **Maya Bennett**. In **Overview**, find **Notes**. ![Studio Manager shows the same note after a full reload](assets/appmint-mobile/26-desktop-notes-readback.png) ![Fresh Studio readback of the mobile note, with score50 preserved](../application-fixes/assets/mobile-local/21-desktop-notes-score.png) *24 September: the same Ada Learner organisation and `.invalid` Maya record as the new phone captures. Overview shows the appended note, value4800, source Referral and score50. This confirms the phone-to-desktop handoff for this training record.* You should see **Maya prefers linen samples**, matching the phone. The screenshot also shows **0 Activities**: saving Notes did not create an activity record. **Try it:** explain Maya’s preference to a colleague using only the record, not your memory. **Check yourself:** where should they look—Activities or Overview → Notes? Notes. That is the field edited in this exercise. ## Part 4 — Keep the follow-up moving A preference explains the customer; a task says who should do what next. For Maya, the next action is **Send linen samples to Maya Bennett**. Do not mistake the saved note for a scheduled reminder. Open **Home → CRM → Tasks**, then tap **+**. Enter **Send linen samples to Maya Bennett** in **Title** and **Send the two samples discussed in the consultation** in **Note**. Choose the appropriate **Status** and due date, then tap **Save** once. Return to the task list and reopen the saved task to check its title, note, status and date. A note on a lead alone does not establish a scheduled follow-up. To set the date, tap **Set due date**, choose the day in the calendar, then tap **OK**. The form should display the selected date before you save. Our training attempt used **New** and **25 September 2026**; choose a date appropriate to your own follow-up. ![The new task reopened after saving to the local backend](../application-fixes/assets/mobile-local/18-task-saved-reopened.png) *24 September local acceptance: the saved title, note, New status and 2026-09-25 date survived reopening and a full app restart. The earlier `/repository/create` error was a backend route-registration defect and is now fixed locally. [Repair and verification](../application-fixes/repository-create-route.md).* The list summarises the date as **Due today**, **Due tomorrow** or an overdue label. Reopen the record to check its exact date. In the corrected local build, the 25 September task shows **Due tomorrow** on 24 September. ![Saved task with its corrected relative due date](../application-fixes/assets/mobile-local/20-task-due-tomorrow.png) If the save fails, keep the form open, read the error and correct the cause before retrying. A success message alone is not your final check: reopen the record. ![Historical task-creation failure before the local fix](assets/appmint-mobile/30-task-save-result.png) *This capture predates the 19 September repair. The old build rejected new tasks with “Item must have sk field.” The corrected local application uses the create endpoint and its server-generated identifier. Four local Flutter regressions cover creation/readback, existing-task updates, failed-save recovery and empty-title validation. [Repair and test evidence](../application-fixes/mobile-task-creation.md). Native creation and after-restart readback passed on 24 September after the backend route repair.* ![The native Calendar tab shows a sample month](assets/appmint-mobile/31-calendar-placeholder.png) The bottom **Calendar** currently renders a static sample month in the tested build. It does not load the CRM task list. Check due dates in the task record and your team’s working calendar rather than relying on that tab for reminders. **Try it:** turn “send samples” into a task title another colleague can recognise without opening it: **Send linen samples to Maya Bennett**. **Check yourself:** does a note saved on the lead establish an owner, reminder or due date? No. Arrange those explicitly in the task workflow. ## Part 5 — Choose the next mobile workflow ### Use the company phone line The centre phone button opens calling. A CRM login does not itself give you a business number. Follow [Employee phones and mobile CRM](appmint-mobile-employee-phones-and-crm.md) for company calling setup, number assignment, handset registration, incoming-call checks and SMS. ### Run a BusinessMade shift Show the **BusinessMade** group again. The location selector matters for venue-specific POS, Tabs and Floor work. Use [Run a busy service](businessmade-run-a-busy-service.md), [Multiple locations](businessmade-multiple-locations.md) and [Device Hub](businessmade-device-hub.md) for that workflow. Pairing a printer, configuring a kitchen route and opening a POS tab are different tasks; a visible Devices entry does not complete them. For shared counters, **More** has **My quick-login passcode** and **Access cards (admin)** under Quick Login. The employee must be linked correctly and the company must permit quick sign-in. Read [Staff self-service](businessmade-staff-self-service.md) before treating an employee ID as a sign-in credential. Cards must be registered; typing a staff number alone is not card authentication. ### Work at an event Show **Event** to reach management, tickets and scanning. Staff use Appmint Mobile’s event tools; attendees use EventOxygen. Continue with [Run an event](appmint-run-an-event.md) and [Attend with EventOxygen](eventoxygen-attend-and-connect.md). A camera permission is only the start of scanning: the event, ticket and entry rules still need checking. ## Part 6 — Finish the day and sign out Tap **More**, scroll to the bottom and select **Logout**. Closing the app preserves a session; Logout deliberately ends the current one. ![Logout at the bottom of More](assets/appmint-mobile/32-more-logout.png) The tested app returned to Login with a **Tap to sign in** area and Jordan’s remembered-account tile. **A remembered tile can sign you back in without asking for the password while its saved session remains valid.** This was verified in the current local build. For a shared handset, tap **Forget this account** after Logout and confirm the tile disappears before handing over the device. Forgetting the tile removes that saved account from this device; it does not delete your company account. ![Login after signing out, with the remembered account](assets/appmint-mobile/33-logout-result.png) ![Current local build after Logout, showing Login and the remembered account](../application-fixes/assets/mobile-local/23-logout-confirmed.png) *24 September: Logout returned Ada to Login. The follow-up check confirmed the saved tile can reopen Home. Use Forget this account on a shared device as described above.* Check your Home layout and calling availability when you next sign in. Logout clears local settings in the inspected implementation; do not assume it behaves like an ordinary restart. The current local check verified both the tile’s return to Home and Forget this account removing it. A fresh email/password sign-in then exercised the authenticator challenge. **Try it:** decide which action suits your situation: close the app briefly between visits, or sign out before handing a shared device to someone else. **Check yourself:** does the saved account tile mean the last customer’s page is still open? No—the app has returned to Login. ## If something goes wrong | What you see | What to check and do next | | --- | --- | | Android download returns 403 | This is a download-host response. Check that you used the Android link above, whose path contains `android/latest/`, not the obsolete `android-latest/`. If that exact current link fails, report the URL and response to Appmint support. Changing Android install permissions cannot repair a server download error. | | No account found for the email | Check spelling, staff-account status and whether the invitation was accepted. Do not create a new company to join an existing one. | | Old build asks for Site Name | Enter the organisation ID supplied by the company, not the page slug or display name. | | Wrong company’s records | Sign out and sign in with your company’s account; choose the correct organisation if the current app offers a choice. | | Missing Home app | Check both its group switch and its individual switch in Home apps & groups. Then check access with the administrator if opening it fails. | | Lead missing | Select All, refresh the list, and compare the email and organisation with the desktop record. | | Notes empty after typing | Confirm the text actually appears before Save. Reopen the app if text entry is stuck. Do not overwrite existing notes with an empty field. | | Score or other metadata changes after Save | Stop mobile edits on live leads; keep the before/after record details and use Studio Manager while the issue is investigated. | | New task does not save | Preserve the task text, use the company’s working task route and verify the resulting record. A failed form is not a saved task. | | Calendar shows a sample month | Use the actual task and reservation screens; the inspected Calendar tab is a placeholder. | | Phone has no assigned number | Follow the employee phone course. Creating a CRM account does not assign a business line. | For help, use **Support → Documentation** or **Support → Submit Ticket** in Studio Manager. For a defect, **Report a Bug** is a separate toolbar action. Include the mobile build, device type, screen, steps and the result you expected. Never include a password or access token in a screenshot. ## What happened behind the scenes
How the mobile and desktop records meet The current mobile login uses an email directory lookup before password authentication. Its authenticated API requests carry the organisation and staff session. Leads uses the organisation’s generic `lead` records, which is why a record created in Studio Manager can appear on the phone. The active Leads route opens `LeadFormScreen`. For existing records it patches only changed fields under their data paths, preserving untouched metadata. It does not call an activity-creation endpoint. The local handoff check confirmed Notes and score50 in Studio after the phone edit. Home visibility uses local `home_hidden_groups` and `home_hidden_items` settings. Those keys contain no user identifier. Logout clears local settings except specific remembered-account values; a layout surviving an ordinary restart does not establish that it will survive logout or travel to another phone. Source checked under `/Users/imzee/projects/appmint_go/appmint_mobile/`: `lib/screens/auth/login_screen.dart`, `lib/services/api_service.dart`, `lib/providers/auth_provider.dart`, `lib/config/home_apps.dart`, `lib/screens/dashboard/dashboard_screen.dart`, `lib/screens/more/more_screen.dart`, `lib/screens/settings/home_layout_screen.dart`, `lib/providers/app_visibility_provider.dart`, `lib/services/storage_service.dart`, `lib/screens/leads/leads_screen.dart`, `lib/screens/leads/lead_form_screen.dart`, `lib/providers/data_provider.dart`, `lib/services/repository_service.dart`, `lib/screens/tasks/task_form_screen.dart`, `lib/screens/calendar/calendar_screen.dart`.
## Where next - [Give employees their business line in Appmint Mobile](appmint-mobile-employee-phones-and-crm.md). - [Run customer conversations from the desk](appmint-run-customer-conversations.md). - [Roles, groups and permissions](appmint-roles-groups-and-permissions.md). - [Companion-video production guide](production/appmint-mobile-welcome-and-daily-work.md). **Evidence — 18 September 2026:** Native Android current-source release compiled in an isolated copy; local API and Studio Manager0.6.2; local tutorial organisation `learnmu4qn1ha`, Jordan Morgan. A local Maya Bennett training copy was created through Studio Manager for this exercise, distinct from the production record used in the enquiry course. Verified: email/password sign-in, Home/More navigation, hiding groups, session/layout persistence after full restart, logout and remembered-account display, fetching the desktop lead, saving Notes, reopening on Android and reading the same note in Studio after reload. Observed score50→0 on mobile save. The earlier obsolete APK URL returned403. A later fresh check of the canonical `/android/latest/` package 1.0.2+9 verified download, hash, installation and first launch; it did not submit an account. The earlier sign-in attempt involved a truncated organisation entry and does not establish an authentication defect. Not verified: iPhone installation/native UI, public Android account sign-in, challenged sign-in, magic code, quick sign-in/card login, calls, SMS, per-user access restrictions. Real screenshots and screen text are in [capture evidence](assets/appmint-mobile/evidence.json). Production guide records the remaining capture work; no finished companion video is claimed. **Local completion — 24 September 2026:** Ada Learner’s rehearsal verified native password sign-in, authenticator challenge with a real code, Home layout/restart, lead Notes save/restart/desktop score50 readback, task creation/restart/date display, Logout, remembered-session entry and Forget this account, and email reset followed by new-password sign-in. The original 18 September screenshots above remain historical where labelled. iPhone, public release, phone/SMS and finished-video production are outside this local welcome-course acceptance. [Full continuation evidence](../learner-review-records/mobile-continuation-2026-09-24.md). --- # Put the business line on an employee’s phone—and keep the customer context > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-mobile/appmint-mobile-employee-phones-and-crm.html # Put the business line on an employee’s phone—and keep the customer context ![The three voice setup jobs, with SMS as a separate fourth job](assets/appmint-phones/phone-readiness.svg) *Company calling, number assignment and handset registration solve different problems. Work through them in that order.* **Who this is for:** an owner setting up the company line and a colleague using it in Appmint Mobile. **Time:** allow 45–60 minutes for the first configured-line rehearsal, after staff access and the company’s provider-owned line are ready. Shared-line, SMS, background/locked-handset and offboarding tests are separate exercises; provider review has its own timing. **Level:** beginner setup, followed by shared-line, SMS and offboarding practice. **Products:** Studio Manager and Appmint Mobile. **Checked:** setup and native phone captures from 18 September 2026; prerequisite evidence and local assignment reads reviewed 24 September. The phone captures show an unconfigured account, not a working business line. > **Capture status:** the setup forms, Android permission prompts, native phone tabs and no-number state were opened. No service terms were accepted, number provisioned, assignment saved, call placed or SMS sent during this review. Provider-dependent sections below are an implementation-checked rehearsal, with the exact result you must verify in your company. They are not a claim that the pictured training account can make calls. ## What you will have at the end You will know how to prepare company calling, distinguish adding a number from assigning it, register the employee’s handset and test the line in both directions. You will also know how to go offline, remove a colleague’s access without releasing the company number, and turn a conversation into useful CRM context. The practical finish is this: the employee sees the intended number under **From**, an agreed test phone receives a call from that number, and the business line rings on the employee’s phone. A green status indicator on its own is not that finish. Appmint, Appmint Mobile and the softphone are free. ## What you need - Appmint Mobile installed and a working staff sign-in. Complete [Set up Appmint Mobile and take your CRM with you](appmint-mobile-welcome-and-daily-work.md) first, including its current download/build notes. - An owner or administrator who can open **CRM → Phone & SMS** in Studio Manager. - An active colleague account for the person receiving the line. Use [Roles, groups and permissions](appmint-roles-groups-and-permissions.md); a group named Sales or an unsent invitation does not establish that the colleague can sign in. That course now verifies invitation acceptance and separate restricted staff sessions; check the actual colleague’s access before continuing. Its LeadEditor example grants lead work, not proof of phone-provider registration or access to Phone Management. - A business number the company actually controls in the configured provider account, or an administrator ready to obtain one through the number setup flow. - A second phone controlled by someone taking part in your test. Agree the test before calling. - A training lead for the CRM exercise: Maya Bennett, Referral, value4800. The mobile welcome course verifies a Notes save and Studio readback with score50 preserved after the local repair. Keep this practice on training data. Do not enter the example555 numbers from older topic drafts as usable lines. Replace every test destination with a number you control. Adding an employee’s personal mobile number to the company’s number list does not forward calls to their handset. ## Before starting the line setup Complete these checks in order. Keep the owner and employee signed in separately. 1. **Employee identity:** complete the invitation, then have the colleague sign in to Studio under their own email. Check the company and their actual allowed CRM screen. The roles course records this with Nora Ellis and separately with Maya Ito; an owner’s Home screen is not the employee check. 2. **Mobile identity:** complete the mobile welcome course with that colleague’s account. Compare the company and displayed name before opening Phone. The existing Android proof uses the review owner; it does not establish Maya’s handset registration. 3. **Company line:** the administrator must have a number already controlled by the company in its configured provider account. If none exists, stop the calling exercise at the setup screen. You can still learn the interface and complete the independent CRM Notes exercise. 4. **Test partner:** agree which controlled second phone will receive and make the test calls. Record the line, devices and app versions used so the subsequent results can be compared. The 24 September local read returned zero company phone records and zero owner-assigned lines. No placeholder line was created to make the screens appear ready. Successful company setup, employee assignment and calls remain the acceptance work described below. ## The story Cedar & Form wants customers to call the business, even when the team is visiting clients. Maya Ito is the salesperson; Maya Bennett is a customer with an interior-design enquiry. The customer should dial the business line. The employee answers through Appmint Mobile on her own phone. Jordan, the owner, does the company setup. Maya signs in, checks the line assigned to her and practices one call each way before taking customer calls. Afterwards she records the customer’s preference on the lead, rather than relying on the call log to explain what was agreed. The captured native handset belongs to Jordan’s local training account. Maya’s assignment and calls still require the separately prepared colleague account and controlled test numbers. ## The route **Admin: enable company calling → add the owned number → assign the user or group.** **Employee: sign in → grant permissions → check From → test outgoing and incoming → record the customer context.** ## Part 1 — Administrator: prepare the company line ### 1. Open the number-management screen In Studio Manager expand **CRM**, then select **Phone & SMS**. The page title is **Phone Management**. Stay on **Numbers** for setup; **Call Logs** and **Messages** are separate tabs. ![Phone Management before any number or service is configured](assets/appmint-phones/01-phone-management.png) Read the screen in three parts: | Area | What it answers | | --- | --- | | Total Phones, Active, Pending Setup, SMS Registered | Which number records and setup states exist? | | Phone service or Outbound calling status | Is the company’s calling service ready? | | Numbers table and each row’s actions | Which line is this, and who may use it? | In the training organisation, all four counters were zero and **Phone service** showed **Not set up**. That explains why installing the mobile app alone was not enough. ### 2. Review Set up calling If the service is not set up, select **Set up calling**. Read **Accept phone service terms & turn on calling**. This action provisions the organisation’s provider account and prepares outbound calling; it is not a handset permission dialog. ![The actual company service-setup confirmation](assets/appmint-phones/02-phone-service-terms.png) The company’s authorised administrator decides whether to proceed with **Accept & turn on calling**. After completing the company’s setup, return to Numbers and check for **On**. Reload the page and check again. If the action returns an error, keep the error text and address company readiness before trying handset calls repeatedly. If the company already uses its own provider configuration, verify that configuration and the same readiness status. The objective is a ready service, not blindly repeating account provisioning. > **Important distinction:** this course opened the dialog and cancelled it. The screenshot does not show an enabled service. Your completion check is the persisted **On** status after your administrator completes the setup. ### 3. Add a number the company owns Select **Add Phone**. In **Add Existing Phone**, enter the existing provider-owned number in **Phone Number**, using international form beginning with `+` and its country code. In **Friendly Name**, use a label such as `Maya direct` so colleagues recognise its purpose. ![Add Existing Phone with its required number and optional label](assets/appmint-phones/03-add-existing-phone.png) Leave **Set as default phone** off for an employee’s direct line unless it is also intended to become the company default. Select **Add Phone** when the details identify the company’s actual number. Back on Numbers, verify both the number and label, then reload to confirm the row remains. If the company has no provider-owned number, the screen offers **Buy Phone** to obtain a number. That is a separate provider action; use the company’s normal authorisation and selection process. Do not invent a number or use a personal handset number to get past the empty state. > **A phone record is not a ringing handset.** Adding the number makes it manageable by the company. The employee still needs an assignment, and the mobile app still needs registration and permissions. ### 4. Assign the line to the colleague On the intended number’s row, open its action menu and choose **Assign to User/Group**. The drawer has **Users** and **Groups** tabs, a search box and a **Save Assignments** button. For an individual line: 1. Stay on **Users**. 2. Search for the colleague and compare the email, not just the display name. 3. Select **Maya Ito** only when it is her real active staff account. 4. Check the selected count. For this direct-line exercise, leave Groups unselected. 5. Select **Save Assignments**. 6. Look for **Assignment updated**, then reload Numbers and reopen the assignment drawer. Maya must still be selected. These drawer labels are verified in the current implementation. A saved assignment and its readback have not yet been captured on the training account because it has no provider-owned number. > **Do not substitute a similarly named customer.** The assignment is to a staff user or staff group. Maya Bennett’s customer record is not the salesperson’s phone identity. ### 5. Hand over a clear result Tell the employee the line’s friendly name and full number. Ask them to open Phone in Appmint Mobile and confirm that number under **From**. This checks the result from the employee’s perspective; an administrator seeing a row is only half of the handoff. **Try it:** describe what each completed step achieved: company service, owned number, employee assignment. **Check yourself:** which step makes the number appear in the colleague’s From list? The assignment, subject to the employee’s signed-in account and current registration. ## Part 2 — Employee: prepare the handset ### 1. Sign in as yourself Open Appmint Mobile and use your own staff login for the company. The current build starts with **Email Address → Continue with Password**. Follow the mobile welcome course if you have an older Site Name screen or a verification challenge. The app attempts to register the handset for voice after sign-in. That background attempt does not assign a number or prove that calls can be placed. ### 2. Open Phone and understand the screen Tap the round phone control in the centre of the bottom navigation. The screen contains **Keypad**, **Recent** and **Contacts**, a registration/status indicator, the power control and the assigned-line area. ![Native Phone without an assigned company number](assets/appmint-phones/12-native-no-number.png) The training account shows **No phone assigned to your account**, followed by instructions to ask an administrator to assign a number to the user or team group. If your screen matches this, go back to the administrator’s assignment check. Typing a destination into the keypad cannot create the missing From line. When an assignment is ready, check the full number shown under **From**. With more than one assigned line, use the selector to choose the intended one. Friendly labels may help, but compare the number as well. ### 3. Android: allow the permissions the app requests On the tested Android version, opening Phone requested microphone access first. Select **While using the app** to allow the app to use the microphone during calls. ![Android microphone permission prompt](assets/appmint-phones/10-android-microphone.png) The next visible prompt asked whether Appmint Mobile could **make and manage phone calls**. Select **Allow** for the company-calling workflow. ![Android make-and-manage-calls permission prompt](assets/appmint-phones/11-android-phone-permission.png) Android versions can group permissions differently. The app also checks phone-number, phone-state, call-management and notification capabilities. Follow the permission named on your screen rather than expecting five separate dialogs in a fixed order. If the Phone screen reports **Phone account not registered with Android. Reopen the Phone screen to retry**, return to Home and reopen Phone. If it offers **Open Calling Accounts**, tap that button, enable **Appmint Mobile** in Android’s calling-account settings, then return to the app. If you previously denied a required permission, open Android’s app settings for Appmint Mobile and enable the specific permission named in the error. Reopen Phone and check again. Microphone permission fixes microphone access; it does not fix an unassigned business number. > **Capture boundary:** microphone and phone prompts were exercised. Android calling-account enablement and ringing still need a real configured-line test. Do not interpret the permission screenshots as that test. ### 4. iPhone: prepare the native calling path Use an iPhone for the iOS calling test. Grant microphone access when the app requests it. Test receiving a call with Appmint Mobile open, in the background and with the iPhone locked; incoming calls should use the native calling interface when the deployment’s voice/push setup is working. Those iPhone states are not shown in this manuscript yet. Do not use an Android screenshot as a substitute for what your iPhone displays. The inspected mobile implementation deliberately skips the native voice binding on iPad. A backend registration indicator on an iPad therefore does not establish an iPhone-style incoming-call experience. Use an iPhone for this course’s iOS voice workflow. **Try it:** read the line, permission and company-service states before dialling. **Check yourself:** the microphone is allowed but the app says No phone assigned. Is the microphone setting the next thing to change? No—ask the administrator to verify the user/group assignment. ## Part 3 — Test the line in both directions Do this with two participating people and controlled phone numbers. The following is the acceptance exercise for a configured company, not a description of a call already made in the training captures. ### 1. Make one outbound call On **Keypad**, check **From** once more. Enter the second test phone’s full international number. Press the call control and have the other person answer. Confirm three things aloud: 1. The receiving phone shows the expected business caller ID. 2. Both people can hear each other. 3. The app reaches a connected state rather than remaining at dialling. Use the in-call mute and speaker controls to check audio behaviour, then end the call. Open **Recent** and compare the destination and time with the call you just made. Do not identify success from an animated timer alone; the other person must actually receive audio. ### 2. Receive a call to the business line From the second phone, call the business number assigned to the employee. Answer in Appmint Mobile and check two-way audio. Repeat the test with Appmint Mobile in the background, then with the handset locked. Record which states ring and which do not. If foreground works but the locked phone does not ring, investigate native account/push/background setup rather than assigning another number at random. ### 3. Review the call record On the handset, open **Recent**. Use the time, direction and other party to identify your test. In Studio Manager, **Phone & SMS → Call Logs** is the corresponding administrator surface. > **Record ownership:** an earlier unconfigured organisation displayed unrelated provider rows; those captures were excluded. The local account-isolation repair now refuses the missing tenant account before provider access, with 19 passing regressions. A controlled real call-to-log comparison is still required here. If you see records you do not recognise, stop using them for customer follow-up and report the organisation, tab and observation time. Do not use another company’s rows as training evidence. ### 4. Record what matters on the customer lead A call log can establish that a call happened. It does not explain that Maya prefers linen or who will send samples. Open **Home → CRM → Leads → Maya Bennett**. The current route is **Edit Lead**. In a training copy, preserve existing **Notes** and add a concise outcome, such as: > Maya prefers linen samples. Jordan to prepare the sample pack before the consultation. Tap **Save**, reopen the lead, then read the same Notes in Studio Manager. The mobile welcome course contains the [actual native and desktop readback](appmint-mobile-welcome-and-daily-work.md). The repaired local build preserved score50, source and value during that native save and fresh desktop readback. Verify the same fields in your practice record; this CRM result does not depend on a completed phone call. Do not look for the older topic’s **Add Activity** button on the current lead editor. Notes, activities and call logs are separate records or surfaces. **Try it:** a colleague should be able to tell what to do next by reading the note. **Check yourself:** does a call-log row automatically create a follow-up task? No. Assign the task through the company’s working task workflow and verify it was saved. ## Part 4 — Shared lines, SMS and end-of-shift control ### Share one line with a team An administrator opens **Assign to User/Group** on the main line, selects **Groups**, chooses the intended team and presses **Save Assignments**. Reload and reopen to check the selection. Then have an actual group member reopen Phone and check **From**. Use a group whose membership and effective access have been tested. The permissions course now verifies repaired custom roles, saved group membership and separate staff-session access. A Sales card by itself is still not proof that this colleague receives a particular phone number. Confirm the user-side result before directing customers to the line. If the same number is assigned directly and through a group, removing the direct assignment can leave group access intact. Review both when removing someone. ### Prepare SMS separately Voice capability, an assigned line and SMS readiness are separate. On the number row, **Register for SMS** opens **SMS Registration (A2P 10DLC)** in the inspected implementation. For an eligible number, the form groups the information into: | Form section | What to prepare | | --- | --- | | Business information | The legal business name, type, industry, relevant tax identifier, contact email and working business website. | | Business address | Street, city, region, postal code and two-letter country code. | | Authorized representative | A real responsible person’s name, email, phone, position and business title. | | Campaign | The actual messaging use case, description, opt-in/message flow and two representative sample messages. | | Keywords & auto-replies | The organisation’s opt-in, opt-out and help words and replies. | If a business brand is already registered, the form can reuse it and omit some initial business fields. Enter facts about the company and the process it actually operates. For example, describe a booking-form opt-in only if that opt-in really exists; sample text in the form is not evidence that your website collects it. The final action is **Submit A2P Registration**. A submitted request is not the same as provider approval. Check the returned status before scheduling customer texts. Different number types and provider arrangements may require a different verification path; follow the requirements shown for your configured number rather than treating this form as universal. The number configuration distinguishes **System number (calls)** from the SMS default. Choosing the main voice line does not automatically make it an approved text sender. Set the intended SMS sender only after checking its capability and readiness, then test with an agreed recipient before relying on reminders. No registration or SMS delivery was exercised in this review. Keep that test separate from a successful voice call. ### Go offline at the end of the shift In Phone, the power icon’s accessible label is **Go offline (stop receiving calls)** while online. Tap it to choose offline. The control changes to **Go online**. ![Native phone after selecting offline](assets/appmint-phones/15-native-offline.png) This is an explicit availability choice. The tested Android build preserved that choice after the app was fully closed and restarted. Reopen Phone after restarting and check the control before assuming the shift has resumed. Tap **Go online** when you are ready to register again. ![Phone still offline after restarting](assets/appmint-phones/16-offline-after-restart.png) Choosing offline does not remove the employee’s assignment. It is a handset availability control, not an administrator’s offboarding action. ### Remove a colleague from the line without losing the number The administrator reopens the number’s **Assign to User/Group** drawer, clears the colleague’s direct selection and saves. If access also comes through a group, remove the relevant group access or membership as appropriate. Reload and reopen the assignment drawer to confirm both routes are removed. Older configurations can also grant a line through the employee record’s `phones` list or a group record’s `sharedPhones` list. The number’s assignment drawer does not clear those separate entries. If the line remains available after the visible assignments are removed, have your organization administrator check those records and remove only the departing employee’s intended access; preserve the routes used by other staff. Then perform the following checks; do not close the exercise merely because Save succeeded. 1. While the removed colleague’s existing handset session is still present, refresh or reopen Phone. Confirm the removed number is absent from **From**. 2. From the agreed test phone, call the company line. Confirm the removed handset no longer receives the call, including its background/locked state. An existing voice registration must not keep ringing after the intended removal. 3. Have a retained authorised colleague check that the same company line remains available and still receives its test call. Removing one person must not release the number or disable everyone. 4. Complete the company’s account offboarding and sign out on the removed handset. On a shared device, also use **Forget this account** so a remembered tile does not restore its saved session. Check a fresh sign-in according to the intended account policy. These phone-specific revocation checks have not yet been exercised with a configured line. General account Lock/Unlock tests do not prove provider-token revocation or termination of an ongoing call. If the removed handset still sees or receives the line, record that failed result and keep the offboarding task open. **Release Number** is different: it returns the number to the carrier. Do not use it merely because one employee has left. The company may still need that line for other staff and customers. **Delete account** in the mobile app is also different from Logout. Use normal sign-out to leave a shared handset; permanent account deletion is not a routine shift-ending action. **Try it:** list every path by which one employee receives the main line—direct assignment and each relevant group. **Check yourself:** is removing only the direct assignment enough if Sales still grants the line? No. ## If something goes wrong | Symptom | Check in this order | | --- | --- | | No phone assigned | Staff account and company → number row → direct/group assignment → reopen Phone. | | Assignment saved but line absent | Reopen the assignment drawer after reload; verify the colleague’s actual group membership; refresh the handset registration. | | Calls fail immediately | Company calling status → assigned From line → handset registration and permissions → exact returned error. | | Microphone denied | Enable microphone for Appmint Mobile in device settings, then reopen Phone. | | Android calling-account warning | Reopen Phone; use **Open Calling Accounts** if offered; enable Appmint Mobile. | | App-open calls work but locked-phone calls do not | Native incoming-call/push/background configuration; repeat the controlled test in each state. | | Phone does not ring after restarting | Check whether the power control says **Go online**; explicit offline mode may still be active. | | iPad shows registered but does not behave like an iPhone | The inspected implementation skips native voice binding on iPad; test on iPhone. | | Voice works but SMS fails | Number’s SMS capability → registration/approval status → correct SMS default → recipient format. | | Desktop logs show unrelated records | Stop relying on those rows; report the scoping issue with company and screen details, without sharing other people’s messages. | | CRM score or metadata changes after a note save | The local Notes-only update is repaired. Compare your practice record’s before/after fields, retain the app version and report any reproduced loss before editing live records. | For help, **Support → Documentation** and **Support → Submit Ticket** are in Studio Manager’s toolbar. Use the separate **Report a Bug** action for reproducible defects. Include the handset model, app build, whether the app was foreground/background/locked, the selected line and the exact error. Keep credentials and unrelated customer records out of attachments. ## What happened behind the scenes
Number assignment, registration and call routing The phone-management assignment drawer writes `assignedUsers` and `assignedGroups` on the phone record. The handset asks for its available numbers through `/phone/user-phones`; server resolution includes direct assignments, group assignments and supported back-references. That resolved list supplies the From selector. A remembered phone number in a text field is not equivalent to a server-authorised caller ID. Mobile sign-in attempts a voice-device registration. The current service has a stable device identity, periodic heartbeat, explicit unregister and a persisted offline preference. Native voice binding and incoming push are additional parts of the handset path; backend registration alone does not prove that the locked handset can receive calls. The implementation deliberately skips the iPad native binding. The lead edit is independent of telephony. It updates the lead’s Notes through the generic repository path. Call logs do not automatically become those notes. Source checked: `websitemint/packages/ui/src/components/phone/phone-management.tsx`, `phone/a2p-registration-form.tsx`, `common/assign-to-drawer.tsx`; `appengine/src/phone/phone.controller.ts`, `phone.service.ts`; `appmint_go/appmint_mobile/lib/screens/phone/phone_screen.dart`, `lib/services/softphone_service.dart`, `lib/providers/auth_provider.dart`, `lib/providers/communications_provider.dart`, `lib/screens/leads/lead_form_screen.dart`.
## Where next - [Run customer conversations](appmint-run-customer-conversations.md) for the desk-side inbox and support workflow. - [Roles, groups and permissions](appmint-roles-groups-and-permissions.md) for the access behind shared lines. - [Automate a business handoff](appmint-automate-a-business-handoff.md) for the next operational action. - [Companion-video guide](production/appmint-mobile-employee-phones-and-crm.md). **Evidence —18 September2026:** Production organisation `learnmu4zs1td`, Studio0.6.1: opened Phone Management, service terms and Add Existing Phone; cancelled both forms. No company service or number was provisioned. Call Logs/Messages unexpectedly displayed older provider records despite zero training numbers; raw row screenshots and text were removed from public course assets. Native current-source local Android release, organisation `learnmu4qn1ha`, Jordan: opened Phone; allowed microphone and phone permissions; observed no assigned number; inspected Recent/Contacts; selected offline and verified its persistence after restart. No physical-handset or iPhone call, assignment save, SMS registration/delivery, provider approval or call-log isolation success is claimed. The provider-dependent steps are source-checked and await the controlled end-to-end test in the production guide. See [screen evidence](assets/appmint-phones/evidence.json). --- # Attend a conference with your ticket, programme and connections in EventOxygen > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-eventoxygen/eventoxygen-attend-and-connect.html # Attend a conference with your ticket, programme and connections in EventOxygen ![Lina’s admission ticket in EventOxygen](assets/eventoxygen/40-lina-ticket.png) *A registration you can find again: the event, ticket type, holder and admission code are together on your phone.* > **Who this is for:** attendees, speakers and event teams explaining their attendee app. **Time:** 35 minutes for the main walkthrough once the organizer has supplied the app and practice event; allow another 15 minutes with a practice partner. Organizer/developer setup time is additional. **Level:** beginner, with an organizer/developer appendix. **Product:** EventOxygen. **Build checked:** separately configured current-source Android debug build, 24 September 2026. The iPhone download route is included; the captures are Android, not iOS. ## What you will have at the end With the prepared app, event and partner listed below, you will create an attendee account, register for one free ticket, find it after reopening the app, read the programme, connect with another attendee and find a message in your Inbox. You will also distinguish a ticket QR from a profile QR, save a useful post and understand the current limits of meetings and ticket assignment. The rehearsal follows a real record across two apps: Lina registers in EventOxygen, staff admit her in Appmint Mobile, and her EventOxygen ticket changes to **CHECKED IN**. No card payment is needed for this exercise. EventOxygen and Appmint Mobile are free. ## What you need - The app your organizer provides, and an email address you can use for both your attendee account and ticket registration. - A published event with available tickets. This lesson uses **Tutorial Makers Conference 2026**, prepared in [the organizer course](appmint-run-an-event.md): November 14–15, 2026, **America/Chicago**, Tutorial Makers Hall, Chicago. - For the networking exercise, another attendee who can accept your request. Our fictional partner is Kofi Tutorial. - For an admission rehearsal, staff signed into Appmint Mobile. Attendees do not need access to Studio Manager or a staff account. - For Part 5, permission from your organizer to publish a fictional practice question in the displayed audience. You will create and save your own post; no existing example post is required. **Organizer preparation before the timed walkthrough:** give learners the installable app or listing and app version, the exact published event title, a free ticket type, the session date/time/timezone and room, a practice partner who appears in **People**, and an organizer contact route outside the app. Confirm these in the attendee app with a fresh account. The separate tutorial build and event shown here are not distributed by this lesson. When using your own event, substitute those supplied details for Tutorial Makers Conference, General Admission, the joinery talk and Kofi throughout. **Independent review limit:** on 18 September 2026, the corrected stock Android package installed and a fresh account remained signed in after reopening, but **Browse Events** still showed **No events yet — Check back soon**. If that happens, the ticket exercises are blocked until the organizer supplies access to a published event in the installed app. Creating another attendee account does not supply that event. **Which app carries your event?** The stock production EventOxygen configuration points at the `eventos` organization. Your organizer’s separately configured build can show their own Appmint organization. Installing the stock app does not connect it to any organization ID you happen to own. The captures here use a clearly separate **EventOxygen Tutorial** build connected to our training organization. ## The story Lina is attending a talk on 3D-printed joinery. She wants to arrive with the right ticket, know which room to use and meet Kofi afterward to compare materials. The app should help her prepare before the day, get through registration and keep the useful conversation afterward. The current rehearsal uses `lina.eventoxygen.20260924@example.invalid` and `kofi.eventoxygen.20260924@example.invalid`; older captures use `lina.events@example.invalid` and `kofi.community@example.invalid`. They are fictional, non-deliverable training addresses. Use your own actual address when attending a real event; receiving organizer messages may depend on it. ## The route ```mermaid flowchart LR A[Create attendee account] --> B[Browse the event] B --> C[Register for a ticket] C --> D[My Events + My ticket] D --> E[Staff admission] B --> F[Read the programme] F --> G[People: request + acceptance] G --> H[Inbox: continue the conversation] ``` ## Part 1 — Install the right app and create your account ### 1. Get the organizer’s app On iPhone, use the [EventOxygen App Store link](https://apps.apple.com/us/app/eventoxygen/id6770200026) linked by [Appmint’s EventOxygen section](https://appmint.io/). Check the app name and organizer’s instructions before installing. If the organizer supplies their own branded app, use that specific listing. On Android, the published direct download is [EventOxygen APK](https://web.appmint.space/apps/event_app/android/latest/event_app.apk). When a valid APK downloads, open it and follow Android’s installer. If Android asks whether the browser may install apps, allow that source only for the organizer-provided package you intended to install. > **Check the app before continuing:** this corrected public APK was downloaded and installed on a fresh Android emulator during the independent review. The stock app uses its configured organiser; installing it does not connect it to a different Appmint organisation or supply the training event in these screenshots. Confirm the organiser and event before attempting the ticket exercises. If you receive an XML error instead of an APK, check the link above contains `android/latest/`; do not rename the error file to `.apk`. The development appendix explains the separate training build used to continue this course. An attendee should receive an installable app, not have to compile it. ### 2. Open My Account Open the app. The signed-out screen is headed **Events**, with **My Account** at the top right. You can browse the published event before signing in. ![Signed-out event list with My Account](assets/eventoxygen/02-browse-signed-out.png) Tap **My Account**. The sign-in screen has **Email Address**, **Password**, **Sign In**, **Forgot Password?**, and **Don’t have an account? Sign Up**. ![Attendee sign-in screen](assets/eventoxygen/03-attendee-signin.png) If you already have an attendee account for this organizer, enter that email and password and choose **Sign In**. Your staff Studio credentials are a different identity; do not assume they are an attendee account. ### 3. Enter your attendee details Choose **Sign Up**. Fill: | Field | Example | Why you enter it | | --- | --- | --- | | **First Name** | `Lina` | The name other attendees recognize. | | **Last Name** | `Tutorial` | Completes the displayed name. | | **Email** | Your own email | Tickets are matched to their holder email. | | **Password** | A unique password you keep privately | Signs you back into this organizer’s attendee account. | | **Confirm Password** | The same password | Catches a typing mistake before submission. | The checked form’s minimum validator is six characters; use a longer unique password. Keep it hidden when sharing screenshots. Read the linked **Terms of Service** and **Privacy Policy**, then choose the **Create Account** button beneath the fields. The corrected local build opens **Privacy Policy** at `appmint.io/privacy-policy`, the destination published in EventOxygen’s official App Store listing. To revisit it after signing in, open **My Events → your account initials → Privacy Policy**. The native Android check opened a readable page over a secure connection. ![Actual Android browser after opening EventOxygen’s corrected Privacy Policy link.](../application-fixes/assets/mobile-local/eventoxygen-native-privacy-fixed.png) > **Terms link still needs correction:** the current **Terms of Service** address, `eventos.app/terms`, fails certificate validation. The intended replacement is awaiting the owner’s confirmation. Request the intended attendee terms through the organizer’s contact route; do not bypass the browser warning or treat another site’s terms as automatically applicable. ![Attendee signup fields](assets/eventoxygen/04-create-account-form.png) ![Completed fictional account with password fields obscured](assets/eventoxygen/05-account-details.png) ### 4. Reach your signed-in home After **Create Account** succeeds, the corrected Android build opens **My Events** directly. A fresh attendee has **No events yet** until they register for a ticket; choose **Browse Events** to find the organizer’s published event. This direct navigation was verified with a second fresh account on 24 September. Older builds can leave the previous Sign In screen on top: press Android **Back** once before attempting another signup. ![A fresh account reaches My Events directly after signup](../application-fixes/assets/mobile-local/eventoxygen-signup-fixed.png) ![The immediate post-signup sign-in screen](assets/eventoxygen/06-after-account-create.png) ![The new attendee’s empty My Events screen](assets/eventoxygen/07-new-attendee-home.png) **You should see:** your initials at the top left, **My Events**, and **No events yet — Events you have tickets to will appear here.** That empty state means the account exists but has no tickets. It does not mean signup failed. **Try it:** identify the email you will use for registration before leaving this screen. **Check yourself:** will a ticket registered to a different email automatically appear here? No. The ticket holder email and your signed-in attendee email need to match. ## Part 2 — Register once and find the ticket again ### 1. Read the event before selecting a ticket Tap **Browse Events**, then **Tutorial Makers Conference 2026**, or the exact event your organizer supplied. If **Browse Events** says **No events yet — Check back soon**, stop this part and ask the organizer to verify the app/event pairing. This differs from an empty **My Events** ticket list after signup. Once the event is visible, review the dates, timezone, city, speakers and programme. The event detail offers quick links such as **People**, **Schedule**, **Venue** and **Tickets**, plus **Get Tickets** at the bottom. ![Conference detail before registration](assets/eventoxygen/08-event-detail.png) For this exercise, the joinery talk is November 14, 11:00–12:00 at **Main hall stage**. The event’s operating hours are 09:00–17:00. The dedicated Schedule screen formats the session times more clearly than the raw date-time strings visible in parts of the detail page. ### 2. Enter the holder details explicitly Tap **Get Tickets**. Even though Lina was signed in, the checkout’s **Email** and **Name** fields opened empty. Fill both: - **Email:** the same address used for your attendee account. - **Name:** the name staff should recognize at registration. ![Actual ticket selector with empty holder fields](assets/eventoxygen/09-ticket-selection-empty.png) Do not put your colleague’s email here unless the ticket is intentionally for that colleague. The app will look for tickets attached to the signed-in email later. ### 3. Select one General Admission ticket Under **Select Tickets**, tap the orange **+** on **Tutorial General Admission** once. Leave Workshop Pass at `0`. **General Admission** is free and opens the main hall. **Workshop Pass** costs USD 25 in this example and includes the workshop lab as well. A more expensive type is not automatically the right ticket; choose according to the areas and activities you need. ![One free ticket selected with Register available](assets/eventoxygen/10-one-free-ticket.png) **You should see:** quantity `1`, total **Free**, and **Register**. A non-zero total changes the action to **Continue to Payment**. We are practicing the free path; paid checkout requires the organizer’s separately tested payment setup. > **One free ticket per person in this event:** General Admission has a per-customer limit of one. Your colleague should register under their own address. Do not use repeated registrations to fix a missing screen without first checking whether the original ticket was issued. ### 4. Register and read the confirmation Tap **Register** once. Wait for **Booking Confirmed**. Read the booking reference, email, number of tickets and status. ![Successful free registration with one confirmed ticket](assets/eventoxygen/11-registration-result.png) Lina’s example shows reference `NA3X0LG9V`, **Tickets: 1**, **Status: PAID**, **Total: Free**, and one **CONFIRMED** ticket. Here **PAID** means the zero-total booking is settled; no money was charged. Your reference and ticket code will be different. The booking reference identifies the registration; the ticket identifies an individual admission right. ### 5. Reopen the app and find the event Close and reopen EventOxygen. Lina remained signed in, and **My Events** showed the conference with **1 ticket**. ![The ticket and attendee session persisted after relaunch](assets/eventoxygen/12-my-event-after-relaunch.png) Tap the event card. On its **Home** screen, choose **My ticket**. The ticket card shows the event, ticket type, holder, status and QR. ![Event Home with My ticket, Schedule, People and Media](assets/eventoxygen/13-attendee-event-home.png) ![Lina’s ticket ready to present](assets/eventoxygen/40-lina-ticket.png) ### 6. Pin the ticket you need most On a ticket card, open the three-dot menu and choose **Pin ticket**. Open the menu again: **Unpin** confirms the pin is set. Pinning places that ticket first in the list on this phone; it does not issue another ticket or change its access. To remove the preference, select **Unpin**, then reopen the menu and check that it offers **Pin ticket** again. This return action is verified in the corrected build. ![Ticket menu before pinning](assets/eventoxygen/28-ticket-menu.png) ![The same menu now offers Unpin](assets/eventoxygen/29-ticket-pinned.png) These two captures use Kofi’s existing conference ticket while we practice the second attendee’s side. The controls are the same on Lina’s ticket. **Try it:** close the app, reopen your event and point to the holder name, ticket type and status without starting another registration. The illustrated ticket card does not display the holder email; check that email on **Booking Confirmed** during Part 2, step 4. **Check yourself:** is pinning a ticket a reservation for a session? No. It is a local convenience for finding the ticket. ## Part 3 — Read the programme and use the right QR ### 1. Use Schedule from event Home On **Home**, tap **Schedule**. The calendar opens on the first session’s day when sessions are available. Select **Saturday, November 14, 2026** for the example. The joinery talk appears at **11:00–12:00**, with **Main hall stage** beneath it. ![Programme with the session on the selected day](assets/eventoxygen/14-schedule.png) Tap the talk to read its description and room. The session detail in this build displays full date-time strings in its time row; use the event date and timezone when planning your day. ![Session detail and description](assets/eventoxygen/15-session-detail.png) To keep this talk in your personal list: 1. Select **Add to My Schedule** on the session detail. The corrected app saves a bookmark under your attendee identity; the button changes to **Remove from My Schedule** after the API confirms it. 2. Leave the session and reopen it. **Remove from My Schedule** confirms the saved state survived navigation. If the app shows **Retry schedule**, retry the read before assuming the session is unsaved. 3. Return to event **Home**, open the upper-left menu, and choose **Bookmarks**. The screen is headed **Saved**. Select **Session** to show saved programme entries; this example includes the talk title, day, start time and room. 4. To remove it, use the entry’s trash icon or return to the session and choose **Remove from My Schedule**. Saving is personal organization, not a seat reservation, ticket purchase or admission. ![The actual session after saving](../application-fixes/assets/mobile-local/eventoxygen-session-saved.png) ![The saved talk is identifiable in the Session category](../application-fixes/assets/mobile-local/eventoxygen-saved-session-list.png) The 24 September local Android review verified saving, reopening and the saved-list entry with the fresh practice partner account. Earlier builds displayed the button without implementing its action; use your organizer’s updated build if tapping it does nothing. ### 2. Present the admission ticket to staff Return to **Home → My ticket**. This is the code associated with your admission. Staff use Appmint Mobile, select the correct **Check In** mode and zone, and process your ticket. For this rehearsal, staff used **Manual Lookup** with Lina’s holder email. That is a useful fallback when a camera cannot read a screen. It still checks the saved ticket and creates a server-side admission result. ![Staff app admits the ticket registered in EventOxygen](../application-fixes/assets/mobile-local/eventoxygen-staff-admission.png) After staff admission, reopen the ticket in EventOxygen. Lina’s card changed from **CONFIRMED** to **CHECKED IN**, and the saved ticket contained an entry movement in `main-hall`. ![EventOxygen reflects the staff admission; this capture deliberately shows the disconnected QR state to keep the live code private](../application-fixes/assets/mobile-local/eventoxygen-attendee-checked-in-safe.png) General Admission does not include the workshop lab. A zone refusal is an access decision; it does not necessarily mean the account or phone is broken. Staff can check the ticket type and direct you to registration if you need different access. > **Present the live ticket, not an old screenshot.** The corrected app fetches a fresh admission QR and renews it while the ticket remains open. Codes have a short validity window. If the connection fails, the code disappears and **Refresh QR** appears. Reconnect and tap it, or ask staff for holder-email lookup. A profile QR is still not an admission ticket. Physical camera decoding has not been tested in this emulator rehearsal. ![The app hides the admission QR when it cannot refresh it](../application-fixes/assets/mobile-local/eventoxygen-ticket-offline-safe.png) After staff confirm admission, open **Home → My ticket** to fetch the latest ticket. Check for **CHECKED_IN**, then press Android Back to return to event Home. The card under **My Tickets** should show the same status. This confirms that the attendee app reflects the staff action; it does not submit another check-in. ![Home shows CHECKED_IN after reopening the admitted ticket](../application-fixes/assets/mobile-local/eventoxygen-kofi-home-checked-in.png) ### 3. Find your profile QR separately Tap **Me**, then the QR icon at the top right. This opens **My QR Code**, with your name and **Scan to connect**. ![Me screen with its QR icon](assets/eventoxygen/25-me-profile.png) ![The profile QR is for connecting, not admission](assets/eventoxygen/26-profile-qr.png) | Code | Where you open it | What it represents | | --- | --- | --- | | Ticket QR | **Home → My ticket** | An admission ticket for a particular event. | | Profile QR | **Me → QR icon** | Your attendee identity for connecting with people. | To share this profile code, choose **Share QR Code**. The corrected Android build generates a PNG and opens the system **Sharing image** chooser. Select your intended destination yourself, or press Back to cancel. Opening the chooser does not send the image. The 24 September local test verified the image chooser and cancelled without selecting a recipient; delivery through another app was not tested. ![Android opens the image chooser for the attendee profile QR](../application-fixes/assets/mobile-local/eventoxygen-profile-share-chooser.png) **Try it:** have your practice partner ask for your admission ticket, then your profile. Open the correct screen each time. **Check yourself:** does a second admission attempt always fail? No. The organizer’s re-entry rules decide that outcome. The [staff rehearsal](appmint-run-an-event.md) demonstrates both an accepted repeat and a refusal after re-entry is disabled. ## Part 4 — Connect and keep the conversation ### 1. Find the person in People From event **Home**, tap **People**. Use **Search people** or the role filters. This directory contains the event’s confirmed participant records. In the example, Nia is a **Speaker** and Kofi is an **Attendee** participant. ![People directory with Kofi and Nia](assets/eventoxygen/16-event-people.png) > **A ticket and a directory listing are separate.** Kofi already had a ticket, but the organizer added his confirmed participant record before he appeared here. If you cannot find someone, check with them or the organizer instead of assuming they have not registered. ### 2. Send a connection request On Kofi’s card, tap **Connect**. The card changes to **Pending**. Open the hamburger menu, then **Connections**, to see the sent request. ![Request sent and pending](assets/eventoxygen/17-connection-pending.png) ![Open event drawer with Connections, Meetings and Bookmarks](../application-fixes/assets/mobile-local/eventoxygen-drawer-open.png) This capture locates the hamburger icon; it does not show the opened drawer. Open the menu to find **Connections**. ![Lina’s sent request](assets/eventoxygen/19-sent-request.png) **Pending** means a request exists; the other person has not accepted it yet. Avoid repeatedly sending the same request. ### 3. Accept from the other attendee’s account Your partner opens the same event, then the hamburger menu → **Connections**. Under **REQUESTS RECEIVED**, they find your name and tap the **green check icon**. The adjacent **×** declines the request; **Accept all** applies to every received request and is unnecessary for this one-person exercise. ![Kofi’s received request and green acceptance icon](assets/eventoxygen/21-kofi-received-request.png) ![Lina appears in Kofi’s connected list after acceptance](assets/eventoxygen/22-connection-accepted.png) **You should see:** **Connection accepted** and the person under **CONNECTED**. Reopen or refresh the list if the top counters lag behind the row changes. In this rehearsal the row updated before the summary counters. We practiced the two accounts sequentially on one training phone. If you do the same, choose **Log out** from the event drawer. The corrected build immediately returns to the signed-out **Events** list. Sign in as the other attendee; do not merely change an email field inside a booking. ### 4. Send a useful first message From **Connections**, tap the chat-bubble icon beside your partner. Alternatively, open their **People** card and choose **Message**. In **Type a message...**, write something specific, such as: > Coffee after the joinery talk? Meet by the main hall stage. Use the send arrow or the keyboard’s submit action. Kofi sent that message to Lina in the rehearsal. ![Kofi’s sent message](assets/eventoxygen/24-message-sent.png) Lina then signed in and opened **Inbox → Messages**. The conversation appeared with Kofi’s name, the message preview and an unread count. ![Kofi’s actual reply in Lina’s Inbox](../application-fixes/assets/mobile-local/eventoxygen-lina-reply-inbox.png) **You should see:** the same conversation on the recipient’s side. A bubble appearing immediately on the sender’s screen is not sufficient when diagnosing a failed send; reopen the thread or have the recipient check before sending the text repeatedly. The backend permits direct messages without an accepted connection in some paths. A mutual connection is required for the meeting service. This exercise establishes the connection first so the relationship is clear. ### 5. Arrange a meeting your partner can accept Open your connected partner from **People** or **Connections**, then select **Meet**. Check **Meeting with** before filling the form: the invitation goes to that attendee. 1. In **Meeting Title**, enter `Tutorial materials coffee`. A specific title helps you recognize it later among other appointments. 2. Check **Date and time in America/Chicago**. The form uses the event's timezone, so the appointment stays tied to the venue even when your phone uses another timezone. For another event, use the timezone displayed there. 3. Tap the date. The default is tomorrow, which may be the wrong day for your conference. Move the calendar to **November 2026**, select **14**, then choose **OK**. 4. Tap the time. Select the keyboard icon to switch to text input, enter hour **12** and minutes **15**, choose **PM**, then **OK**. Confirm the form says **12:15 PM**, not 12:15 AM. 5. Choose **15m**. This meeting starts after the example's 11:00–12:00 talk, leaving time to reach the meeting place. 6. In **Notes (optional)**, enter `Main hall stage. Compare material samples after the joinery talk.` Put the meeting place in the notes: this form has no separate location field. 7. Review the partner, **14/11/2026**, **12:15 PM**, timezone and duration, then select **Send Meeting Request** once. The success message names your partner and returns you to their profile. ![Completed native meeting form with the venue timezone](../application-fixes/assets/mobile-local/eventoxygen-meeting-form-fixed.png) Return to event Home, open the upper-left menu, and choose **Meetings → All**. Your appointment should say **PROPOSED**, show **14/11/2026 12:15 America/Chicago · 15min**, and include your notes. **Why Upcoming can still be empty:** Upcoming lists confirmed future meetings. A proposal awaiting your partner's response belongs under **All**. Do not create it again because Upcoming says zero. ![The actual proposal under All, with the correct venue time](../application-fixes/assets/mobile-local/eventoxygen-meeting-proposed.png) Now ask your practice partner to sign into **their own attendee account**, open the same event, and choose **menu → Meetings → All**. Their copy has **Decline** and **Accept**. They should read the date, time, timezone and notes before choosing **Accept**. ![Recipient-side Accept and Decline controls](../application-fixes/assets/mobile-local/eventoxygen-meeting-recipient-actions.png) After acceptance, the card says **CONFIRMED** and appears under **Upcoming**. Reopen Meetings on the organizer's account too; both people should see the same appointment. A chat reply is useful context, but it does not accept the meeting invitation automatically. ![Confirmed native meeting after the recipient accepts](../application-fixes/assets/mobile-local/eventoxygen-meeting-confirmed.png) **Watch for:** selecting tomorrow instead of the conference day, choosing AM instead of PM, or interpreting the venue's time as your phone's local time. If an older app reports **At least one participant is required** while a partner is displayed, ask for the updated build; no meeting was created by that error. Check **All** before retrying any uncertain submission. **Try it:** have your partner read the appointment's day, venue timezone and meeting place back to you, then find it again under Upcoming. **Check yourself:** does a **Pending** connection or **PROPOSED** meeting mean the other person agreed? No. Look for the accepted connection or **CONFIRMED** meeting. ## Part 5 — Share a useful question and save a post ### 1. Know what Explore contains Tap **Explore** in the bottom bar. On this build, Explore is the community feed; the programme remains under **Home → Schedule**. The local review feed also contained posts from another community in the same organization. The event’s automatically created page was absent from the active-page lookup, so the app fell back to the broader feed. Your feed may be empty; the next step creates the question used in this exercise. > **Treat the feed as public to the wider organization unless the organizer has verified event scoping.** An event heading is not an audience-control guarantee. Keep private client details, travel documents and confidential conversations out of public posts. ### 2. Ask a question someone can answer Tap **What’s on your mind?**. In **Create Post**, enter: > Tutorial conference question: what would you test before using a printed timber connector? The composer also offers **Photo**, **Camera**, **Poll** and **Link**. Start with text so you can verify the audience and saved result before adding media. ![A focused practice question in the actual native composer](../application-fixes/assets/mobile-local/eventoxygen-practice-post-draft.png) Tap **Post**. The question appears under your name; the latest rehearsal uses Kofi. Leave the feed and reopen it to confirm the saved post is there. ![Kofi’s own question after publishing](../application-fixes/assets/mobile-local/eventoxygen-practice-post-created.png) The saved record in this rehearsal had `page: null` and `visibility: public`. It was an organization-wide training post, not a private conference post. For event-scoped publishing, the organizer must first establish the page and the client’s page association. The bottom **+** action also opens a composer without an event-page ID in this build. ### 3. Save a useful contribution Find the question you just published and tap **Save** beneath that post. Avoid the Save action beneath a different post. Return to **Home**, open the hamburger menu, select **Bookmarks**, then choose the **Post** filter. The screen title is **Saved**. ![The saved entry now identifies the practice question](../application-fixes/assets/mobile-local/eventoxygen-saved-post-fixed.png) **You should see:** **POST**, followed by the beginning of your question. Select **Open saved post**, the arrow leaving a square beside that entry. The original question opens with its author and comment controls. Leave and reopen Saved to confirm it remains available. ![Opening the saved question returns to the original post](../application-fixes/assets/mobile-local/eventoxygen-saved-post-reopened.png) The trash control removes your bookmark, not the original post. If the post was deleted or its audience changed, opening it can fail; do not assume saving a bookmark gives permanent access. A category tab is not evidence that every corresponding screen has a save button. **Try it:** make your question more useful by adding the material, constraint or decision you are considering. Keep personal project details out of the public example. **Check yourself:** did a post made while the conference was open necessarily stay within that conference’s community? No. The actual record and page binding decide the audience. ## Part 6 — PRO: organizer setup and supported integration repairs ### 1. Prepare the organization the app will actually use The [organizer course](appmint-run-an-event.md) creates the event, ticket types, session and participant records. Before distributing an attendee build, verify all of these with a fresh attendee account: 1. The app lists the intended organization’s published event. 2. **Get Tickets** shows the intended prices, currency and quantities. 3. A free registration appears under the same email’s **My Events**. 4. Staff admission updates that exact ticket. 5. **People** contains the confirmed participant records you intend to expose. 6. Programme times, meeting times and community audience are correct in the app itself. A staff dashboard that looks right does not replace this attendee rehearsal. ### 2. Configure a separate EventOxygen build The source used here is `appmint_go/event_app`. The current local build accepts configuration in a private JSON file through Flutter's `--dart-define-from-file` option. You do not need to edit another organization's committed credentials. 1. Complete the [connected-client course's staff authentication lab](appengine-build-a-connected-web-or-mobile-client.md) with your own organization owner account. Retain your API origin, organization ID and completed staff bearer privately. In the following request, `API_BASE`, `ORG_ID` and `STAFF_TOKEN` mean those values; they are not supplied accounts. 2. Register a dedicated application for this attendee build. Choose an application ID and contact email you control. Send this request from your trusted development environment, replacing the example body values: ```sh curl --fail-with-body "$API_BASE/profile/app/register" \ -H "orgid: $ORG_ID" \ -H "Authorization: Bearer $STAFF_TOKEN" \ -H 'Content-Type: application/json' \ --data '{"appId":"my-conference-attendee-app","email":"YOUR_APP_CONTACT_EMAIL"}' ``` Save the response privately. Its `data.username` is the application ID and `data.secret` is the application secret. Use `data.key` when returned; the tested registration response used the secret as the key fallback. Reuse the registered application when rebuilding; do not register it anew for every attendee. Never put the staff bearer in the mobile configuration. 3. Create `eventoxygen-defines.json` outside the source repository. Replace every value below with your organization's configuration. `EVENT_SITE_NAME` is the existing site record's name used for customer-facing links, not the API hostname or the event title. ```json { "APPENGINE_URL": "YOUR_APPENGINE_ORIGIN", "EVENT_ORG_ID": "YOUR_ORGANIZATION_ID", "EVENT_APP_ID": "REGISTERED_APPLICATION_USERNAME", "EVENT_APP_KEY": "REGISTERED_APPLICATION_KEY", "EVENT_APP_SECRET": "REGISTERED_APPLICATION_SECRET", "EVENT_SITE_NAME": "YOUR_SITE_RECORD_NAME" } ``` The app reads these overrides in `lib/config/environment.dart` in both debug and release builds. Its HTTP client obtains application authorization and adds separate customer authorization after attendee signup/sign-in. An attendee uses the normal account screens; they never enter staff credentials or these configuration values. 4. From the `event_app` source directory, resolve dependencies and build your Android test package: ```sh flutter pub get flutter build apk --debug --dart-define-from-file=/absolute/path/to/eventoxygen-defines.json ``` The local rehearsal used OpenJDK 17. Flutter writes `build/app/outputs/flutter-apk/app-debug.apk`. Install that package on your test device using `adb install -r build/app/outputs/flutter-apk/app-debug.apk`. This is a development package, not a store-signed release. For an emulator connected to a local API, configure Android's host connection and development-only HTTP permission; a production build must use your reachable HTTPS API. 5. Open the installed app before distributing anything. **Events** must show your published event. In the 24 September local run, the separate **EventOxygen Tutorial** build displayed Tutorial Makers Conference 2026. A fresh Lina account then registered one free ticket, and **My Events** still displayed it after reinstalling the updated app without clearing app data. ![The configured local app shows the published training event](../application-fixes/assets/mobile-local/eventoxygen-browse-local.png) ![The attendee's event and one ticket remain after reopening](../application-fixes/assets/mobile-local/eventoxygen-my-events-reopened.png) Give attendees your tested installable package or store listing and the event name. The public stock APK is a separate configured distribution; it does not acquire your organization simply because you created an Appmint account. Complete the readiness checklist above before inviting attendees. Feature fixes made during this local review are recorded in the [live application review](../application-fixes/eventoxygen-live-walkthrough.md); unverified exercises remain open. A public source-download URL and redistribution license were not established here. Obtain the authorized source distribution and applicable license from the project owner before publishing a customized app. An iPhone release also needs its signing and distribution work; the Android build is not an iOS validation. ### 3. Reference: meeting request fields used in the rehearsal This is an endpoint-contract reference for integration developers. Attendees complete the working native sequence in Part 4 and do not need an API workaround. For authentication setup, use application registration in Part 6, step 2 and the [connected-client authentication course](appengine-build-a-connected-web-or-mobile-client.md). Customer-facing requests use the application bearer plus that attendee’s separate `x-client-authorization: Bearer …` header and the organization ID. Keep staff owner tokens out of customer requests. Replace the fictional attendee identity, date/time and location below with your actual practice details; the example address is not a provided partner account. The 24 September native walkthrough created and accepted the meeting through these endpoints using the two fresh attendees. The rehearsal created the request as Lina with **POST `/client/community/meetings`**: ```json { "title": "Tutorial materials coffee", "participantIds": ["kofi.community@example.invalid"], "startTime": "2026-11-14T12:15:00-06:00", "duration": 15, "timezone": "America/Chicago", "locationType": "in_person", "location": "Main hall stage", "description": "Compare material samples after the joinery talk." } ``` The connection must already be accepted. The rehearsal response was `201`, with the meeting in **proposed** state. In your authorized integration, retain the new response’s record ID for `:meetingId` below. The rehearsal accepted it using Kofi’s separate customer identity with **PUT `/client/community/meetings/:meetingId/respond`**: ```json { "response": "accepted" } ``` The response was `200`; the participant became **accepted** and the meeting **confirmed**. Read **GET `/client/community/meetings`** as Lina, then reopen **Meetings** in the app. The corrected native list displays the stored instant in the meeting’s named timezone: the local walkthrough stored 18:15 UTC and displayed **12:15 America/Chicago** on both sides. ### 4. Understand ticket assignment before offering it **My ticket → three-dot menu → Assign to someone** opens **Assign Ticket**, with **Recipient Email**, **Recipient Name (optional)** and **Assign Ticket**. ![Ticket assignment form inspected without changing the holder](assets/eventoxygen/30-assign-ticket-form.png) The menu was present even though this rehearsal’s General Admission type disallowed transfer. A visible action is therefore not a guarantee the service permits it. The service checks the ticket type and can return **This ticket type does not allow transfers**. When an allowed assignment succeeds, the service changes the holder and code material and notifies the relevant addresses. Confirm the recipient’s exact email before a real transfer; the recipient must use that email to find the ticket. This course inspected the form without transferring either attendee’s rehearsal ticket. A launch test should verify the new holder’s view, the previous holder’s view, admission rules and message delivery together. ### 5. Repair the community and profile gaps at their actual layer The event-page creation hook omitted an active status while the page list filters for active pages. Verify the event-linked page and its memberships in Studio, then make the client scope its feed and composer to that page. Avoid a silent global fallback where the UI promises an event-only audience. Existing tutorial members also hit the second-page membership issue documented in the [community course](appmint-build-a-member-community.md). To update your attendee profile, open **Me → Edit Profile**. Enter a useful **Job Title**, such as **Materials researcher**, and your company or short bio if you want other attendees to see them. Select **Save**, then reopen **Edit Profile** to check your changes. The corrected build persists them to your account; the job title was also verified after a full app restart. If saving fails, your entries stay in the form with an error so you can retry. ![Job title retained after saving the actual attendee profile](../application-fixes/assets/mobile-local/eventoxygen-profile-saved.png) The profile QR share action is now implemented and verified to open the Android image chooser. The event drawer’s **Settings** and **Help** still simply close the drawer. Those two drawer controls need implementation; changing tutorial wording cannot make them persist data or contact support. Give attendees a verified organizer contact route outside those controls. For paid tickets, complete the payment verification work in the [organizer course](appmint-run-an-event.md) before taking real money. A pending booking is not an issued admission ticket, and an Appmint bookkeeping refund is not the same as moving money through a provider. **Try it:** repeat the fresh-account, free-ticket and staff-admission sequence in your own configured build. Keep a record of the actual app version and organization used. **Check yourself:** can you validate a branded attendee app by signing in as the organization owner? That tests the wrong identity. Use a normal attendee and only the intended customer-facing permissions. ## If something goes wrong | Symptom | Check first | What to do | | --- | --- | --- | | Android download shows **AccessDenied** | Whether an APK actually downloaded | Get the organizer’s current working package; the XML error is not an APK. | | Correct account, wrong events | Which organization the installed app is configured for | Use the organizer’s named build; the stock production app uses `eventos`. | | Signup returns to sign-in | Whether you are still on the prior login route | Return once to My Events before creating another account. | | **My Events** is empty after signup | Whether this email holds a ticket | Open **Browse Events**; if the supplied event is visible, register once under the same email. | | **Browse Events** says **No events yet / Check back soon** | Whether the organizer’s event is available in this installed app | Ask the organizer to verify the app/event pairing before continuing; a new account does not supply a published event. | | Policy link shows a certificate warning | The browser error and linked address | Request a working policy link through the organizer contact route; do not bypass the warning. | | Registration fields are blank while signed in | Email and name in **Get Tickets** | Fill them explicitly; do not assume account details were copied. | | Booking says **PAID** and **Free** | Total is zero | This is a settled free booking, not a card charge. | | Ticket missing after an error | Existing booking/ticket under the holder email | Ask the desk to check before registering again. | | QR will not admit you | Correct ticket versus profile QR; code freshness; network | Ask staff to use Manual Lookup and check the actual admission result. | | Workshop door refuses General Admission | Ticket type’s zone access | Ask registration about workshop entitlement. | | Another scan is accepted | Re-entry rule | Staff should follow the configured movement procedure. | | Person missing from People | Confirmed participant record | Ask the organizer; a ticket alone did not create the listing here. | | Connected row updates but counters do not | Reopen/refresh Connections | Use the saved accepted row while diagnosing the stale summary. | | Message appears only on sender’s screen | Recipient Inbox and persisted thread | Reopen/check once before resending. | | Meeting says **At least one participant is required** | Older app build with the wrong request field | Get the organizer’s updated build. Check All before retrying; the corrected native form creates the invitation. | | Meeting is absent from Upcoming | Whether the recipient accepted it | Open All for proposals. Upcoming contains confirmed future meetings. | | **Add to My Schedule** or Saved’s **Session** filter is unclear | Agenda controls versus Bookmarks | The agenda action’s behavior is unverified here; use the programme and do not infer a seat reservation. | | Saved list only says **POST** | Bookmark exists but lacks display details | Return to the original feed; the list needs a richer title/link. | | Explore includes another community’s posts | Event page lookup and fallback | Treat the feed as broader/public until the organizer verifies scope. | | Assign action exists but transfer is refused | Ticket type’s transfer permission | Ask the organizer; do not infer permission from the menu. | | Profile edits disappear | Save implementation | Ask the organizer for a working update route; the native handler is incomplete. | | Share QR Code does nothing on an older build | App version | Use the organizer’s updated build; the locally verified version opens an image chooser. | | Event-drawer Help or Settings does nothing | Known empty callbacks | Use the organizer’s verified contact route. | | Log out leaves the event visible | Navigation stack | Return to the signed-out Events list before using another attendee account. | ## What happened behind the scenes
Records, source pointers and verification Source paths are relative to `/Users/imzee/projects`: - `appmint_go/event_app/lib/screens/auth/register_screen.dart`, `login_screen.dart`: account fields, validation and post-signup navigation. - `lib/screens/customer/public_events_shell.dart`, `my_events_screen.dart`, `event_detail_screen.dart`, `booking_summary_screen.dart`: browsing, email-specific tickets, checkout and confirmation. - `lib/providers/event_provider.dart`, `lib/screens/customer/ticket_screen.dart`: ticket persistence, enrichment, pinning and assignment. The current ticket screen uses `LiveTicketQr` to fetch and renew holder-authorized codes, clears them in the background and hides them when refresh fails. Refreshed tickets also replace the active Home ticket list. - `lib/screens/customer/event_shell.dart`, `event_home_screen.dart`, `schedule_screen.dart`, `session_detail_screen.dart`: actual navigation and programme presentation. - `lib/screens/customer/people_screen.dart`, `connections_screen.dart`, `chat_screen.dart`, `meetings_screen.dart`: connection request/acceptance, messaging, meeting form payload and time display. - `lib/screens/customer/feed_screen.dart`, `create_post_screen.dart`, `bookmarks_screen.dart`: event-page lookup, global fallback, composer association and saved-post presentation. - `lib/screens/customer/me_screen.dart`, `qr_profile_screen.dart`, `edit_profile_screen.dart`: profile editing, persistence and profile QR sharing. - `appengine/src/events/events-client.controller.ts`, `events-client.service.ts`, `event-ticket.service.ts`: holder-specific ticket reads, public purchase, transfer and QR handling. - `appengine/src/community/community-client.controller.ts`, `community-connection.service.ts`, `community-messaging.service.ts`, `community-meeting.service.ts`, `community-page.service.ts`: accepted connection, saved thread, correct meeting payload and page creation/listing. [Capture ledger](assets/eventoxygen/evidence.json), [download error](assets/eventoxygen/download-evidence.json), [attendee ticket after admission, redacted](assets/eventoxygen/lina-tickets-readback.json), [accepted connection](assets/eventoxygen/connections-readback.json), [message received through the customer endpoint](assets/eventoxygen/message-thread-readback.json), [bookmark](assets/eventoxygen/bookmarks-readback.json), [failed-form meeting list](assets/eventoxygen/meetings-readback.json), [supported meeting create](assets/eventoxygen/meeting-api-create.json), [accepted meeting](assets/eventoxygen/meeting-api-accepted.json), [public feed record with signed URLs removed](assets/eventoxygen/feed-readback.json).
## Where next - [Run the event and rehearse the staff door](appmint-run-an-event.md) — event setup, ticket access and registration operations. - [Build and moderate the community](appmint-build-a-member-community.md) — page membership, reporting and moderation. - [Get started with Appmint Mobile](appmint-mobile-welcome-and-daily-work.md) — the separate staff app. **Current evidence, 24 September 2026:** native Android API36 emulator, a separately configured current-source EventOxygen Tutorial app and real isolated local AppEngine data. Two fresh attendee accounts completed signup and free registration. Native checks passed for ticket reopening and staff manual admission, schedule bookmarks, People filtering, accepted connections with immediate totals, reciprocal messages, native meeting creation/acceptance and venue timezone, profile Save/restart/server readback, profile QR image sharing, learner-created post save/reopen, logout, ticket QR renewal/offline recovery and pin/unpin. Application source was repaired during this review. [Current fixes and proof](../application-fixes/eventoxygen-live-walkthrough.md). The Privacy Policy destination is corrected and passed native navigation; the intended Terms of Service destination remains unresolved. No iOS execution, paid payment/refund, actual ticket transfer, physical QR-camera decode or finished companion video is claimed. The 18September capture ledger remains historical evidence; current repair screenshots are explicitly linked above. [Video production guide](production/eventoxygen-attend-and-connect.md). **Independent stock-app review, 18 September 2026:** the corrected public APK, EventOxygen 1.0.0 (8), installed on a fresh API 36 emulator. A new reviewer account reached My Events using Android Back and remained signed in after reopening. Browse Events stayed empty, so the reviewer did not execute the ticket, programme, networking, feed or admission exercises. The signup policy page showed a certificate warning. Documentation corrections on 21 September use that saved evidence; they do not establish a new end-to-end app pass. --- # Connect your own client to AppEngine and follow a booking into Studio > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appengine/appengine-build-a-connected-web-or-mobile-client.html # Connect your own client to AppEngine and follow a booking into Studio ![Fresh learner booking on the reloaded local Studio Reservations board.](../application-fixes/assets/local-connected-client/04-reservations-reloaded.png) *Lina's consultation was created through HTTP, then opened in Studio Manager. Both interfaces use the same saved record.* **Who:** developers comfortable with basic JavaScript and JSON. **Time:** 60–90 minutes. **Level:** first integration, followed by deployment and access-control work. **Product:** AppEngine and Studio Manager. **Checked:** 19 September 2026; local AppEngine 0.132.0 and Studio 0.6.2, with newly created controlled customer accounts. Earlier 18 September captures are labelled historical below. **Example:** Cedar & Form's consultation workflow, rehearsed with the separate fictional accounts Lina Tutorial and Kofi. ## What you will have at the end You will sign a customer in, read their profile and reservations, create a consultation, and find it on the staff board. You will understand the response envelopes, the organization header, the difference between customer and staff credentials, and the second customer header used by a server acting on a customer's behalf. You will also have a concrete acceptance test for your integration. On the checked local build, profile edits persist, Lina can read her reservation, and Kofi cannot read it through either the customer route or generic repository route. Test these same boundaries in your deployment. The historical duplicate-booking behavior still calls for reconciliation and server-side idempotency before automated retries. ## What you need - Your organization ID from [Appmint signup and first setup](appmint-getting-started.md). This is the company's identifier, supplied as `orgid`. - Two controlled customer accounts with known passwords in this organization. Part 1.2 now creates Lina and Kofi through the supported customer-signup API; an organization-configured EventOxygen build is not required. The `.invalid` addresses below cannot receive external email. - An isolated local training organization with outgoing email routed to a local test inbox before signup or booking. These operations can create welcome messages, owner copies and reminder schedules. The local rehearsal used an organization-owned SMTP integration pointed at a loopback catcher; no external mail provider was used for customer signup or booking. - A staff account for configuring the service and reading back the booking. Customers use their own customer session; they do not receive this staff credential. - Node.js with `fetch`, or curl and a JSON viewer. You can complete the exercise in a terminal before connecting your own web or mobile interface. - A free reservation definition. Part 2 includes the exact definition used in this rehearsal. For the business setup behind it, see [Enquiries, leads and consultations](appmint-turn-an-enquiry-into-a-project.md). **Choose one environment.** The checked production API is `https://appengine.appmint.io`. Its `/health` returned 200. The alternative `api.appmint.io` did not resolve during this check. The local rehearsal used an operator-configured development server; use the base address your operator provides. A local account and a production account are not interchangeable. ## The story and route Cedar & Form wants its own client experience while keeping bookings in Appmint. Lina books a design conversation from that client; staff work from **CRM → Reservations** as usual. Kofi is a second test customer used to check ownership. ![Concept diagram: customer client, AppEngine and staff operations.](assets/appengine-client/request-map.svg) There are three distinct identities to keep straight: | Value | Meaning | Where it belongs | | --- | --- | --- | | `orgid` | Which company the request addresses | Every business request | | Customer bearer | Which customer is signed in | Customer API calls | | Staff/application bearer plus `x-client-authorization` | A trusted server acting for a particular customer | Your server-side integration | An organization ID is an identifier, not a password. A staff API key or application secret is a credential. Keep the latter on the server. ## Part 1 — Read real customer data ### 1. Check the API before debugging credentials Set your base address and company ID in the shell. Replace the organization placeholder; do not copy the training company's ID into your own integration. ```bash API='http://localhost:3300' # The operator-configured local API used in this lab. ORG='' curl -sS "$API/health" curl -sS "$API/profile/whoami" ``` **You should see:** health JSON containing `isHealthy`, service states and a version. `whoami` returned the plain text `Anonymous User` in this rehearsal, even when called with the customer bearer. Read it as text; do not use this public response as your customer-session check. If the host cannot be resolved, fix the API address before changing the password or organization ID. A DNS error occurs before customer authentication. ### 2. Create both customer accounts, then sign in Use the same `API` and `ORG` as your staff account. For a first run, create **Lina Tutorial** through `POST /profile/customer/signup`: ```bash curl -sS -X POST "$API/profile/customer/signup" \ -H "orgid: $ORG" -H 'Content-Type: application/json' \ --data '{"firstName":"Lina","lastName":"Tutorial","email":"lina.events@example.invalid","password":""}' ``` Use a fresh private password, not the literal placeholder. Repeat with `firstName: "Kofi"`, email `kofi.events@example.invalid` and a different private password. The checked local response was **201** with `customer`, `token` and `refreshToken`; `customer.sk` is each new account's ID. No inbox verification was required by this local signup flow. Its welcome messages arrived in the local test inbox. Keep the full responses private. If an account already exists, sign in using its known password instead of registering it again. An existing-account error does not give you control of that identity. Use another controlled training address if you do not own it, and use that address consistently in the booking and ownership checks. Use `POST /profile/customer/signin` with the company header and the customer's email/password. The password is private; the example deliberately uses a placeholder. ```bash curl -sS -X POST "$API/profile/customer/signin" \ -H "orgid: $ORG" -H 'Content-Type: application/json' \ --data '{"email":"lina.events@example.invalid","password":""}' ``` The successful response contains `token` and `refreshToken`. Keep them in your session implementation or private terminal variables. Do not paste the response into a support ticket or public capture. A wrong password returned HTTP **400** with `error: "bad password"`; do not write a client that recognizes authentication failure only when the status is 401. If a sign-in response requires a password change or another challenge, complete that flow before treating it as a usable session. ### 3. Read the profile and unwrap the record With the returned access value in `CUSTOMER_TOKEN`, call: ```bash curl -sS "$API/client-data/profile" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" ``` The profile is a record envelope. Read the person's name from **`response.data.firstName`**, not `response.firstName`. Its `sk` identifies the saved customer; `data` holds the business fields. ### 4. Read lists without mistaking the envelope for an array ```bash curl -sS "$API/client-data/reservations" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" curl -sS "$API/client-data/orders" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" ``` Both returned objects containing `total`, pagination fields and a **`data` array**. An empty account returned `total: 0, data: []`, not a bare `[]`. Each list item is itself a record, so a booking's service is `response.data[0].data.service`. ![Selected actual profile and booking responses, displayed as a sanitized text transcript.](assets/appengine-client/04-customer-responses.png) *This is a transcript of real HTTP results, not a mock application screen. Notice the profile's `data` object and the reservation list's `data` array.* ### 5. Read the dashboard, then check what its counters mean Call `GET /client-data/dashboard` with the same headers. It returned `profile`, `summary`, `recentOrders`, payment information, analytics, addresses, benefits and affiliate information. After Lina booked, the reservations list contained her appointment but `summary.upcomingReservations` remained **0**. This counter currently selects the literal reservation status `upcoming`; the create endpoint saved `new`. Studio's board still displayed an upcoming appointment by date. For a booking view, render the returned reservation list and its actual date/status. Do not hide a valid booking because this aggregate counter is zero. Similarly, `nextReservation` was omitted when the aggregate found no matching status; handle an absent field as well as `null`. **Try it:** sign in as your second controlled customer in the same `API` and `ORG`, keeping the first customer’s bearer in `CUSTOMER_TOKEN` and the second customer’s bearer in a separate `SECOND_CUSTOMER_TOKEN` variable. Compare `profile.data.email` and the reservations envelope; do not overwrite the first customer’s token. Part 4 uses both identities. **Check yourself:** a list has `total: 1` and your UI says “No bookings.” Check whether you tested `response.length` instead of `response.data.length`. ## Part 2 — Create the service and book one consultation ### 1. Establish the reservation definition as staff A booking needs a real reservation definition and an offered service. The old shortcut of posting only a date and time is insufficient on this build. For the API exercise, sign in with your staff account, using the same `API` origin and `ORG` as Part 1: ```bash curl -sS -X POST "$API/profile/user/signin" \ -H "orgid: $ORG" -H 'Content-Type: application/json' \ --data '{"email":"","password":""}' ``` Replace both placeholders privately; do not publish the completed command or response. A completed sign-in returns `token`; retain that JWT as `STAFF_TOKEN`, separately from `CUSTOMER_TOKEN`. If the response asks for verification or a password change, it is not a completed sign-in: complete your account’s supported verification/recovery flow before this lab. Do not copy a challenge token into `STAFF_TOKEN`. Check the completed sign-in response before creating anything: `user.datatype` must be `user`, `user.data.email` must match your staff email, and the returned `orgId` must match `ORG`. Keep the bearer returned by that same response. Then confirm that the protected definition lookup below succeeds. `GET /profile/whoami` returned `Anonymous User` even with a valid staff bearer on this local build. It is not a usable staff-session gate here; do not abandon a successful protected sign-in because of that public diagnostic response. **First run or returning learner: look up the definition before creating it.** Reuse `API`, `ORG` and `STAFF_TOKEN` from above. This is a read-only GET; there is no request body: ```bash curl -sS --write-out '\nHTTP %{http_code}\n' \ "$API/repository/find-by-attribute/reservation_definition/data.name/tutorial-api-consultation?p=1&ps=2" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` The response should be HTTP 200 with a list envelope: `total` and `data`, where each element has its own `sk` and nested `data`. The filter searches the definition’s `data.name`; it does not search reservation references or service titles. - **`total: 0` and an empty `data` array:** no matching definition was found. Continue with the create request below. - **Exactly one match:** inspect `data[0].data`. Reuse it only if it is the intended training definition: name `tutorial-api-consultation`, type `service`, status `active`, and service `Design consultation` with duration 30 and price 0. Check its hours, timezone and venue against the example before booking. Put the record’s **`data[0].sk`**, not its name, into `DEFINITION_ID`, then skip the create request. - **More than one match, different settings, or an unexpected response:** stop and have the staff operator identify the intended definition in **Reservation Definitions**. Do not pick the first row or create another to work around an ambiguous result. `ps=2` limits the returned rows, while `total` reports the matching count. A 401/403 or failed request does not mean the definition is absent. For the reuse branch, retain and read the selected ID once more: ```bash DEFINITION_ID='' curl -sS --write-out '\nHTTP %{http_code}\n' \ "$API/repository/get/reservation_definition/$DEFINITION_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` Expect HTTP 200 and a single record whose `sk` equals `DEFINITION_ID` and whose `data` contains the definition just reviewed. This lookup uses an authenticated staff session with repository read permission. Keep the same environment and organization throughout. **Only when the lookup returned zero matches**, create this free training definition: ```bash curl -sS -X PUT "$API/repository/create" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \ -H 'Content-Type: application/json' --data '{ "datatype":"reservation_definition", "isNew":true, "data":{ "name":"tutorial-api-consultation", "title":"Tutorial API design consultation", "type":"service", "status":"active", "services":[{ "name":"Design consultation", "duration":30, "price":0, "breakAfter":0 }], "officeDays":["Monday","Tuesday","Wednesday","Thursday","Friday"], "officeHours":{ "timezone":"America/Chicago", "startTime":"09:00", "endTime":"17:00" }, "spots":1, "venueData":{ "type":"physical", "name":"Tutorial design room", "address":"Training venue" }, "notificationTemplate":[] } }' ``` **You should see:** HTTP 200 and the definition record. Save its `sk` as `DEFINITION_ID`. Returning learners who reused the matching definition above already have this value and should not repeat the create request. > **This example uses PUT.** Both PUT and POST creation routes are now registered and verified locally. If you see the historical POST404 error on an older build, use this PUT example and report the installed version. In Studio, open **CRM → Reservations → Reservation Definitions** to review the service. The UI course explains office hours, blocked time, venue information and the business's notification choices. An empty notification-template list is not a general promise that booking has no notification side effects; the service also creates default reminder schedules. Use controlled training contacts. ### 2. Send a complete booking as the customer Switch back to the customer bearer. Save the following JSON as `reservation.json`, replacing the definition ID with your returned value and selecting a future available date for a fresh rehearsal. ```json { "reservationDefinitionId": "", "service": "Design consultation", "startTime": "2026-10-05T10:00:00-05:00", "endTime": "2026-10-05T10:30:00-05:00", "customer": { "email": "lina.events@example.invalid", "name": "Lina Tutorial" }, "partySize": 1 } ``` The offset is explicit. For this October date, 10:00 in Chicago is `-05:00`; do not use the same fixed offset for every date of the year. Your application should calculate the offset from the appointment date and business timezone. ```bash curl -sS -X POST "$API/client-data/reservations" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" \ -H 'Content-Type: application/json' --data-binary @reservation.json ``` **You should see:** HTTP 201 and a reservation with its own `sk`, generated reference and `data.status: "new"`. The fresh local booking has reference **HJTLSSU4** in `data.name`; Studio displays the final eight characters of its record ID, **3d2a0b45**, on the card. Your generated values will differ. Its saved customer ID matched Lina, and the saved timezone was **America/Chicago**. Retain this new record’s `sk` as `RESERVATION_ID` for the read-only ownership test in Part 4. This is the **reservation** ID, not `DEFINITION_ID`, the customer ID, or the short booking reference: ```bash RESERVATION_ID='' ``` ### 3. Read the saved booking from the customer's list Repeat `GET /client-data/reservations`. Find the returned reservation by `sk`; compare service, start/end, customer and status with the request. The repaired single-record route also works on the checked local build: ```bash curl -sS --write-out '\nHTTP %{http_code}\n' \ "$API/client-data/reservations/$RESERVATION_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" ``` Expect **200** and the reservation record, not a list envelope. Kofi's token requesting this same ID returns **404**. The customer's filtered list remains useful for listing bookings; neither path needs a staff credential in the browser. ### 4. Open the same booking as staff In Studio, open **CRM → Reservations**. On the **Reservations** tab, use the **Pipeline** view. The **New** column contains Lina's card with **Design consultation**, **Oct 5**, **10:00 AM** and her email. Reload and locate it again. The board's **CHANGE STATUS** control offers **New**, **Confirmed**, **Pending**, **Cancelled** and **Completed**. Booking creation did not automatically confirm the appointment; leave it New for this exercise. ![Fresh local staff board after customer creation and browser reload.](../application-fixes/assets/local-connected-client/04-reservations-reloaded.png) ### 5. Handle uncertain outcomes without blind resubmission In the historical 18 September run, a repeated identical booking POST created a second record, **R88D6C8W**, with a different `sk`. It did not return the first record or reject the occupied time in this test. ![Historical reloaded board after a repeated request created two bookings.](assets/appengine-client/08-two-bookings.png) Disable duplicate submits while a request is running. After a timeout, first read the customer's recent bookings and reconcile the request. A production booking service needs a server-enforced idempotency key and an atomic availability check; a disabled button alone does not protect against retries from another device. **Try it:** read back your booking in the customer list and staff board without sending another create request. **Check yourself:** HTTP 201 means a new record was created. If two attempts return different IDs, you now have two bookings, even if their times match. ## Part 3 — Build a client that handles real responses ### 1. Separate transport errors from empty data This helper works with the response shapes exercised above. It also handles an empty response body instead of assuming every successful request contains JSON. ```js async function request(path, { method = 'GET', body, token } = {}) { const response = await fetch(`${API}${path}`, { method, headers: { orgid: ORG, 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); const text = await response.text(); let result = text; try { result = text ? JSON.parse(text) : null; } catch {} if (!response.ok) { const error = new Error(result?.error || result?.message || text || `HTTP ${response.status}`); error.status = response.status; error.code = result?.code; error.action = result?.action; throw error; } return result; } ``` For the view, keep separate states: loading, loaded with records, loaded with no records, and failed. For a failed request, retain the error and a deliberate retry action; do not show “No bookings” as though the server successfully returned an empty list. ```js const result = await request('/client-data/reservations', { token: session.access }); if (!Array.isArray(result?.data)) throw new Error('Unexpected reservations response'); const bookings = result.data.map(record => ({ id: record.sk, reference: record.data.name, service: record.data.service, startsAt: record.data.startTime, timezone: record.data.timezone, status: record.data.status })); ``` For a small runnable check, download the [local response-check page](../application-fixes/assets/local-connected-client/client.html) into an empty folder. In that folder run: ```bash python3 -m http.server 4317 --bind 127.0.0.1 ``` Open `http://127.0.0.1:4317/client.html`. Fill **Local API origin**, **Organization ID** and the **Customer access token** from Lina's sign-in. Choose **Load bookings**. The page uses the helper above and keeps the token in memory without saving it to browser storage. Use only a customer token here. ![The local browser client displays Lina’s saved booking through the real API.](../application-fixes/assets/local-connected-client/06-browser-own-booking.png) Replace the token with Kofi's and load again: **No bookings** means a successful empty list. Clear **Organization ID** and load again: **Request failed (400)** means an error, with a retry instruction. Restore the organization and Lina's token, then load again to recover the booking. Invalid credentials produce a separate **401** failure. ![Kofi’s successful empty list in the browser.](../application-fixes/assets/local-connected-client/07-browser-empty.png) ![A structured missing-organization error stays distinct from an empty list.](../application-fixes/assets/local-connected-client/09-browser-missing-org.png) Close the page when finished and stop the temporary server with Ctrl+C. This is a local response-check page to adapt, not a production customer interface. ### 2. Refresh once, preserving the existing refresh value `POST /profile/customer/refresh` accepts `{ "refresh_token": "" }` with `orgid`. A valid refresh returned HTTP 201 and a new access token. With a bare refresh JWT, this build omitted the returned `refreshToken`; retain the old value if no replacement arrives. ```js const renewed = await request('/profile/customer/refresh', { method: 'POST', body: { refresh_token: session.refresh } }); if (!renewed?.token) throw new Error('Sign in again'); session.access = renewed.token; session.refresh = renewed.refreshToken || session.refresh; ``` Allow one refresh and one retry of a failed read, then return to sign-in if it still fails. Do not loop indefinitely. Do not automatically repeat a booking POST after refreshing if its original outcome is uncertain. The invalid-token response in this run also included `action: "refresh_token"`; the old claim that only expired tokens carry that action was incorrect. Expiry itself was not forced in this rehearsal. ### 3. Verify a profile edit rather than trusting its status Send a flat patch containing the supported profile fields: ```bash curl -sS -X PUT "$API/client-data/profile" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" \ -H 'Content-Type: application/json' \ --data '{"firstName":"Lina","lastName":"Tutorial Updated","phone":"+12025550147"}' curl -sS "$API/client-data/profile" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" ``` Expect **200** from the update and a fresh GET containing `data.lastName: "Tutorial Updated"` and `data.phone: "+12025550147"`. The checked local update returned a safe customer response; Kofi's fresh profile remained unchanged. Send profile fields only: do not include a record ID, organization, roles or password in this patch. The repair applies to this API, not every profile form in every app. The earlier screenshot below records the old empty-body/no-persistence behavior. The 19 September local requests and fresh readback replace that result for this build. Still check persistence before showing “Profile saved” in your own client. ![Historical profile no-op, refresh and duplicate-create results from 18 September.](assets/appengine-client/06-update-and-retry.png) **Try it:** feed an empty reservations envelope and a structured 400 error into your rendering function. The user-facing states should differ. **Check yourself:** why retain the old refresh value? The checked response issued a new access token without a replacement refresh value; overwriting it with `undefined` would break the next renewal. ## Part 4 — Prove access and choose a deployment boundary ### 1. Test two customers, including direct record access The fresh local check returned Lina's booking in her list and zero rows in Kofi's. Lina's single-record customer route returned **200**; Kofi's request for that ID returned **404**. The generic repository route returned **200** for Lina and **403** for Kofi, without booking data in the denial. Repeat these checks with your own records; a hidden menu or filtered list alone does not establish the boundary. ![Historical failed generic-read isolation check, before the local repair.](assets/appengine-client/05-access-check.png) The image above records the earlier leak. The corrected results are preserved in the [fresh local API transcript](../application-fixes/assets/local-connected-client/api-results.json). **Repeat the read-only check with your own two customers and booking.** The following commands reuse `API`, `ORG`, customer A’s `CUSTOMER_TOKEN`, customer B’s `SECOND_CUSTOMER_TOKEN`, and `RESERVATION_ID` from Part 2. Both customers must be controlled test identities in this organization. Do not use the staff bearer or `x-client-authorization` for either request. First confirm that the two tokens still identify different customers. These are GET requests with no body: ```bash curl -sS --write-out '\nHTTP %{http_code}\n' "$API/client-data/profile" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" curl -sS --write-out '\nHTTP %{http_code}\n' "$API/client-data/profile" \ -H "orgid: $ORG" -H "Authorization: Bearer $SECOND_CUSTOMER_TOKEN" ``` Both should return 200. Compare each record’s `sk` and `data.email` with your two test accounts; the IDs must differ. Customer A’s profile `sk` must equal the saved booking’s `data.customer.id` / `data.customerId`. If any identity does not match, stop before the cross-customer check. Now read exactly the reservation your first customer created. The route is **`GET /repository/get/reservation/`**, with no request body. It returns a single record, not the `/client-data/reservations` list envelope: ```bash # Control: customer A reads their own fictional booking. curl -sS --write-out '\nHTTP %{http_code}\n' \ "$API/repository/get/reservation/$RESERVATION_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" # Ownership check: customer B requests the same controlled booking ID. curl -sS --write-out '\nHTTP %{http_code}\n' \ "$API/repository/get/reservation/$RESERVATION_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $SECOND_CUSTOMER_TOKEN" ``` | Request | Expected result verified against the running local API | | --- | --- | | A’s own-record control | HTTP 200, `sk` equal to `RESERVATION_ID`, and A’s saved customer ID/reference in `data`. This establishes that the route and test record exist. | | B requesting A’s record | HTTP 403 with no booking/customer data in the response. HTTP 200 containing A’s record is a failed boundary check. The separate customer-specific single-reservation route uses 404 for inaccessible records; it is not the generic route exercised here. | A 401 means the session must be corrected; it is not proof of ownership enforcement. A 404 for both requests does not establish a successful denial: first resolve the missing route/record or wrong environment. If your policy deliberately denies *all* customer use of generic repository routes, record that separate policy and prove the owner’s supported `/client-data` read instead; do not claim the owner-allowed generic-read test passed. The fresh review used real customer sessions and a persisted local booking for both GETs. It did not query production records or infer an access result from a source-only test. These commands cover direct **reads only** and do not create, update or delete records. Update, delete and attachment access are separate operations requiring disposable owned fixtures and an explicitly scoped procedure; this course does not supply or certify those mutation tests. A filtered list or the two GETs above cannot certify those other operations. ### 2. Call on behalf of a customer from your server Use the `STAFF_TOKEN` established in Part 2 for this server-only example, with the customer token from Part 1. A separately registered application can also be a primary identity, but obtaining that credential is not part of this exercise: ```bash curl -sS "$API/client-data/reservations" \ -H "orgid: $ORG" \ -H "Authorization: Bearer $STAFF_TOKEN" \ -H "x-client-authorization: Bearer $CUSTOMER_TOKEN" ``` On the checked local API, this returned Lina’s one booking; substituting Kofi’s customer bearer returned zero. The single-record and generic ownership denials also held with a primary staff bearer and delegated Kofi identity. The organization identifies the company, the primary bearer identifies the trusted server-side staff identity in this example, and the second bearer supplies the customer's identity. Derive that identity from your authenticated session; do not accept an arbitrary customer's email from a browser and treat it as authorization. ### 3. Understand the API-key screen Open **Account → API keys → Create New Key**. The captured form has **API Key Name**, **Description**, **Permissions**, per-minute/hour/day **Rate Limits**, **Expiration Date**, **Cancel** and **Save API Key**. ![Fresh local API-key form, opened without saving a key.](../application-fixes/assets/local-connected-client/02-api-key-form.png) The Permissions section rendered without selectable scope entries in this run. No new key was created for the customer exercise. The screen's scope explanations should not be mistaken for verified enforcement: source review shows keys resolve to their creator's privileges. Use a dedicated appropriately restricted staff identity, store keys server-side, and test permissions at the API boundary. Do not put an owner key into a downloaded app or browser bundle. ### 4. Choose a client adapter without changing the API contract The local JavaScript client source exposes `createClient`, `auth.signIn` and generic `get` calls. Its sign-in is the customer flow. The Flutter client separates customer and staff authentication; its `updateFields()` uses the partial-update route, while the inspected `update()` route does not match the server's generic update route. These are source notes, not a package-install or SDK execution result from this rehearsal. Start with the HTTP calls above, then check your installed SDK version against them. Hosted **Configuration → Scripts**, the `window.appmint` runtime, chat embedding and Vibe-generated interfaces are alternate presentation paths. They do not remove the need for response handling, correct identity or access testing. Follow [Customer chat](appmint-run-customer-conversations.md) and [Vibe Studio](appmint-vibe-build-a-client-app.md) for those user-facing workflows. The local response-check page at `http://127.0.0.1:4317` successfully called `http://localhost:3300`: its preflight returned **204** and allowed the organization/content-type/authorization headers; actual GET responses included `Access-Control-Allow-Origin: *`. The browser loaded records, an empty list and structured errors. For browser deployment, test your actual origin. Production CORS uses `ALLOWED_ORIGINS` when configured; the current source otherwise reflects origins. A successful curl request does not establish browser access. Check the preflight and response headers rather than assuming localhost is always blocked or always allowed. **Try it:** repeat the working read through your server with both identities and compare the returned customer with a direct customer-session read. **Check yourself:** does a staff key's name “read only” restrict it? A descriptive name is not an access policy. Verify the creator's permissions and the server's enforcement. ## If something goes wrong ![Sanitized examples of the actual authentication errors.](assets/appengine-client/07-auth-errors.png) | Symptom | First check | Next action | | --- | --- | --- | | Host does not resolve | API origin | Use the checked service address or your operator's configured origin. | | 400 `missing_orgid` | Company header | Send `orgid` consistently. | | 400 `bad password` | Customer route and credentials | Correct the customer sign-in details; staff and customer routes differ. | | 401 `missing_authorization_header` | Bearer header | Add the access credential with `Bearer ` and a space. | | 401 `invalid_token` | Valid session from this environment | Renew once if appropriate, otherwise sign in again. | | Staff dashboard returns customer-not-found | Identity type | Use customer sign-in or both application/customer headers. | | List exists but UI is empty | Response envelope | Read `response.data`, then each record's `data`. | | Dashboard upcoming count is zero | Actual booking status | Read the reservation list; `new` does not match aggregate `upcoming`. | | Single booking route returns empty or 404 for its owner | Correct environment, session and reservation ID? | The checked local route returns one record for its owner; verify your deployed build and compare the filtered list. | | Profile says saved but readback is unchanged | Flat supported fields and current API build | Repeat GET; the checked repair persists firstName/lastName/phone. Do not treat a success status alone as persistence. | | Duplicate booking | Repeated POST or uncertain timeout | Reconcile IDs; add server idempotency before automated retries. | | Repository create POST returns 404 | HTTP method | Use the demonstrated PUT route. | | Another customer can read a generic record | Server access policy | Fix ownership enforcement and retest; menu changes do not repair it. | ## What happened behind the scenes
Implementation and evidence for developers Read `appengine/src/client-account/client-account.controller.ts` and `client-account.service.ts` for the `/client-data` routes, record wrappers, dashboard's literal status filter and the repaired profile-update validation/persistence. `crm/reservations.service.ts` validates the definition/service, constructs the reservation, links the customer, saves `new` and creates notifications/reminders. Its single-record ownership lookup is now repaired and verified through the running API. The historical duplicate-create behavior is distinct from that read repair. Authentication and refresh are in `users/users.controller.ts`, `users/users.service.ts`, `middlewares/current-user.middleware.ts` and `users/auth/jwt.auth.guard.ts`. Refresh currently splits the provided refresh string when returning a replacement value. Generic routes and permissions are in `repositories/repository.controller.ts`; a customer list and a generic record read are separate paths. API-key handling is in `users/apikey.controller.ts`, its service and the identity middleware. Client adapters are in `appmint-client/appmint_js_client/src/index.ts` and `appmint_flutter_client/lib/src/repository.dart`. CORS configuration is in `appengine/src/main.ts`. Evidence: [initial reads](assets/appengine-client/initial-checks.json), [definition](assets/appengine-client/definition-create.json), [booking request](assets/appengine-client/reservation-request.json), [first create](assets/appengine-client/reservation-create.json), [two-customer readback](assets/appengine-client/booking-readback.json), [two-identity reads and repeated create](assets/appengine-client/additional-checks.json), [capture ledger](assets/appengine-client/evidence.json).
## Where next - [Connect a supplier API](appengine-connect-a-custom-business-api.md) — server-side provider calls. - [Build an AI business assistant](appengine-build-an-ai-business-assistant.md) — tool discovery and controlled execution. - [Vibe Studio](appmint-vibe-build-a-client-app.md) — a generated interface connected to these data contracts. **Historical evidence, 18 September:** normal staff/customer sign-in, profile/dashboard/orders/reservations reads, free definition and booking creation, Studio board readback, two-account list/direct-record checks, two-identity headers, wrong-password/missing-header/malformed-token errors, valid refresh, nonpersisting profile update and repeated-create behavior were exercised on the local training organization. Public production health was checked; production business-data access, forced expiry, SDK execution, API-key creation and public-client deployment were not performed. No new webpage was built. [Video production guide](production/appengine-build-a-connected-web-or-mobile-client.md). **Fresh learner verification, 19 September 2026:** used controlled local organization `ck-local-mu83iwh3`, newly created Lina/Kofi customers and one new booking. Actual local API and browser checks passed for signup/sign-in, profile/list/orders/dashboard, definition creation and reuse, reservation creation/list/single read, Studio reload, customer profile persistence, refresh, direct and delegated ownership denials, API-key form inspection, and browser loaded/empty/400/401/retry states. Customer signup and booking mail reached an organization-scoped loopback SMTP catcher; raw mail and credentials remain private. No production access, external provider send, second booking POST, API-key creation, SDK package execution, forced expiry, reservation mutation/file security test or public deployment was performed. [Verification report and evidence](../application-fixes/connected-client-local-walkthrough.md). --- # Bring supplier availability into Appmint with a verified API handoff > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appengine/appengine-connect-a-custom-business-api.html # Bring supplier availability into Appmint with a verified API handoff ![Actual local Notes API readback after the supplier handoff and API restart; this is a sanitized transcript.](../application-fixes/assets/local-custom-api/09-note-readback.png) *Northwind's fixture returned 12 units and a three-week lead time. AppEngine retained the timestamped result on the consultation record.* **Who:** developers connecting an existing business system, with administrators reviewing gateway configuration. **Time:** 60 minutes. **Level:** developer integration, then provider extension. **Product:** AppEngine and Studio Gateway Manager. **Checked:** 19 September 2026, running local AppEngine 0.132.0 and Studio 0.6.2, using the learner-owned organization and reservation from the connected-client course. **Example:** Cedar & Form checking Northwind Timber SKU `NW-OAK-24` for Lina's consultation. ## What you will have at the end You will inspect the actual provider catalog, call a controlled supplier API, reject bad responses, and save a useful result against an Appmint business record. You will distinguish a registered Gateway Manager provider from a server-side adapter that you own, and understand where email configuration and inbound webhooks fit. **Current build boundary:** the checked registry has no generic HTTP/REST provider and no Zapier provider. This course's successful supplier call uses the supplied server-side adapter, followed by AppEngine's supported Notes API. It is not presented as a working arbitrary-HTTP button inside Gateway Manager. The provider-extension section explains how to bring the same contract into the registry when that capability is implemented. ## What you need - Organization ID and staff authentication from [Connect a client to AppEngine](appengine-build-a-connected-web-or-mobile-client.md). - The training reservation you created in Part 2 of the connected-client course. Reuse its `RESERVATION_ID`, your `ORG`, and your private `STAFF_TOKEN`; do not use the historical author's reservation. The fresh local run used Lina’s booking from that prerequisite. - Node.js with built-in fetch, two terminal windows and an isolated training environment. - [The supplier fixture](assets/appengine-gateway/examples/supplier-fixture.mjs) and [the server-side integration example](assets/appengine-gateway/examples/check-supplier.mjs). The fixture's `tutorial-fixture-only` header value is deliberately public test data, not a real supplier credential. Replace it with your actual server-held credential when implementing a real integration. Do not put staff credentials or supplier secrets into a downloaded browser bundle. ## The story and route A client is choosing an oak worktop. Staff need a current stock figure and lead time before making a promise. The useful output is a dated note attached to the consultation, not a disconnected JSON response or an unverified green toast. ```text Your server → supplier availability endpoint → validate status and body ↓ AppEngine Notes API → consultation's saved notes Future registered provider path: Your server → AppEngine /upstream/call// → supplier ``` The saved note is a snapshot. It does not reserve stock, place an order or guarantee the supplier still has that quantity tomorrow. ## Part 1 — Understand Gateway Manager's actual controls ### 1. Open the integration area In Studio, select **Configuration → Integration Config**. The page is **Gateway Manager**, with **Configurations**, **Integration Builder** and **API Playground**. ![Fresh local Gateway Manager configuration list.](../application-fixes/assets/local-custom-api/01-gateway-configurations.png) The fresh learner organization already has the loopback mail-capture configuration used for the connected-client prerequisite. It appears in this list; a new empty organization instead shows **No Configurations Found**. Use **New Configuration** to start a configuration when needed. A saved configuration is a company’s instance of a provider; it is different from the catalog of implementations. ### 2. Inspect the catalog rather than inventing a provider name Select **Integration Builder**. The checked UI counted **58 integrations available**, including blank/helper entries that are registry defects, so that number should not be interpreted as 58 proven user journeys. ![Fresh local provider catalog.](../application-fixes/assets/local-custom-api/02-provider-catalog.png) In **Search integrations...**, enter `HTTP`. The result showed **0 integrations available**. ![HTTP search returns zero providers on the checked local build.](../application-fixes/assets/local-custom-api/03-http-search.png) You can read the same registry with: ```bash curl -sS "$API/upstream/integration-types" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` Each usable entry supplies its name, configuration schema and operations. The current list includes providers such as `SMTPProvider`, `MailgunProvider`, `ResendProvider`, `SendGridProvider`, `TwilioProvider`, `StripeProvider` and sales-channel providers. Exact casing matters. It did not include the old topic's proposed generic provider or Zapier fallback. ### 3. See how a real provider supplies its fields Clear the search, enter `SMTP`, then select **SMTPProvider**. The form includes **Name**, **Type**, **Use Cases**, **Priority**, **Provider**, **Email**, **Host**, **Port**, **Tls**, **Secure**, **Auth → User / Pass**, and **Send outgoing mail through this server**. It has **Test**, **Save** and **Cancel**. ![Fresh unsaved SMTP configuration form, with no credentials entered.](../application-fixes/assets/local-custom-api/04-smtp-schema.png) These are SMTP-specific fields. They are not a place to paste an arbitrary supplier REST URL. Inspect this form, then choose **Cancel**. This step does not save another mail connection or invoke **Test**; the prerequisite’s local mail catcher remains separate. The Priority helper says higher-priority eligible gateways are chosen first. Provider transport configuration is separate from **CRM → Email Accounts**, where business mailbox/sender workflows live. A working transport does not by itself establish which employee should send from which address. ### 4. Read the Playground's prerequisites Select **API Playground**. It offers **Auth Method: API Key / Auth Token**, **Select API Key...**, **Manage Keys** and **Available Configurations**. The local test-mail configuration appears by name; select it to inspect its `sendEmail` operation schema without executing the operation. ![The repaired Playground lists the learner’s existing SMTP configuration.](../application-fixes/assets/local-custom-api/06-playground-fixed.png) A saved enabled configuration is available here even if the backend has not yet initialized a live instance in its current process. After a restart, the authenticated `GET /upstream/active` list can be empty until a provider is used. Once the owned SMTP transport was used locally, this endpoint returned **200** with its ID, configuration ID, provider and operation names. It returns metadata, not transport internals or credentials. The earlier build returned500 when serializing a live SMTP transport and hid saved configurations whose optional status was absent. Both defects were fixed and retested in this local walkthrough. If configuration loading fails, the Playground now displays **Unable to load integrations** and **Retry** instead of a false empty state: ![Controlled load failure is distinct from an empty configuration list.](../application-fixes/assets/local-custom-api/07-playground-load-error.png) The Playground still cannot execute an unregistered supplier provider. Do not choose a payment or SMS preset merely to test that the interface reacts. **Try it:** select a provider relevant to your own business and compare its form fields with its returned schema. Keep credentials private and avoid saving a connection until you know what its test operation does. **Check yourself:** does a catalog entry mean your company already has a working connection? No. Implementation, saved configuration, active instance and successful operation are separate checks. ## Part 2 — Make a real supplier call with the supplied adapter ### 1. Start the controlled supplier Download the fixture into a working directory and run: ```bash node supplier-fixture.mjs ``` It listens only on your computer's loopback interface, port 4311, and prints a ready message. This address is a developer fixture, not a website link customers should use. The fixture contract is: | Request | Result | | --- | --- | | `GET /availability?sku=NW-OAK-24`, correct test header | 200, 12 units, three weeks | | Same route with `sku=EMPTY` | 200, zero units, three weeks | | Wrong `x-supplier-key` | 401, invalid training key | | `sku=FAIL` | 503, temporary supplier failure | ### 2. Call it directly once In the second terminal: ```bash curl -sS 'http://127.0.0.1:4311/availability?sku=NW-OAK-24' \ -H 'x-supplier-key: tutorial-fixture-only' ``` The actual response was: ```json { "sku":"NW-OAK-24", "available":12, "leadTimeWeeks":3 } ``` ![Actual success, empty-stock, credential and availability-error responses.](assets/appengine-gateway/07-supplier-results.png) The `EMPTY` response is successful HTTP with a valid business answer of zero stock. Test numbers explicitly; `if (!available)` would incorrectly classify zero as a missing value. ### 3. Configure the server-side example Supply these environment values privately to `check-supplier.mjs`: | Variable | Value | | --- | --- | | `APPMINT_API` | Your AppEngine base URL | | `APPMINT_ORG` | Your organization ID | | `APPMINT_STAFF_TOKEN` | Staff bearer from normal sign-in | | `SUPPLIER_URL` | `http://127.0.0.1:4311` for this fixture | | `SUPPLIER_KEY` | `tutorial-fixture-only` for this fixture | | `RESERVATION_ID` | Your own training reservation's `sk` | For a real deployment, put credentials in the server's secret configuration. Keep them out of shell recordings, source control and client-side code. The script intentionally refuses to start if a required variable is absent. ### 4. Run the availability-to-note handoff ```bash node check-supplier.mjs NW-OAK-24 ``` The supplied script performs the full sequence: 1. Builds the supplier URL with an encoded SKU. 2. Applies a five-second request deadline. 3. Checks the supplier's HTTP status. 4. Requires the requested SKU and nonnegative numeric availability/lead time. 5. Adds a timestamp and writes one note to the training reservation. 6. Reads the notes back and requires an exact match. The exact downloadable script was executed successfully in this rehearsal and printed `noteVerified: true`. It performs one intentional note write per successful invocation; it is not a continuously polling service. ### 5. Understand the AppEngine write The business-record handoff uses: ```bash curl -sS -X POST "$API/notes/reservation/$RESERVATION_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \ -H 'Content-Type: application/json' --data '{ "comment":"Tutorial Northwind Timber check: NW-OAK-24; 12 units available; lead time 3 weeks. Checked . Supplier fixture, not a placed order." }' ``` The checked response was HTTP 201 with `author`, `comment` and `date`. Then: ```bash curl -sS "$API/notes/reservation/$RESERVATION_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` returned an array containing the same note. Notes append to the parent record's `notes` array. This example does not invent a “project” datatype or claim that every Studio screen renders those notes; the demonstrated persistence check is the Notes API. **Try it:** run the supplier-only `EMPTY` request and explain the business answer. Do not promise stock until you have checked the actual supplier again at the decision point. **Check yourself:** does the saved note update automatically when supplier stock changes? No. Its timestamp identifies when the snapshot was taken. ## Part 3 — Handle errors without writing misleading business data ### 1. Reject bad credentials before saving Call the fixture with the wrong test key. It returns 401. Fix the credential source; do not retry the same rejected key in a tight loop. Keep supplier authentication separate from AppEngine authentication. A supplier 401 concerns the outbound header; an AppEngine 401 concerns the company-side session. Record which host returned the error. ### 2. Stop on a temporary supplier failure Run: ```bash node check-supplier.mjs FAIL ``` The exact example exited with code 1 and `Supplier HTTP 503: Training supplier temporarily unavailable`. It stopped before the AppEngine note write. The earlier successful note remained a dated historical check, not a new claim about current stock. For a read operation, a bounded retry with backoff can be appropriate. For order placement, a timeout may occur after an order was accepted; reconcile by supplier operation ID before repeating a write. Do not reuse a read-retry policy for purchases. ### 3. Distinguish malformed data from valid zero stock The script requires matching SKU and finite nonnegative numbers. To exercise those failures, download the separate [fault-only supplier fixture](../application-fixes/assets/local-custom-api/supplier-fault-fixture.mjs). In another terminal run: ```bash node supplier-fault-fixture.mjs ``` It listens only on loopback port4312 and deliberately returns unusable replies. Keep the same private AppEngine variables, but override the supplier URL for each failing check: ```bash SUPPLIER_URL=http://127.0.0.1:4312 node check-supplier.mjs MISMATCH SUPPLIER_URL=http://127.0.0.1:4312 node check-supplier.mjs NEGATIVE SUPPLIER_URL=http://127.0.0.1:4312 node check-supplier.mjs NONNUMERIC SUPPLIER_URL=http://127.0.0.1:4312 node check-supplier.mjs MISSING SUPPLIER_URL=http://127.0.0.1:4312 node check-supplier.mjs BADJSON SUPPLIER_URL=http://127.0.0.1:4312 node check-supplier.mjs SLOW ``` The first four exit1 with an unexpected-contract error. `BADJSON` fails JSON parsing. `SLOW` exceeds the adapter’s five-second deadline and raises `TimeoutError`. After each attempt, repeat the Notes GET from Part 2.5: the previous successful note must remain unchanged and no new note should appear. These are deliberately faulty test-server responses, not simulated AppEngine writes. All six were exercised against the actual adapter and persisted local reservation. Stop the fault fixture with Ctrl+C when finished. Extend the validated contract to the actual supplier's documented units, currency, stock location and lead-time meaning. For example, three calendar weeks and fifteen working days are not interchangeable promises. If validation fails, stop before writing a new result. An HTTP 200 with an error object or a missing field is not a usable availability answer. ### 4. Diagnose the gateway boundary separately The following request to the absent provider returned404 on the running local API: ```bash curl -sS -X POST "$API/upstream/call/TutorialNorthwindProvider/availability" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \ -H 'Content-Type: application/json' --data '{"data":{"sku":"NW-OAK-24"}}' ``` Omitting `orgid` from discovery returned 400 `missing_orgid`; omitting authentication returned 401 `missing_authorization_header`. ![Actual missing-provider and authentication errors.](assets/appengine-gateway/09-gateway-errors.png) Changing the display name of a saved configuration cannot install a missing implementation. Use the tested adapter path until the provider is available. **Try it:** compare success, zero stock, supplier 401, supplier 503 and AppEngine missing-provider responses. Decide which, if any, can produce a new business note. **Check yourself:** should a failed fresh check erase the timestamp on an older successful check? No. Preserve the old observation and identify the new failure separately. ## Part 4 — Optional maintainer extension: bring the adapter into the registry The supplier-call-and-note walkthrough is complete after Part 3. The following is a separate maintainer implementation project; it is not required to run the supplied adapter and was not deployed during this course. 1. Add a provider class extending `IntegrationProvider` in AppEngine's integrations source. Its configuration schema should define the supplier base URL and server-held authentication fields; credentials should use password-style form fields. 2. Implement `init`, `doOperation`, `shutdown`, `test` and `postSave`. Declare only supported operations, for example `availability` and a read-only connection test. Reuse the validated request/response contract from the supplied example. 3. Export the class from `src/integrations/index.ts`. The registry loads exports at startup; a local file that is never exported will not appear in discovery. 4. Build and test in an isolated environment. Verify 12 units, zero units, invalid credentials, upstream 503, timeout and malformed payload. Confirm that failure cannot write a successful availability note. 5. Read `/upstream/integration-types/` from the deployed test build. Confirm the schema and operation names before documenting them as UI steps. 6. Save a configuration through the generated form, reload it, execute the read-only operation and compare its output with the direct fixture. Only then replace the adapter path in this course with a demonstrated gateway path. The current provider ID convention is `-`. `POST /upstream/save-integration` expects a configuration **record envelope** with `data.provider` and provider-specific fields, not the old speculative `{provider,name,config}` body. Use the current generated form or a verified envelope from your deployed implementation. The gateway is server-side, but the configuration UI necessarily handles entered credentials. Do not claim credentials were never in an administrator's browser; the important boundary is that they are not shipped to the customer-facing app. ### Email and inbound webhooks Email transport providers are existing integrations, while employee/customer mailbox workflows are separate configuration. Test transport with an address you control, then test the intended sender identity and delivery result. The connected-client prerequisite used a loopback-only mail catcher. This supplier handoff does not send an external email or require new email credentials. The generic connect route is `/connect/webhook/:vendor/:serviceId?`. The controller accepts organization context from `orgid` header or query. The optional path segment is **serviceId**, not a guaranteed organization slot. The middleware's no-header fallback is inconsistent with the old topic's proposed URL. Do not hand a supplier an invented `/connect/webhook/northwind/` address and assume it works. A custom inbound integration needs a real vendor handler, verified signature handling, replay/idempotency rules, organization resolution and a controlled delivery test. A public route declaration alone does not make it a safe catch-all webhook receiver. ### Automate only after the contract works Use [background jobs](appengine-run-reliable-background-jobs.md) or [business automation](appmint-automate-a-business-handoff.md) after a single availability check and note readback work reliably. Add a freshness rule and duplicate-write policy before scheduling repeated checks. This Notes API is append-only: repeating a successful script adds another note. ## If something goes wrong | Symptom | First check | Next action | | --- | --- | --- | | No HTTP provider | Running catalog | Use the tested server adapter; do not invent a provider name. | | Catalog count includes blank entries | Entry schema and name | Treat as registry defects, not working integrations. | | No available configurations | Saved provider and explicit status | A saved provider with no status is enabled; explicitly inactive/error/testing entries are excluded. Use Retry for a load error. | | Active endpoint returns500 | Running backend version | The repaired local endpoint returns safe metadata; update/retest the affected build. | | Wrong supplier key | Outbound credential | Correct it; no business write on 401. | | Zero available | Valid response body | Report out of stock; zero is a valid number. | | Supplier 503 | Supplier availability | Stop the write, preserve old timestamp, retry reads deliberately. | | Note appears twice | Script invoked twice | Add an operation/check identifier before automation. | | Gateway provider 404 | Exact registered class/instance | Install and verify implementation before configuring it. | | Webhook goes to the wrong company | Header/query and vendor handler | Test explicit organization resolution; do not guess path semantics. | | Transport works but sender is wrong | CRM Email Accounts | Configure the business sender workflow separately. | ## What happened behind the scenes
Source and evidence Gateway UI: `websitemint/packages/ui/src/components/configuration/gateway/` contains builder, list, playground and store. AppEngine's `upstream.controller.ts`, `upstream.service.ts` and `upstream.register.ts` own discovery, configuration envelopes, in-memory active instances and dispatch. `integrations/integration.provider.ts` defines the extension contract. `notes/notes.controller.ts` and `notes.service.ts` implement the append/read handoff. `connect/connect.controller.ts` and the current-user middleware explain the webhook organization mismatch. Evidence: [supplier and Notes API rehearsal](assets/appengine-gateway/supplier-rehearsal.json), [exact example execution](assets/appengine-gateway/example-check.json), [provider discovery](assets/appengine-discovery/upstream-integration-types.json), [active-list error](assets/appengine-discovery/upstream-active.json), [capture ledger](assets/appengine-gateway/evidence.json). The fixture and adapter source are linked in prerequisites.
## Where next [Operate and extend AppEngine](appengine-operate-and-extend-the-platform.md) covers request tracing and release checks. [Background jobs](appengine-run-reliable-background-jobs.md) covers the difference between a schedule, queued work and a persisted result. **Historical evidence, 18 September:** Gateway tabs, catalog, HTTP search, SMTP form and Playground inspected live; loopback supplier success/zero/401/503 exercised; supported note append/readback completed; exact downloadable adapter tested for success and 503; missing-provider and auth errors verified. No native generic-HTTP configuration, SMTP save/send, external supplier order, inbound webhook delivery or provider deployment is claimed. [Video production guide](production/appengine-connect-a-custom-business-api.md). **Fresh learner verification, 19 September 2026:** authenticated Gateway tabs/catalog/HTTP search/SMTP form/Playground inspected with the learner’s own staff account. Exact downloadable supplier fixture and adapter completed one note append/readback on the prerequisite reservation. Zero stock,401,503, missing configuration, mismatched SKU, negative/string/missing numeric fields, malformed JSON and timeout were exercised; failing adapter runs left the saved note unchanged. The note survived a local API restart. `/upstream/active` serialization and Playground configuration/error handling were repaired, regression-tested, rebuilt and verified against the actual local API/UI. [Walkthrough and application-fix report](../application-fixes/custom-api-local-walkthrough.md). No generic-provider implementation/deployment, external supplier order, production call or inbound webhook delivery is claimed. --- # Schedule a real action, diagnose a failed job and recover it > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appengine/appengine-run-reliable-background-jobs.html # Schedule a real action, diagnose a failed job and recover it ![Actual successful schedule, changed target and saved run history.](assets/appengine-jobs/04-success-readback.png) *The schedule became `done`, the target note became `ready`, and one run recorded `success`. These are three separate observations.* **Who:** developers and operators maintaining background work. **Time:** 60 minutes, including due-time checks. **Level:** developer; platform operations afterwards. **Product:** AppEngine schedules, Bull queues and Studio Schedule Management. **Checked:** 24 September 2026, local training organization. **Example:** an internal training note that changes status; no messages, payments or external services. ## What you will have at the end You will create a one-off schedule, find its delayed queue job, wait for a useful action and inspect the saved result. You will then diagnose a malformed target, repair the same schedule and inspect its successful run. Finally, you will understand the current pause and deletion behavior instead of trusting a label or toast. The distinction matters: a database schedule is a plan; a queue job is work waiting or executing; a run record is an activity report; the target record tells you what changed. ## What you need - Your organization ID and a staff bearer from [Connect a client to AppEngine](appengine-build-a-connected-web-or-mobile-client.md). - An AppEngine environment with MongoDB and its Redis-compatible queue service available. Use an isolated training organization. - Studio access to **AI, IVR, Automation → Schedule**. - curl or a small server-side script. Set `API`, `ORG` and `STAFF_TOKEN` privately. The checked production origin is `https://appengine.appmint.io`; this rehearsal used the operator's local server. Use fresh future times. Copying a due time from these September captures will not schedule a new future action. The helper below calculates a time from your current clock. ## The story and route Before scheduling appointment reminders or social posts, the developer wants to prove the mechanics with an internal record. The target starts **waiting**. A job changes it to **ready**. A deliberately incomplete target then shows how a job can fail while the overview still calls its schedule active. ```text Saved schedule → delayed queue job → due-time worker → target update │ │ │ │ record read org-schedules queue details target read └── activity → schedule-runs ``` ## Part 1 — Run one harmless action ### 1. Open the Schedule module Select **AI, IVR, Automation → Schedule**. The screen title is **Schedule Management**. Its tabs are **Overview**, **Upcoming**, **Runs** and **Logs**, with **New Schedule**, **Refresh** and status filters. ![Schedule Management showing both completed jobs and two future training jobs.](../application-fixes/assets/jobs-local/07-overview-fixed.png) The overview separates **Database** schedules and **Redis Queue** entries. A completed one-off job can disappear from Redis while its database record remains. **Completed** counts database status `done`; **Paused** counts `stop`. A future one-off job shows its start date and countdown. These summaries help you navigate; the target read below proves the intended business change. ### 2. Create the target record Create a note used only by this tutorial: ```bash curl -sS -X PUT "$API/repository/create" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \ -H 'Content-Type: application/json' --data '{ "datatype":"note", "isNew":true, "data":{ "name":"Tutorial schedule status target", "title":"Tutorial schedule status target", "content":"Harmless training record; no external actions.", "status":"waiting" } }' ``` Save its `sk` as `TARGET_ID`. The returned `data.status` should be `waiting`. This is an internal repository record, not a customer message or a note already used by another workflow. ### 3. Calculate the due time and save the schedule For example, print an ISO time two minutes ahead: ```bash node -e 'console.log(new Date(Date.now()+120000).toISOString())' ``` Put that value and the target ID in `schedule.json`: ```json { "datatype":"schedule", "isNew":true, "data":{ "name":"tutorial-once-status", "title":"Tutorial mark internal note ready", "type":"simple", "status":"active", "target":[{"datatype":"note","id":""}], "start":{ "date":"", "action":"update-status", "actionArgs":"ready" } } } ``` ```bash curl -sS -X PUT "$API/repository/create" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \ -H 'Content-Type: application/json' --data-binary @schedule.json ``` Save the schedule's returned `sk` as `SCHEDULE_ID`. The target needs **both** `datatype` and `id`; the action needs its intended value in `actionArgs`. `type: "simple"` creates a one-off start job when a future `start.date` exists. An end job is created only when a valid later `end.date` is supplied. This example created **one**, not two, queue jobs. ### 4. Confirm it reached the queue ```bash curl -sS "$API/queue-manager/org-schedules" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` Find `queues[].schedules[]` where `data.sk` equals your schedule ID. The rehearsal showed: | Field | Observed value or pattern | | --- | --- | | Queue | `schedule-queue` | | Job name | `start` | | Job ID | `--start` | | State | `delayed` | | `scheduledFor` | The requested future ISO time | | `opts.attempts` | `3` | | `opts.removeOnComplete` | `true` | A successful schedule save does not establish that a job was queued. If the date is already past, the queue helper rejects it internally; inspect the queue rather than waiting for a time that has passed. ### 5. Check the result after the due time Use separate reads: ```bash curl -sS "$API/repository/get/schedule/$SCHEDULE_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" curl -sS "$API/repository/get/note/$TARGET_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" curl -sS "$API/queue-manager/schedule-runs/$SCHEDULE_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` The schedule became **`done`** with `lastRun`. The target became **`ready`**. The run endpoint returned **`totalRuns: 1`**, with status **`success`**, type **`update`**, and a comment beginning **Schedule started**. > **Use the returned status vocabulary.** The observed run status was `success`, not `completed`. Filtering history for a status that was never written can hide the run you need. ### 6. Compare the UI without losing the API evidence In Studio, open **Runs**. Your successful activity appears as **completed** with its execution time and schedule ID. Select **View Logs** on that row to read the recorded action. Match the ID rather than relying on similar names. ![Two actual successful activities in the repaired Runs tab.](../application-fixes/assets/jobs-local/09-runs-fixed.png) ![The selected run's recorded details.](../application-fixes/assets/jobs-local/10-run-details.png) The API calls a successful activity `success`; Studio displays it as **completed**. Use `status=success` when filtering the API. The schedule-statistics endpoint also returned 200 in this rehearsal. If a summary fails to load, read the target and ID-specific history before retrying an action that may already have succeeded. ## Part 2 — Fail one job, then repair the same schedule ### 1. Introduce a controlled input error Use another schedule named `tutorial-recover-status`, with title `Tutorial recover a malformed target`, a future date and action `update-status` with `actionArgs: "reviewed"`. For this isolated failure exercise only, omit the target's datatype: ```json "target": [{ "id": "" }] ``` The create request returned 200; validation did not reject the incomplete target. When due, the worker failed with: ```text Cannot read properties of undefined (reading 'datatype') ``` The queue retained the failed job with **`attemptsMade: 3`**. No external destination was involved. ### 2. Read the failure at the layer that recorded it The database schedule still said `active`, and `schedule-runs/` returned zero runs. The failure happened before the worker wrote its successful activity. Therefore neither an active record nor empty activity history established a healthy job. An authorized operator can inspect `GET /queue-manager/details/schedule-queue` and find the exact job ID inside `failedJobs`. This endpoint can include jobs from other organizations; restrict any saved diagnostic output to the job being investigated and apply proper access controls to the operational endpoint. ![The actual failed job and successful recovery history.](assets/appengine-jobs/05-failure-recovery.png) Do not use `GET /queue-manager/queue/schedule-queue` as a status endpoint; that returned 404. The similarly named **POST** route enqueues work and is a different operation. ### 3. Read the latest raw schedule and correct it Retrieve `GET /repository/get/schedule/`. Keep its current `pk`, `sk`, `datatype`, version and business fields. Add `datatype: "note"` to the target, calculate a new future start time, and retain `actionArgs: "reviewed"`. Submit the complete current record through: ```bash curl -sS -X POST "$API/repository/update/$FAILED_SCHEDULE_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \ -H 'Content-Type: application/json' --data-binary @corrected-schedule.json ``` The rehearsal returned 201 and incremented the version to 1. Updating schedules invokes removal/recreation of their keyed queued work. Do not submit an old enriched list object or drop fields you intend to preserve. ### 4. Confirm the corrected run After its new due time, the same schedule read `done`; its run history contained one `success` activity for the corrected action. The earlier failed queue attempt had been captured before repair, because replacing the keyed job changes what remains in the queue. Read the recovery target immediately using the same repository GET from Part 1. Its `data.status` must now be `reviewed`. The fresh rehearsal captured this exact value, the same schedule ID marked `done`, and its successful activity together. Use separate targets for later exercises so another action cannot overwrite this evidence. [Actual repair readback](../application-fixes/assets/jobs-local/06-repair-result.json). **Try it:** compare the original malformed target with the corrected target. Only the second supplies the record type the worker needs. **Check yourself:** should you increase retries for a missing datatype? No. The same invalid input fails repeatedly until corrected. ## Part 3 — Understand pause and cancellation before relying on them Use two new notes and two new schedules so this check cannot overwrite your successful recovery. Repeat Part 1.2 for each note, naming them **Pause safety target** and **Delete safety target**, both initially `waiting`. Repeat Part 1.3 with distinct schedule names **Pause practice** and **Delete practice**, their respective note IDs, `actionArgs: "should-not-run"`, and start times at least five minutes ahead. Save all four IDs. Confirm both delayed jobs in `org-schedules` before continuing. ### 1. Pause one future schedule 1. Open **Overview**, select **Refresh**, and find **Pause practice** in **Database Schedules**. 2. Select its pause icon, labelled **Pause Schedule**. Wait for the save to finish, then refresh. 3. Read that schedule through `GET /repository/get/schedule/`. Its status should be `stop`; Studio's **Paused** counter should increase. 4. Read `org-schedules` again. Its keyed start job should be absent. 5. After the original due time, read **Pause safety target**. It must still be `waiting`, with no successful execution activity for this schedule. The rehearsal passed all five checks. The worker checks the saved status again before acting, covering work picked up around the time a pause is saved. Pause cannot undo an external action that already began. **Resume carefully:** the play icon saves `active`. It does not replace an expired start date or mean “run immediately.” Before resuming an overdue one-off schedule, use **Schedule Settings** to give it a new future start time, then confirm the new delayed job. In **Schedule Settings**, check **Target records**, set **Run at** under **When**, and select **Save Schedule**; preserve the action and its target status. The local rehearsal verified this save, play, requeue and due-time execution. Do not change the date of a customer's live job while practising. ### 2. Delete the other future schedule 1. Find **Delete practice**, select **Delete Schedule** (trash icon), and confirm **Delete** in the dialog. Cancelling the dialog keeps the record. 2. Read the schedule by ID. A 404 confirms that the record is gone. 3. Read `org-schedules`. Its keyed delayed job should also be absent after the delete request finishes. 4. After its original due time, read **Delete safety target**. It must remain `waiting`. ![The actual schedule editor with action, timing and status controls.](../application-fixes/assets/jobs-local/15-target-editor-reopened.png) These are separate checks: a success toast alone proves neither cancellation nor an unchanged target. Deletion now notifies the scheduler to remove its own jobs; the worker also skips a missing record. This is cancellation of future work, not a rollback of work already in progress. ### 3. Separate retries from business duplication Our schedule helper enqueued three attempts. Notification jobs use different options: the checked notification enqueue path uses two attempts with exponential backoff starting at five seconds and a 60-second timeout. Generic datatype work and other processors differ again. Read the options on the actual job rather than copying a universal retry number. An idempotent status assignment can tolerate repetition more easily than charging a payment or placing an order. A provider timeout may occur after the provider accepted a request. For real external actions, use the provider's idempotency facility and a persisted business operation ID; a queue's deduplicated job ID alone does not prove exactly-once delivery. **Try it:** compare the paused and deleted records. Pause retains a schedule with `stop`; deletion removes it. Both must leave their practice targets unchanged after the due time. **Check yourself:** can a `done` schedule prove an email reached an inbox? No. It may only establish that work was handed off. Delivery needs the messaging/provider evidence. ## Part 4 — Operator branches The organization-scoped `org-schedules` and ID-specific `schedule-runs` endpoints are useful for business troubleshooting. Global queue details, monitoring and sync state have a broader scope. Their visibility and role checks are not identical to the Studio menu. **System Operations → Queue Manager** belongs to platform operations. Its APIs include queue listing/stats, queue actions, one-off scheduling and queue pause/resume. The inspected controller protects several mutations with RootAdmin/ConfigAdmin roles, while some read routes and the cluster sync-tick removal route lack equivalent role guards. Treat that as an access-control issue to address, not as an invitation to operate a shared cluster from an ordinary tutorial account. Repeatable cron jobs live in Redis. Restarting a process is not a reliable way to erase them. The checked schedule update handler's cron branch also needs a full integration test of expression propagation before you promise recurrence from a database field alone. This course executed one-off jobs only. For social publishing, billing, escalation and messaging, pair this queue course with the relevant business workflow. Verify the action's actual target and side effects before creating a schedule. The `run`, `start` and `stop` action branches inspected in the worker contain pending implementations; a label alone is not an executable custom job framework. ## If something goes wrong | Symptom | First check | Next action | | --- | --- | --- | | Saved record but no delayed job | Future date, queue connection, type | Correct the input and inspect org-schedules. | | Overview says active after failure | Exact job ID in failedJobs | Read failedReason and attemptsMade. | | Missing datatype error | Target object | Supply both datatype and id; reschedule with a new future time. | | Empty Runs tab | ID-specific history endpoint | Read schedule-runs and the target directly. | | schedule-stats returns 500 | Other read endpoints | Keep the error; use history and target checks. | | Run filter hides success | Saved activity status | This run used success, not completed. | | Paused schedule executes | Saved status and due-time ordering | Confirm `stop`, worker version, and whether execution began before the pause. | | Deleted record still has a delayed job | Exact job ID and completed delete request | Refresh the queue read; retain evidence and report a cancellation failure if it persists. | | Retry performs the action twice | External action's idempotency | Reconcile by business operation ID before resending. | | UI count disagrees with record | Status vocabulary and data source | Compare database, queue and activity separately. | ## What happened behind the scenes
Code and evidence `appengine/src/sync/commands/schedule-updated-handler.ts` loads the schedule and manages keyed Bull jobs. `sync/queue-consumer/schedule.consumer.ts` resolves targets, performs actions, updates status and records activities. `sync/queue.manager.service.ts` implements delays, retries, queue details and history. `repositories/repository.crud.service.ts` owns database writes and schedule commands. `sync/queue.manager.controller.ts` defines route methods and guards. Fresh local acceptance: [complete report](../application-fixes/schedule-live-walkthrough.md), including corrected recovery target, real pause, immediate cancellation and resume. The older assets below document the original rehearsal and its pre-fix failures. Historical evidence: [target](assets/appengine-jobs/target-create.json), [schedule request](assets/appengine-jobs/schedule-request.json), [queued job](assets/appengine-jobs/queued-before.json), [successful result](assets/appengine-jobs/successful-run.json), [malformed target](assets/appengine-jobs/failure-request.json), [failed job](assets/appengine-jobs/failed-queue-job.json), [repair](assets/appengine-jobs/recovery-update.json), [pause/recovery readback](assets/appengine-jobs/final-readback.json), [deletion immediately afterwards](assets/appengine-jobs/cancel-readback.json), [deleted job after due time](assets/appengine-jobs/deleted-job-after-due.json), [capture ledger](assets/appengine-jobs/evidence.json).
## Where next [Operate and extend AppEngine](appengine-operate-and-extend-the-platform.md) explains how to follow a failure from request to source and operational state. [Business automation](appmint-automate-a-business-handoff.md) covers the staff workflow around the action. **Evidence:** a successful internal-note action, three-attempt malformed-target failure, corrected same-record run with target `reviewed`, and real UI pause/deletion checks were exercised locally on isolated training records. No real notification, payment, social post, global queue mutation, cron execution or cluster sync change was performed. [Video production guide](production/appengine-run-reliable-background-jobs.md). --- # Give an assistant real business tools and verify what it actually did > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appengine/appengine-build-an-ai-business-assistant.html # Give an assistant real business tools and verify what it actually did ![The actual Studio Test drawer showing one exact customer match from the executed search tool.](../application-fixes/assets/ai-local/test-exact-email-live.png) *One requested email, one matching customer. Check the executed tool result before acting on it.* **Who:** business administrators configuring an assistant, and developers connecting an external AI client. **Time:** 60 minutes. **Level:** guided setup, then developer integration. **Product:** Studio Manager AI Assistant and AppEngine MCP. **Checked:** 24 September 2026 against the local application. **Example:** a fictional front desk and Noah Tutorial for customer search; the MCP chapter explains the separate booking lookup and labels its historical reference. ## What you will have at the end You will configure a narrowly focused assistant, understand tools versus instructions, and read its saved configuration. You will also make a real MCP lookup of a booking and distinguish a business-tool result from model-generated prose. The current Studio test successfully retrieved the exact requested customer through the configured model and search tool. The Test panel displays the tool result separately because this execution returned no follow-up prose. MCP booking retrieval was verified independently. The course also shows how to recognize an apparent success that lacks a matching tool result. ## What you need - Studio access to **AI, IVR, Automation → AI Assistant**. - Your organization ID and a staff session for developer requests. [Connect a client to AppEngine](appengine-build-a-connected-web-or-mobile-client.md) covers normal sign-in, headers and response handling. - A fictional customer you created and whose email you can check in CRM. For the MCP chapter, also have a booking you control and its saved reference. Use your own records; the screenshots' names and references identify the author's training fixtures. - An AI provider and compatible model configured by your operator for the Studio execution branch. MCP's direct service lookup does not require a model to generate an answer. Use `https://appengine.appmint.io` as the checked production API origin, or your operator's local/staging origin. The business-data rehearsal here used local training accounts; production health availability does not establish that every production tool matches the local build. ## The story and route The front desk needs facts, not confident guesses. A staff member asks who a customer is and when a known consultation is booked. The first assistant can search customers. A developer's MCP connection can retrieve a booking by reference. Changing that booking remains a separate, deliberate staff action. ```text Studio test: saved instructions + selected tools → model → executed tool result External client: discover MCP service → inspect signature → authenticated tool call Both paths: compare returned facts with the saved business record ``` **Tools perform actions.** A behavior rule tells the model how to behave; removing a tool removes that capability from this assistant's configured tool set. A capability is an instruction playbook. A channel or trigger decides when the assistant runs. These controls solve different problems. ## Part 1 — Build an assistant with one useful tool ### 1. Open the current assistant editor In Studio, select **AI, IVR, Automation → AI Assistant**, then **New Assistant**. The module also has **Dashboard**, **Templates**, **Assistants**, **Available Tools**, **Phone & Voice**, and **Activity Logs**. The current editor is one page with expandable sections: **Who it is**, **How it behaves**, **What it can do**, **When it works**, **What it knows**, **Where it stops**, and **Memory and voice**. Select a section heading to open or close it. There is no Next/Review wizard on this build; you save with **Create** at the bottom. ### 2. Identify it and keep it inactive In **Who it is**, enter: | Field | Training value | Purpose | | --- | --- | --- | | Name | `Tutorial Front Desk` | The readable name staff see | | Handle | `tutorial-front-desk-review` | Its unique identifier in your organization; use another unused handle if this one exists | | Status | **Inactive** | Prevent execution while configuring it; the new form initially offers Active | | Who can see it | **Owner** | Keep this practice configuration in your own view | | What it is for | `Find training customer details for the front desk. Do not send messages or change bookings.` | Define the small job this lesson will test | Typing Name generates a handle until you edit Handle yourself. Confirm both before saving: a different display name does not fix a duplicate handle. ![The actual saved identity fields in the current one-page editor.](../application-fixes/assets/ai-local/10-identity.png) ### 3. Give it a clear manner and boundaries Open **How it behaves**. In **Personality**, enter: > Professional and concise. Use the enabled customer search to find the exact training customer. State only facts returned by the tool. Under **Rules it always follows**, type each rule and press Enter or select **Add**. Each must appear as a separate saved chip: 1. `Never send messages, create leads, or change reservations.` 2. `If the tools cannot retrieve a booking, say so and ask staff to check the reservation reference.` 3. `Do not claim a task succeeded without a matching tool result.` Leave **When… then…** and **First message** empty for this exercise. A behavior rule guides the assistant's response; it does not remove a tool or replace the server's permissions. ### 4. Allow customer search only In **What it can do → Tools**, select **Only the ones I pick**. On the checked build this initially selects the available tools. Clear every checkbox except **Search customers**. Check the final summary: it must say **1 tools**, and reopening must show only customer search checked. **Every tool** grants the available tool set. **None — it only talks** grants none and hides the checklist. Neither matches this exercise. If you select None, return to Only the ones I pick and review the entire checklist again; do not assume it remembers a one-tool choice. Customer search looks up CRM customers by email, name, phone or username. It does not by itself retrieve an existing reservation. Keep all messaging, booking-changing, ticket-changing and phone actions unchecked. Leave **What it is good at** playbooks unselected for this narrow test. ### 5. Separate channels, triggers and knowledge In **When it works**, leave **What starts it** empty: do not add an event trigger. Read the channel/account hints carefully. No selected channels means **every channel**, and no accounts means all accounts. An empty channel list is not an off switch; **Inactive** is the execution stop used while preparing this lesson. Leave **What it knows** empty. This exercise obtains facts from the customer's actual record, so uploading an unrelated document adds nothing. For a later policy assistant, use **Add a source** and provide an approved document, collection, URL or text appropriate to that task; review any upload's visibility before saving. **Where it stops** and **Memory and voice** contain further behavior and retention settings. Keep the inherited values here. Choosing a voice does not assign an employee phone or establish telephone routing. The employee-phone course covers that separate setup. ### 6. Save, reload and prove the scope persisted 1. Recheck **Inactive**, the unique Handle, and the single checked **Search customers** tool. 2. Select **Create**. If validation fails, correct the displayed field and keep the draft open; do not repeatedly create another assistant. 3. Reload Studio, select **Assistants**, and find **Tutorial Front Desk**. 4. Its card should show **inactive** and **Tools: 1 enabled**. 5. Select **View**. This opens the editor. Confirm your identity fields, three rule chips and one selected tool. Existing assistants use **Save**, and also expose **Test it**. ![The actual inactive assistant after reloading Studio.](../application-fixes/assets/ai-local/03-inactive-reloaded.png) The local API readback independently confirmed the saved handle, three rules, `search_customers` only, `status: "inactive"` and `triggers: []`. [Saved settings](../application-fixes/assets/ai-local/04-saved-settings.json). **Try it:** identify a tool that could contact someone or change a booking, then show that it is unchecked. Keep the assistant inactive until the controlled test below. **Check yourself:** does writing “Never cancel bookings” remove cancellation capability? No. Check the actual enabled tools and server authorization as well. ## Part 2 — Execute a controlled test and diagnose the result ### 1. Read the saved assistant through its supported endpoint With a staff bearer. Both `POST /profile/user/signin` (the connected-client lesson) and `POST /user/signin` (the MCP handshake instructions) are supported aliases; both returned a token with the same controlled account in the local 24 September check. ```bash curl -sS "$API/crm/ai-assistant" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` The response contains `data` and `count`. Find the record by `data.name`, save its `sk` as `ASSISTANT_ID`, then read `GET /crm/ai-assistant/$ASSISTANT_ID`. ![Sanitized excerpt of the actual saved settings.](assets/appengine-assistant/17-saved-configuration.png) ### 2. Understand the inactive test result The test endpoint takes **`task`**, not `message`. Set `CUSTOMER_EMAIL` to the email of the training customer you created; set `BOOKING_REF` to your own training booking reference. Keep these values in your local shell. Build the JSON with an encoder so punctuation cannot break the request: ```bash export CUSTOMER_EMAIL='replace-with-your-training-customer-email' export BOOKING_REF='replace-with-your-training-booking-reference' node -e 'process.stdout.write(JSON.stringify({task:`Find customer ${process.env.CUSTOMER_EMAIL} using search_customers. Return only the matching name and email.`}))' > assistant-test.json ``` ```bash curl -sS -X POST "$API/crm/ai-assistant/$ASSISTANT_ID/test" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \ -H 'Content-Type: application/json' \ --data-binary @assistant-test.json ``` You can also run this check from the saved editor: select **Test it**, enter the task, and select **Send**. While inactive, the corrected local API returns400 and the panel explains: “AI Assistant is inactive. Activate this training assistant before running a test.” This is a configuration guard before tool/model execution, not a provider outage. The earlier generic500 was fixed and retested. ![Actual inactive-state explanation in the Test panel.](../application-fixes/assets/ai-local/06-inactive-fixed.png) ### 3. Activate only for an isolated test The rehearsal temporarily set `status: "active"` with **no automatic triggers**, confirmed that customer search was the only enabled tool, and issued the test. The update endpoint is: ```bash curl -sS -X PUT "$API/crm/ai-assistant/$ASSISTANT_ID" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \ -H 'Content-Type: application/json' \ --data '{"status":"active","triggers":[]}' ``` Use a controlled training assistant for this exercise. Do not change a live assistant's triggers just to run a tutorial. Now run the same task from the saved editor: 1. Open **AI Assistant → Assistants → View** for your training assistant. 2. Select **Test it**. In the task box, replace the example email with the email of your own training customer. Keep the instruction to use `search_customers` and not change data or contact anyone. 3. Select **Send** once and wait for the result. 4. Read **Tool result: search_customers · Completed**. For an exact email lookup, expect the one matching customer's name and email. Compare both with the customer you created. An unrelated list is not a successful lookup. 5. Distinguish the tool result from model prose. This endpoint can execute a tool without generating a second text answer. **No text reply was returned** alongside a completed tool result means the lookup ran; it does not mean the tool failed. **No tools ran in this test** means there is no executed-tool evidence, even if the text sounds confident. ![Actual Studio Test result: the exact email returns Noah Tutorial, with the executed tool shown separately from model text.](../application-fixes/assets/ai-local/test-exact-email-live.png) The 24 September local rehearsal used `deepseek-v4-pro` and returned exactly the requested training customer. The raw result contained one successful `search_customers` entry and one matching customer. No booking or customer was changed, and no message was sent. The screenshot captures the result drawer over an editor opened while inactive; that background field is not the activation readback. The test temporarily activated the saved record, and a separate readback confirmed it was inactive afterward. **Gotcha: text that looks like a tool call is not a tool result.** An earlier attempt printed `` without executing anything. Another returned customers who merely shared the email domain. Both application defects were repaired and the exact-email result above was rechecked. If either symptom appears, preserve the task and result for support; do not use those records as a confirmed match. ### 4. Restore inactive, then check the boundary Close the Test drawer. Set **Status → Inactive**, select **Save**, then reopen the assistant and confirm it is inactive with only the intended tool selected and no automatic triggers. Developers can perform the same restoration with `PUT /crm/ai-assistant/$ASSISTANT_ID`: ```json { "status": "inactive", "triggers": [] } ``` Read the saved record again; a closed drawer alone does not deactivate an assistant. For the separate refusal exercise, use a fictional booking and ask to move it while only customer search is enabled. Inspect both the tool trace and the unchanged booking afterward. The current successful rehearsal proves customer lookup only; a live model refusal and unchanged-booking readback remain to be verified. Do not treat the instruction “never change reservations” as a substitute for disabling mutating tools. ### 5. Check the saved activity, not just the assistant's answer 1. Return to **AI Assistant → Activity Logs**. 2. Select **Refresh** after the test finishes. Look for the activity description, time, type and status that match your test. A failed execution should remain identifiable as a failure. 3. If loading fails, read the displayed error and retry with **Refresh** after resolving it. A request failure is different from a successful request returning no records. 4. For a developer cross-check, request `GET /crm/ai-assistant/$ASSISTANT_ID/activities` with the same staff authentication and organization. Compare the returned record's `data.comment`, `data.type`, `data.status` and `createdate` with the screen. The organization-wide screen uses `GET /crm/ai-assistant/activities`. 5. If an action was requested, also inspect the actual business record. An activity description or model answer alone does not prove a booking changed or a message arrived. Look for a separate **ai_tool_call · success** entry for the search, alongside the execution's start and completion. The current local API contains that tool entry and its one-customer result. An **ai_execute · success** row alone only establishes that the execution returned; the earlier no-tool attempt also produced one. ![Actual local Activity Logs after the completed customer-search rehearsal.](../application-fixes/assets/ai-local/activities-exact-email-live.png) Keep credentials and customer-sensitive content out of screenshots shared outside your team. **Try it:** compare a failed test's HTTP response with the matching activity comment and time. If there is no corresponding record, record that gap rather than inventing an activity. **Check yourself:** can a successful saved configuration establish a successful conversation? No. You still need an execution result and, when an action is involved, a business-record readback. ## Part 3 — Retrieve a booking through MCP This is a separate developer path. MCP exposes registered service methods to an external client; it does not use the saved CRM assistant's 27-tool list. ### 1. Initialize and discover tools All requests below are JSON-RPC over `POST /mcp`. Start without business credentials for discovery: ```bash curl -sS "$API/mcp" -H 'Content-Type: application/json' --data '{ "jsonrpc":"2.0","id":1,"method":"initialize", "params":{"protocolVersion":"2025-03-26"} }' ``` The checked response identifies **Appmint AppEngine** and supports tools. Then send: ```json { "jsonrpc":"2.0", "id":2, "method":"tools/list" } ``` The three tools are `list_services`, `describe_service` and `call_service`. Use `tools/call` to invoke the first: ```json { "jsonrpc":"2.0", "id":3, "method":"tools/call", "params": { "name":"list_services", "arguments":{} } } ``` Discovery succeeded without signing in. Business-data execution needs authenticated context. ### 2. Describe the actual registered service `ReservationsService` was **not** registered on this build. Its description returned `AI-callable service not found: ReservationsService`. Use the discovered `RepositoryCrudService`: ```json { "jsonrpc":"2.0", "id":4, "method":"tools/call", "params": { "name":"describe_service", "arguments":{"service":"RepositoryCrudService"} } } ``` Read the signature of **`findOneAnyId`**: `orgId`, `datatype`, `dataId`, `options`. Its first parameter is exactly `orgId`, so this registry injects the organization from authenticated context. Omit that parameter from the positional `args` you send. > **Organization argument:** omit the first organization parameter from `args` whether the discovered signature spells it `orgId` or `orgid`. The server inserts the authenticated organization. The local repair verified both `findOneAnyId` and `find` against the same saved booking. Do not pass another organization's ID as an argument. For a filtered lookup, the registered `find(orgid, datatype, query, options)` method takes `args: ["reservation", {"sk": "YOUR_SAVED_BOOKING_SK"}, {"enrich": false, "pageSize": 1}]`. Replace the placeholder with the `sk` returned for your booking; a display reference is not necessarily its `sk`. The example below uses `findOneAnyId` so you can start with your booking reference. ### 3. Make the authenticated lookup Use the `BOOKING_REF` you set for your own training booking above. This generator inserts that reference into the positional arguments; it does not query the author's historical booking. ```bash node -e 'process.stdout.write(JSON.stringify({jsonrpc:"2.0",id:5,method:"tools/call",params:{name:"call_service",arguments:{service:"RepositoryCrudService",method:"findOneAnyId",args:["reservation",process.env.BOOKING_REF,{}]}}}))' > booking-lookup.json curl -sS "$API/mcp" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \ -H 'Content-Type: application/json' --data-binary @booking-lookup.json ``` **You should see:** HTTP 200, `result.isError: false`, and JSON serialized inside `result.content[0].text`. Parse that text as JSON to inspect the record. The fresh local read returned our own **Noah Tutorial** booking, **Design consultation**, **5 October 2026 at 10:00 America/Chicago**, reference **ZK0EFBML**, and **status `new`**. Your own reference and customer should replace these example values. [Fresh MCP results](../application-fixes/assets/ai-local/09-mcp-proof.json). ![The actual successful MCP lookup, summarized from its returned record.](assets/appengine-assistant/14-mcp-lookup.png) A factual answer could say: “Noah's consultation ZK0EFBML starts at 10:00 on 5 October, Chicago time. Its current status is new.” Do not paraphrase `new` as “confirmed.” This example answer is derived from the tool response; it is not a captured model-generated reply. ### 4. Exercise refusal paths Repeat the call without Authorization: the tool result has `isError: true` and an authentication-required message. Sending a token without `orgid` produced the same authentication-required message in this build because no user context was resolved. Requesting `tutorialNotExposed` as the method produced the not-`@AiCallable` error. ![Actual tool errors, all inside HTTP 200 envelopes.](assets/appengine-assistant/16-mcp-errors.png) Check both the HTTP layer and `result.isError`. A transport 200 does not mean the service action succeeded. **Try it:** use your own training booking reference and compare the MCP record with the staff board. **Check yourself:** the client says “done” but the MCP result has `isError: true`. Treat it as failed and show the tool error; do not trust the client's summary over the response. ## Part 4 — Extend without handing over every action A general MCP connection is not automatically read-only because you ask it read-only questions. The inspected `call_service` gate checks content **read** permission before invoking exposed methods; the registry includes mutating methods too. Apply a server-side service/method allowlist for a narrowly scoped assistant and verify the underlying method's authorization. Prompt rules alone are not that boundary. For a human-reviewed change, keep lookup and mutation separate. Present the exact booking, current time/status and proposed new values to staff. Staff should use the established booking workflow or an explicitly authorized mutation after review. The [workflow course](appmint-automate-a-business-handoff.md) covers the platform's approval concepts; automatic wiring from this assistant into an approval was not exercised here and should not be implied. To expose a custom service, inspect `@AiService` and `@AiCallable`, register the provider in the Nest module, and verify the deployed signature with `describe_service`. The current organization-injection behavior depends on the exact parameter name. Treat a signature change as an integration change, not merely a refactor. For voice, separate the paths: browser assistant voice uses the AI voice gateway, telephone media uses the voice stream integration, and IVR routing chooses how a number handles calls. Selecting **ballad** on the personality screen does not provision these. Follow the [employee phones course](appmint-mobile-employee-phones-and-crm.md) for assignment and mobile calling. Appmint and its apps are free. Provider configuration and usage records help diagnose which service processed an AI request; they should not become invented paid-plan prerequisites in this tutorial. ### Extending to support tickets: identify the caller before the ticket Keep ticket tools disabled in the customer-search exercise above. When building a separate support assistant, choose the intended caller first: | Caller | Lookup | Update boundary | | --- | --- | --- | | Signed-in customer | Their own ticket number; the server keeps their persisted account filter | The assistant’s Update Ticket tool is staff-only | | Signed-in staff | Ticket number, optionally narrowed by the customer’s email | Current ticket read and update grants are required | | Voice/channel identity without authenticated context | An email or caller number does not establish ticket ownership | No private ticket access or update | An email field narrows a search; it does not authenticate anyone. Do not ask a customer for another person's email to get around a refusal. A custom integration must supply the authenticated principal through the server's trusted request context, never through model arguments or `input.data`. Before enabling a support assistant, test with two fictional customers: the first can read their own request and cannot read the second's. Then test a permitted staff account and an account without the required grants. For a staff update, compare the tool result with the saved ticket status. A notification request is separate from confirmed email delivery. The local tool/service regressions cover these boundaries and the repaired staff lookup by number. A further 12-check rehearsal used persisted fictional records: the customer read their own ticket, cross-customer access was refused, staff updated ticket AIPROOF24 by number, and the actual authenticated HTTP read returned its saved resolution. That update used the compiled tools with a scoped persistence adapter and suppressed notification sink. It proves persisted tool behavior, not a full model conversation or message delivery. [Evidence and limits](../application-fixes/ai-ticket-persisted-acceptance.md). ## If something goes wrong | Symptom | First check | Action | | --- | --- | --- | | New assistant has many tools | Tools count | None, then enable only the intended tool. | | Cannot find Next or Review | Current one-page editor | Expand the section headings; review settings, then use Create or Save at the bottom. | | Reload hides the card | Module tab | Select Assistants after Dashboard loads. | | View opens a form | Current UI behavior | This is the editor; saved records expose Test it. | | No channels are selected | Empty channel selection means every channel | Keep Inactive while configuring; check channels separately from event triggers. | | Test says the assistant is inactive | Saved status | Expected guard: the corrected build returns 400. Activate only when ready for the controlled test, then restore inactive. | | Provider rejects model | Exact provider error | Align provider and model in platform configuration. | | No text reply, but a tool completed | The separate Tool result card | Read its actual outcome; this test can return tool results without follow-up prose. | | Tool-like text but no tool result | `run.toolResults` | Do not treat text as an executed lookup; report the missing execution. | | Exact email returns unrelated customers | Matching email and returned count | Preserve the task/result and report the lookup problem. | | Activity Logs empty | Assistant activities API | Refresh, read any loading error, and correlate the ID-specific records. | | ReservationsService missing | `list_services` | Use a registered service; this course demonstrates findOneAnyId. | | `Organization or Site Not Found: reservation` on an older build | Deployed registry version and described signature | The local fix injects both `orgId` and `orgid`; keep organization omitted and report a build that still shifts the arguments. | | HTTP 200 but no data | `result.isError` | Read the tool error instead of treating transport success as action success. | | Model reports a change | Tool trace and saved record | Verify the actual mutation; prose alone is insufficient. | ## What happened behind the scenes
Code and rehearsal evidence Studio: `websitemint/packages/ui/src/components/crm/ai-assistant/ai-assistant-editor.tsx` defines the current one-page editor, tool defaults and channel/trigger conversion. `app.tsx` owns the module tabs. AppEngine: `crm/ai-assistant/ai-assistant.controller.ts` takes `task` for tests; `ai-assistant.service.ts` checks active status, executes and records activities. `ai/agents/crm-assistant-role.ts` builds the enabled tool handlers. `ai/agents/base-agent.ts` selects the default model through configuration; this rehearsal did not change shared provider settings. MCP: `mcp/mcp.controller.ts`, `mcp/mcp.service.ts` and `ai/ai-service-registry.service.ts` implement discovery, permission checking and exact-name organization injection. Read the current deployed description rather than using an old method list. Evidence: [saved assistant](assets/appengine-assistant/assistant-saved.json), [inactive test](assets/appengine-assistant/inactive-test.json), [lookup test error](assets/appengine-assistant/test-lookup.json), [change-request test error](assets/appengine-assistant/test-change-request.json), [final inactive state](assets/appengine-assistant/inactive-after-test.json), [API activities](assets/appengine-assistant/activities.json), [MCP description](assets/appengine-assistant/mcp-describe.json), [successful booking lookup](assets/appengine-assistant/mcp-booking-lookup.json), [capture ledger](assets/appengine-assistant/evidence.json).
## Where next - [Reliable background jobs](appengine-run-reliable-background-jobs.md) — follow queued work to an actual result. - [Operate and extend AppEngine](appengine-operate-and-extend-the-platform.md) — trace failures and maintain integration boundaries. **Historical 18 September evidence:** complete inactive assistant creation and reload, one-tool readback, inactive rejection, two active/no-trigger tests, restoration to inactive, activities API/UI comparison, anonymous MCP discovery, signature inspection, authenticated booking read and three failure paths were exercised. No successful LLM answer, messaging, booking mutation, Claude Desktop session, voice call, automatic approval flow or production deployment is claimed. [Video production guide](production/appengine-build-an-ai-business-assistant.md). **24 September continuation:** the current one-page editor, inactive-state guard, local booking/MCP reads, ticket authorization regressions and activity-feed repairs were checked separately. The approved DeepSeek run executed customer search and returned exactly one matching customer in the real Test drawer. No follow-up model prose was generated; the tool outcome is displayed separately. Restoration to inactive and the saved tool activity were verified. [Current review evidence](../application-fixes/ai-assistant-local-review.md). **Management access, 24 September:** the repaired management endpoints require signed-in staff. Live checks confirmed staff-owner access and refusal for the training customer, site app and anonymous caller. The execution guard also rejects inactive assistants and customer/site-app invocation of owner/team/organization assistants before model work. Existing global access and trusted internal triggers remain supported. Nineteen isolated regressions verified these boundaries; this is separate from the live model/tool acceptance in the [current review](../learner-review/appengine-build-an-ai-business-assistant.md). --- # Diagnose an AppEngine request and prepare a verifiable release > Open the full course: step-by-step instructions, examples, images and troubleshooting. Source: https://docs.appmint.io/courses-appengine/appengine-operate-and-extend-the-platform.html # Diagnose an AppEngine request and prepare a verifiable release ![Search the running API reference for the exact profile route.](assets/appengine-operations/02-profile-route-search.png) *One path, two methods, different responsibilities. We will follow the update through its controller and service, then compare the response with the saved data.* **Who:** developers and platform maintainers with source access. **Time:** 75–90 minutes. **Level:** developer, with advanced operations branches. **Product:** AppEngine, Studio Manager and Vibe development environments. **Checked:** 21 September 2026; local AppEngine 0.132.0 through the Cedar training renderer and API r11. Production deployment is not required for this local course. ## What you will have at the end You will know how to turn “it says saved, but nothing changed” into a precise diagnosis. You will locate a live route, reproduce it with a training identity, follow the source, read the affected record, and produce a release checklist that tests the intended behavior. You will also compare health signals, inspect queue and usage evidence, and distinguish an environment record from a working deployment. This is a maintainer course. Business owners do not need to install a backend to use Appmint or BusinessMade. The practical investigation uses an existing local training server; the isolated-development and release sections describe the next engineering work, without claiming a patch or deployment was performed. ## What you need - The AppEngine and WebsiteMint source checkouts and permission to inspect the environment you are diagnosing. - A training organization and its own staff/customer accounts from [Connect a client to AppEngine](appengine-build-a-connected-web-or-mobile-client.md). - Your API base URL, terminal and browser. Our verified learner server is port 3311; the source default is `SERVER_PORT=3000`. Match the actual running process. - For the environment section, your own Vibe project. The Cedar local rehearsal intentionally has no `dev_environment` fixture, so its 404 branch is documented instead of borrowing another organisation’s project. Production AppEngine answered at `https://appengine.appmint.io`. The alternative `api.appmint.io` did not resolve during this check. Local accounts and production accounts belong to different installations: a local signup is not automatically a production login. ## The story and route Lina tries to update her phone number through the customer portal. The direct API can persist the value, but an older server-side proxy adds an internal query field and returns 400. We will reproduce the boundary, repair the proxy, and prove the saved value with a fresh read. ```text User action → request method/path → identity and organization → controller → service → saved record ↓ readback → diagnosis → isolated fix → release checks ``` Keep a short incident note as you work: environment, time, actor type, request method/path, status, intended result, observed result and record identifier. Include sanitized response fields. Passwords, bearer tokens and credential-bearing invitation URLs are not diagnostic attachments. ## Part 1 — Trace a real success response that saved nothing ### 1. Open the API reference served by this installation Open `/documentation` on your API base URL. The checked page is a custom **AppEngine API** index, with a search field, sections, credential controls and curl/JavaScript/Python examples. It reported 2,751 endpoints in 144 sections at capture time; counts change with the build. ![The actual runtime API index and credential controls.](assets/appengine-operations/01-runtime-api-index.png) Keep **Credentials · sending nothing** while browsing the reference. The page separates **Authorization — user token** from **x-client-authorization — customer token** and provides **Login as user**, **Login as customer** and **Forget all**. Enter credentials only for a request you intend to execute, then clear them when finished on a shared machine. > Gotcha: the documentation describes available routes and declared responses. It cannot establish that a service actually saves the requested field. This example's declared “Profile successfully updated” response is precisely what we are testing. ### 2. Find the exact method and path In **Search endpoints…**, enter `client-data/profile`. The live result contains **GET /client-data/profile** and **PUT /client-data/profile**. GET reads; PUT is intended to update. Do not treat them as interchangeable because their path is the same. ![The two profile operations in the live reference.](assets/appengine-operations/02-profile-route-search.png) For a terminal investigation: ```bash curl -sS "$API/documentation/search?q=client-data/profile" curl -sS --get "$API/documentation/endpoint" \ --data-urlencode 'method=PUT' \ --data-urlencode 'path=/client-data/profile' \ --data-urlencode 'format=json' ``` The second request returned 200 with method, path, tag, operation and components. Omitting `format=json` returns Markdown. `/documentation-json` and `/openapi.json` expose the full specification; `/llms.txt` and `/documentation.md` provide entry points for source-aware tools. The search response in our local installation generated absolute detail links on `proxy.appmint.io`. Keep your intended API origin and use the returned path when investigating the local build. Accidentally moving between installations makes comparisons unreliable. ### 3. Establish a before value with the correct identity Authenticate through normal customer sign-in using your own training customer. Use that customer bearer as `CUSTOMER_TOKEN` and your organization ID as `ORG`: ```bash curl -sS "$API/client-data/profile" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" ``` The returned profile is a record envelope: business fields are in `data`. Record the current phone value, including whether it is absent. Our fixture had no phone. A staff token is not a substitute for the customer session; the tested staff dashboard request returned a customer-not-found error. ### 4. Update one harmless field and prove the readback Use the same customer session for both requests. The supported body is a flat JSON object; do not wrap it in `data` and do not add transport metadata to it: ```bash curl -sS -i -X PUT "$API/client-data/profile" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" \ -H 'Content-Type: application/json' \ --data '{"phone":"+12025550123"}' curl -sS "$API/client-data/profile" \ -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN" ``` The local rehearsal returned **200** from the PUT and the fresh GET returned `data.phone: "+12025550123"` with `phoneVerified: false`. The retained sanitized transcript is [profile-before-update-after.json](../application-fixes/assets/appengine-operations/profile-before-update-after.json). A phone change deliberately clears the verification flag; it does not claim that the number has been verified. If you call the route through a WebsiteMint/base-app proxy, use the patched client or a current build. The proxy must not append its internal `clientQuery` property to the customer body. Before this repair, that leaked transport field caused a strict profile patch to return 400 even though direct AppEngine accepted the same flat body. The fix is recorded in [the application repair report](../application-fixes/customer-profile.md). An HTTP client must handle an empty body on other PUT routes before attempting JSON parsing. For this repaired profile route, assert both the HTTP status and the fresh GET value. ### 5. Follow controller to service and provider From the projects directory, search the source: ```bash rg -n 'updateProfile|updateClientProfile|updateOwnCustomerProfile|updatePartial' /Users/imzee/projects/appengine/src/client-account /Users/imzee/projects/appengine/src/repositories ``` `client-account.controller.ts` binds `PUT /client-data/profile` and delegates to `ClientAccountService.updateClientProfile`. That service calls `updateOwnCustomerProfile`, which validates the authenticated customer, restricts the patch to `firstName`, `lastName` and `phone`, checks the organisation-scoped record, writes dotted fields through `RepositoryService.updatePartial`, reloads the record and compares every requested value. This readback is the boundary assertion that the old no-op implementation lacked. The shared base-app client is a separate boundary: it forwards the JSON body to AppEngine and may carry query metadata internally. Keep that metadata out of strict domain payloads. Test both direct API and proxy paths when a UI reports success. ### 6. Write the useful diagnosis Use this structure for the internal bug record: > Before the repair, the local base-app proxy appended `clientQuery` to the flat customer patch, so `PUT /client-data/profile` returned 400 through the UI. Direct AppEngine accepted the same flat body. After the shared client fix, the renderer path returns 200 and a fresh GET confirms the phone value. The customer service also enforces identity, allowed fields and readback. Include the timestamp, installation, actor type, method/path, status, sanitized record identifier and readback value. Passwords, bearer tokens, magic-link URLs and full customer exports are never diagnostic attachments. **Try it:** send an unsupported field, then send a valid phone update. The first must fail without changing the record; the second must return a value you can prove with a fresh GET. **Check yourself:** would changing the success toast fix a proxy that adds an invalid field? No. Fix the boundary and assert persistence. ## Part 2 — Read the platform's operating signals ### 1. Compare health components Use the same `API` origin and `ORG` organisation ID established in Part 1. Read both endpoints; the production monitoring endpoint requires the organisation header: ```bash curl -sS "$API/health" curl -sS "$API/monitoring/health" -H "orgid: $ORG" ``` The screenshot below is the earlier local rehearsal, not a promised result for your environment. The later production check returned `missing_orgid` when the monitoring header was omitted; the command above includes it. Compare the responses from your own origin and organisation rather than expecting the screenshot's component states. Our local `/health` response reported `isHealthy: true`, `degraded: false`, and memory, database, KeyDB and S3 up. The monitoring response's headline was `healthy`, but its Redis component was `unhealthy` with `connected: false`. ![Actual health responses with their conflicting component signals.](assets/appengine-operations/03-health-comparison.png) Do not flatten these into “all systems healthy.” Record the disagreement and investigate which client/connection each probe checks. The responses alone do not tell you whether a particular customer action worked. Follow the action's request and record too. The current rate limiter defaults to 10,000 requests per 15 minutes, overridable by `RATE_LIMIT_MAX`. It skips OPTIONS and `/monitoring/health`; `/health` is not exempt. A probe should evaluate component results as well as HTTP status. A 200 headline with a failing component should remain visible to operators. ### 2. Inspect queues without changing shared work ```bash curl -sS "$API/monitoring/queues" -H "orgid: $ORG" ``` The practiced request returned queue aggregates without a bearer. This is an operational exposure to account for, not evidence of role-based protection. Shared counts may include other organizations. Keep investigation exports restricted to your own fixture when reading detailed job endpoints. Use [Background jobs](appengine-run-reliable-background-jobs.md) for the completed record → delayed job → worker → target rehearsal. That course includes a failed three-attempt job, repair and a later successful run. Empty queue depth is not sufficient: successful jobs can be removed, failed jobs can remain, and the Studio Runs tab did not show the activity available through the API. Do not pause a shared queue or remove synchronization ticks to make a training screenshot. Those actions affect work beyond one tutorial record. ### 3. Read your organization's usage ```bash curl -sS "$API/usage/balance" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" curl -sS "$API/usage/stats" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` Both returned 200 for the training organization. The balance response included free usage balance information; stats were paginated and indicated another page. Read pagination before reporting totals. Usage telemetry does not introduce a paid prerequisite for Appmint or its mobile apps. A usage entry names an operation. It does not certify the recipient received a message, a phone call connected or a charge settled. Verify each business outcome in its own system. ### 4. Separate a Vibe environment record from its deployment Use the production account and organisation that own the project. `ENV` is the **Dev Environment Name** entered when creating the project in the [Vibe course](appmint-vibe-build-a-client-app.md#3-inspect-the-creation-form-before-provisioning), not a database `sk`, organisation ID or display title. Use the existing project's name from your own creation record; do not create a duplicate to obtain it. Assign that exact name locally before the requests below: ```bash read -r -p 'Your existing Dev Environment Name: ' ENV : "${ENV:?Enter the existing environment name before continuing}" ``` These examples run in Bash. The environment read looks up `dev_environment.data.name` within the organisation header. Check its returned `data.name` matches your chosen name before interpreting container status. If the record read returns 404, verify the project name, owning organisation and API origin; do not proceed with another person's example name. ```bash curl -sS "$API/dev-env/get/$ENV" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" curl -sS "$API/dev-env/$ENV/status" \ -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" ``` In the Cedar local organisation used for this review, a read-only `POST /repository/find/dev_environment` returned `total: 0`; both the example record and status lookups therefore returned 404. That is a missing local fixture, not evidence that an arbitrary environment exists. Stop at this branch, record the exact organisation/name/API origin, and continue the lifecycle exercise only after the learner has created or selected an environment in Vibe. A record row and a running container remain different facts. The earlier project transcript reported a missing SpinForge partner key. A fresh `get-spinforge` read now returned 200; the historical message alone no longer establishes the present server configuration. That response can contain connection details, so the public evidence retains only status and field names. Follow [the Vibe course](appmint-vibe-build-a-client-app.md) for the current editor and preview captures. An environment row, connected file editor, local static preview and public deployment each need their own check. **Try it:** create a four-line incident summary: API health, component warning, affected request, persisted result. Avoid the single sentence “the server is up, so it must be the browser.” ## Part 3 — Prepare an isolated fix and release ### 1. Match the checkout to the running build Record the version from `/health` and the source revision you will change. Confirm the server's actual port and API origin. Do not restart a shared process while another person is testing against it. For a separate development instance, inspect `package.json`, `src/config/config.ts` and `src/config/app.config.ts`. The checked configuration names include `MONGODB_CONN` or MongoDB host/port credentials, Redis host/port credentials, and `SERVER_PORT`. Use a dedicated training database and queue configuration. Never copy a production secret file into a recording or commit. The checkout's scripts include `npm run watch` for Nest watch mode, `npm run build` for the server and copied UI resources, and `npm run start:prod` for the built entry point. `npm run lint` uses `--fix`, so it changes files; it is not a read-only diagnostic command. Use the project's existing dependency lockfile and environment instructions when installing. A port change alone does not isolate a shared database or Redis queue. ### 2. Define behavior before changing the service For the profile issue, the acceptance cases are concrete: | Case | Required result | | --- | --- | | Customer changes an allowed field | Validated value persisted and returned by fresh GET | | Customer sends another customer's identifier | Server refuses access; identity comes from the authenticated session | | Unsupported field or malformed phone | Clear validation error; unrelated fields retained | | Expired/missing authentication | Authentication error; no write | | Empty update | Explicit documented behavior; no false claim that a field changed | | Persistence failure | Error surfaced; no success toast or fabricated updated record | Use two training customers for ownership checks. The connected-client rehearsal found a separate generic repository read that exposed one fictional customer's reservation to another customer's bearer. Fixing the profile no-op does not fix that route. Customer isolation must be enforced across the server surface before a public customer app is certified. ### 3. Test the actual boundary Add a focused service/integration test for the update and a request-level ownership test. Re-run the exact before → PUT → after sequence against the isolated build, then reload the consuming client. Check that invalid input does not erase unrelated fields and that the second customer cannot update the first. For the older group-editor issue, investigate the sequence separately: the UI saves the group, then attempts a generic user save rejected with **User should be updated using user api**. A persisted membership plus an error toast is a partial success, not the profile no-op. Keep the repository's identity protections; repair the caller's use of the appropriate user operation. ### 4. Release with a known recovery point Prepare the reviewed change, test results, expected migrations, configuration changes and a known previous artifact/revision. Deploy only through the team's authorized release workflow. Run the same readback on the target installation using a training account and inspect errors/queue effects. Environment APIs expose lifecycle actions such as start, stop, restart and rebuild. They are mutations, not diagnostics. A rebuild of current files is not automatically a rollback. Recovery requires the intended previous source/artifact and any compatible data/configuration state. This course did not rebuild, stop or deploy the shared production project. ### 5. Know which extension belongs where - Gateway integrations implement `IntegrationProvider`, register exports and supply schemas/operations. Follow [the supplier course](appengine-connect-a-custom-business-api.md). - Studio navigation lives in WebsiteMint's `packages/ui/src/ui/sidebars/links.ts`; BusinessMade uses its own sidebar and route registry. A hidden menu does not enforce server permissions. - System Operations is gated by system-organization UI conditions. Queue actions have their own server guards. Inspect both before granting an operator role. - Stowbo is a gated extension with storage/host/booking workflows; this training organization did not exercise it. Do not turn a directory listing into a claimed completed customer journey. - Social automation spans CRM, integration providers and synchronization. The empty `src/social` directory does not mean social features are absent. - Hub agent distribution and device registration belong in [Device Hub](businessmade-device-hub.md), including actual download links and pairing checks. ## If something goes wrong | Symptom | First check | Next action | | --- | --- | --- | | 200, field unchanged | Fresh GET and service body | Diagnose missing/incorrect persistence; do not retry indefinitely | | 400 `missing_orgid` | Organization header | Supply the company ID for this installation | | 401 or session expired | Actor type and normal sign-in | Renew the intended identity; avoid token sharing | | Browser fails while terminal works | Actual origin, preflight and response | Inspect CORS and rate-limit response; production origin list is conditional | | Healthy headline, unhealthy Redis component | Both health response bodies | Investigate probe/client disagreement | | Search link opens another installation | Generated absolute URL | Keep your selected base URL and use the route path | | Record exists, container status 404 | Environment versus hosting state | Keep project; investigate missing container/domain mapping | | Group membership saved with red toast | Both sequential requests | Repair the inappropriate user save, preserving the successful group write | | Stats list looks incomplete | Pagination | Read remaining pages before totals | ## What happened behind the scenes
Source and evidence for maintainers Profile path: `appengine/src/client-account/client-account.controller.ts` and `client-account.service.ts`. Runtime reference: `documentation/documentation.controller.ts` and its UI. Boot, rate limiting, CORS and port: `main.ts`. Configuration: `config/config.ts`, `config/app.config.ts`; read names and validation, not secret values. Monitoring: `monitoring/monitoring.controller.ts`. Usage: `usage/usage.controller.ts`. Environment lifecycle: `site/dev-environment.controller.ts`; hosting: `site/container-management.service.ts`. Group sequence: WebsiteMint `components/user-management/group-editor.tsx` and AppEngine `repositories/repository.crud.service.ts`. Sidebar gating: WebsiteMint `ui/sidebars/links.ts`. Evidence: [health](assets/appengine-operations/health.json), [monitoring health](assets/appengine-operations/monitoring-health.json), [queue aggregates](assets/appengine-operations/monitoring-queues.json), [profile route detail](assets/appengine-operations/profile-route-detail.json), [search](assets/appengine-operations/documentation-search.json), [anonymous identity](assets/appengine-operations/anonymous-identity.json), [usage balance](assets/appengine-operations/usage-balance.json), [usage stats](assets/appengine-operations/usage-stats.json), [environment readback](assets/appmint-vibe/review-account-environment-readback.json), and [capture ledger](assets/appengine-operations/evidence.json).
## Where next [Background jobs](appengine-run-reliable-background-jobs.md) follows queued work to a business result. [Connected clients](appengine-build-a-connected-web-or-mobile-client.md) supplies the authentication, booking and ownership examples behind this investigation. **Evidence:** runtime documentation, endpoint detail, anonymous identity, health, monitoring, usage and own production environment reads completed. Profile no-op and customer-boundary checks reuse the actual connected-client rehearsal. Source path inspected. No source patch, fresh backend installation, deployment, rollback, root-account operation or Stowbo workflow is claimed. [Video production guide](production/appengine-operate-and-extend-the-platform.md). --- # Welcome to Appmint > One backend for commerce, CRM, operations and payments — and the products that run on it. Source: https://docs.appmint.io/getting-started/welcome.html **Start with the visual welcome tutorial:** [Signup to your first working setup](/tutorials/visual-appmint-introduction). For a specific task, use the [complete feature walkthrough map](/tutorials/system-walkthrough), including device, website, chat, API, email and phone setup where relevant to this product. Appmint is one backend with a family of products on top of it. The same customer, order and inventory records power the visual builder, the operations console, the mobile app and the event platform — so adopting a second product costs no integration work. Start with the quickstart if you are building against the API, or go straight to the product you are using. ## Start here ```cards cols: 3 items: - title: Quickstart href: /docs/getting-started/quickstart icon: rocket body: Sign in, get a token, read your data. Five minutes. - title: Core concepts href: /docs/getting-started/core-concepts icon: lightbulb body: Organizations, datatypes and records — the ideas everything rests on. - title: How it works href: /docs/platform/architecture icon: layers body: Architecture, tenancy and the shared data model. ``` ## Products ```cards cols: 3 items: - title: AppEngine href: /docs/appengine/overview icon: code body: The backend API. Auth, data, commerce, CRM, automation and AI. - title: Studio Manager href: /docs/studio-manager/overview icon: paint body: Business management suite — build, operate and automate from one console. - title: Appmint Mobile href: /docs/appmint-mobile/overview icon: smartphone body: Your business on the go — CRM, POS, payments, scanning and events. - title: EventOxygen href: /docs/event-oxygen/overview icon: ticket body: Ticketing, check-in, accreditation and attendee networking. - title: Appmint Chat href: /docs/appmint-chat/overview icon: help body: Embeddable chat with live agents and AI answers. ``` ## Build with AI ```cards cols: 2 items: - title: Vibe Studio href: /docs/vibe-studio/overview icon: paint body: Visual AI app builder — describe what you want and edit it visually. - title: Vibe Agent href: /docs/vibe-studio/vibe-agent/overview icon: zap body: AI coding agent that builds features against your own data. ``` ## For AI readers These guides are also published as plain markdown for agents and tools: [llms.txt](https://docs.appmint.io/llms.txt) maps every page, [llms-full.txt](https://docs.appmint.io/llms-full.txt) is all of them in one file, and any page is available as `.md` beside its `.html`. The API reference has its own at AppEngine's `/llms.txt`. An [AI employee](/docs/platform/ai-employees) is pointed at all of them from its handover. ## Everything shares one backend There is no per-product database and no per-product login. A tenant is an organization, every request carries it, and each organization gets its own isolated database. That is why a customer created at the point of sale is the same record the storefront greets, the mobile app calls and the event app checks in. --- # Quickstart > Sign in, read the token apart, and fetch records — with the exact payloads AppEngine returns. Source: https://docs.appmint.io/getting-started/quickstart.html Everything below is a real request against a running AppEngine. Production is `https://appengine.appmint.io`; a local dev server listens on `SERVER_PORT` (the mobile clients default to `3300`). ## 1. Check the server is up Health and readiness are the only routes that need neither auth nor an `orgid`. ```http GET /health public ``` ```bash curl https://appengine.appmint.io/health ``` ```json { "isHealthy": true, "services": { }, "timestamp": "2026-08-28T10:00:00.000Z" } ``` It returns `503` with the same envelope when a dependency is down. `/readiness` is the Kubernetes readiness probe; `/version` reports the build. ## 2. Sign in as a user ```http POST /profile/signin public ``` Also mounted at `/profile/user/signin`, `/user/signin`, and `/user/user/signin` — the controller declares `@Controller(['profile', 'user'])` with `@Post(['/user/signin', '/signin'])`, so all four resolve to the same handler. ```bash curl -X POST https://appengine.appmint.io/profile/signin \ -H 'Content-Type: application/json' \ -H 'orgid: acme' \ -d '{ "email": "ops@acme.com", "password": "…" }' ``` On success: ```json { "user": { "pk": "…", "sk": "6512…", "datatype": "user", "data": { "email": "ops@acme.com", "roles": ["Owner"], "permissions": { … } } }, "orgId": "acme", "rootOrg": "appmint", "sharedOrg": "…", "token": "eyJhbGciOi…", "refreshToken": "eyJhbGciOi…" } ``` > [!NOTE] > **A 200 does not always mean you are signed in** > > `userAuth` has three non-token exits. Handle all of them: > > - `{ requiresPasswordChange: true, userId, email }` — the caller used a temporary password. > - `{ requiresTwoFactor: true, challengeToken, twoFactorMethod, message }` — 2FA is on for the org or the user. `twoFactorMethod` is `email`, `sms` or `authenticator`; for the first two a code has already been sent. > - `{ requiresTwoFactor: true, isNewDevice: true, … }` — the org has `enableNewDeviceAuthentication` on and this device fingerprint is unrecognized. Wrong credentials return `400 Invalid username or password`. A blocked device fingerprint returns `403`. ## 3. Understand what the token is The JWT payload **is the signed user record** — `jwtService.sign(user)`. That is why middleware can read `tokenInfo._id`, `tokenInfo.datatype` and `tokenInfo.data.roles` straight out of it without a lookup. Two lifetimes, both from config: `JWT_EXPIRES_IN` for `token`, `JWT_REFRESH_EXPIRES_IN` for `refreshToken`. > [!WARNING] > **Roles in the token are not trusted blindly** > > `CurrentUserMiddleware` re-fetches the user from the org database on every request and then copies `permissions` and `roles` from the token onto it. A revoked user is rejected with `401 user_not_found`, but a role changed after issue still rides along until the token is refreshed. ## 4. Make an authenticated request Two headers, always: the bearer token and the tenant. ```http GET /profile/whoami jwt ``` ```bash curl https://appengine.appmint.io/profile/whoami \ -H 'Authorization: Bearer eyJhbGciOi…' \ -H 'orgid: acme' ``` Drop the `orgid` and you get a `400` before any controller runs: ```json { "code": "missing_orgid", "message": "Organization ID is required. Pass orgid as a header, query param, or body field." } ``` ## 5. Read some data The repository controller is generic over datatype. ```http GET /repository/get/:datatype/:id jwt ``` ```bash curl https://appengine.appmint.io/repository/get/sf_product/6512abc… \ -H 'Authorization: Bearer …' -H 'orgid: acme' ``` Searching takes a body: ```http POST /repository/search/:datatype jwt ``` ```bash curl -X POST https://appengine.appmint.io/repository/search/customer \ -H 'Authorization: Bearer …' -H 'orgid: acme' \ -H 'Content-Type: application/json' \ -d '{ "keyword": "smith", "query": {}, "options": { "page": 0, "pageSize": 25 } }' ``` The body is exactly three fields — `keyword`, `query` and `options`. An empty `keyword` skips full-text search and runs `query` as a plain filter. Responses come back as a `BaseModelDTO` — the records in `data`, plus paging: ```json { "datatype": "customer", "total": 128, "page": 0, "pageSize": 25, "hasNext": true, "data": [ { "sk": "…", "data": { … } } ] } ``` ## 6. Refresh before it expires ```http POST /profile/user/refresh jwt ``` Customer tokens refresh at `/profile/customer/refresh`. The `401` bodies tell you which case you are in — `token_expired`, `invalid_token`, `missing_authorization_header` or `auth_error` — and expired/invalid both carry `action: "refresh_token"`. ## Authenticating without a user For server-to-server work, mint an API key instead and send it as `apiKey` or `x-api-key`. The middleware resolves the key to its owning user and injects a bearer token for the rest of the request. See [API keys](/docs/appengine/authentication/api-keys). ## Next - [Conventions](/docs/appengine/conventions) — headers, envelopes, errors, rate limits. - [Working with data](/docs/appengine/data/repository) — the full repository surface. --- # Core concepts > Organizations, datatypes, BaseModel, and the difference between a user and a customer. Source: https://docs.appmint.io/getting-started/core-concepts.html Five ideas explain most of AppEngine. Learn these and the 2,400-odd endpoints stop looking arbitrary. ## 1. The organization is the tenant Every request carries an `orgid`. AppEngine resolves it to a **dedicated MongoDB database** — the org record holds a `data.database` field, and `getDatabase(orgId)` looks it up and caches the connection (`repository.provider.mongodb.ts`). Tenants are therefore isolated at the database level, not by a `tenantId` column. Two orgs can hold a `customer` collection with colliding keys and never see each other. > [!WARNING] > **orgid is not optional** > > A request with no resolvable `orgid` is rejected before it reaches a controller, with `400` and `code: "missing_orgid"`. The only exceptions are `/health`, `/readiness`, `/favicon.ico`, OAuth callbacks under `/connect/oauth2callback/`, `/connect/webhook/*`, and two pre-signup `/org-management` paths. You can supply it four ways — header, query string, body field, or a cookie: ```http GET /repository/get/customer/1234 orgid: acme Authorization: Bearer ``` ## 2. Everything is a datatype There are no bespoke tables. Every record in the system is one of ~261 **datatypes**, enumerated in `DataType` (`@jaclight/dbsdk`). A datatype is literally the MongoDB collection name. They cluster into recognizable families: | Prefix | Family | Examples | |---|---|---| | *(none)* | Core content and identity | `page`, `site`, `collection`, `user`, `customer`, `file`, `task`, `setting` | | `sf_` | Storefront / commerce | `sf_product`, `sf_order`, `sf_cart`, `sf_invoice`, `sf_discount`, `sf_rental` | | `bm_` | HR, payroll and books | `bm_employee`, `bm_payroll_run`, `bm_leave_request`, `bm_bill`, `bm_vendor` | | `community_` | Social layer | `community_post`, `community_follow`, `community_story`, `community_reaction` | | `event_` | Events and ticketing | `event_booking`, `event_ticket`, `event_session`, `event_checkin` | | `bank_`, `ledger_`, `loan` | Banking and finance | `bank_account`, `bank_transfer`, `ledger_entry`, `journal_entry`, `loan_payment` | | `stowbo_` | Storage marketplace | `stowbo_listing`, `stowbo_unit`, `stowbo_booking` | Because the datatype is a URL segment, one generic controller serves all of them: ```http GET /repository/get/sf_product/{id} POST /repository/search/sf_product PUT /repository/create DELETE /repository/delete/sf_product/{id} ``` The full list is in the [datatype reference](/docs/platform/datatypes). ## 3. Every record is a `BaseModel` Records share one envelope. Your domain fields live under `data`; everything else is platform machinery. ```ts interface BaseModel { pk: string; // partition key sk: string; // sort key — this is the id you pass to /get/:datatype/:id name: string; data?: T; // your fields datatype?: DataType | string; subschema?: string; // a variant within a datatype version: number; state?: ModelState; // draft | pending | approved | published | archived | deleted | … createdate?: Date; modifydate?: Date; publishedDate?: Date; author?: string; requiredRole?: RequiredRoleModel; // per-record read/create/update/delete/review/approve roles workflow?: TaskModel; stats?: BaseModelStats; // likes, views, shares, ratings, reactions share?: BaseModelShare; // tokenized public share link owner?: { datatype?, id?, name?, email? }; notes?: { author, comment, date }[]; created_by?: string; modified_by?: string; } ``` Two consequences worth internalizing: - **`sk` is the id.** Not `_id`, not `data.id`. Endpoints that take `:id` take the `sk`. - **Publishing, versioning, approval, sharing and social stats are free.** They are on every datatype because they are on `BaseModel`, not bolted onto individual features. `ModelState` runs `draft → new → pending → inprogress → reviewed → approved → published → completed`, with `hold`, `rejected`, `cancelled`, `archived` and `deleted` as terminal states. ## 4. Users and customers are different identities This is the distinction that trips people up most. | | **User** | **Customer** | |---|---|---| | Who | Operates the tenant — staff, admins, builders | An end user of the tenant's site or app | | Datatype | `user` | `customer` | | Signs in at | `/profile/*` operator routes | `/profile/customer/signin`, `/profile/customer/signup` | | Token marker | `datatype` absent or `user` | `datatype: "customer"` in the JWT | | Sees | The org's whole workspace, subject to roles | Their own records | `CurrentUserMiddleware` decodes the bearer token, branches on `tokenInfo.datatype`, and populates `request.currentUser` — plus `request.currentCustomer` when the identity is a customer. There is a third mode. A **System** user (an integration or service account) can act on behalf of a customer by sending both tokens: ```http Authorization: Bearer x-client-authorization: Bearer orgid: acme ``` For repository writes, `JwtAuthGuard` will then evaluate permissions against the *customer*, and stamp the customer as `author` on created records. Omitting `x-client-authorization` on such a write returns `401 — No customer found`. ## 5. Roles gate content and components `RoleType` has fifteen values: `Guest`, `User`, `Customer`, `Owner`, `Publisher`, `Reviewer`, `PowerUser`, `ContentAdmin`, `ConfigAdmin`, `System`, `AI` (an AI employee's user), and the `Root*` variants (`RootUser`, `RootPowerUser`, `RootAdmin`, `RootSystem`). Each role expands to two permission sets — `content` (what records you may touch) and `component` (what UI you may load) — plus menu include/exclude lists that the Studio, Business Made and the mobile app use to build navigation. The sign-in profile carries each role's menu separately under `data.permissions.menu` — see [Menu access](/docs/platform/identity-and-access#menu-access). Authorization is checked in layers, in this order (`JwtAuthGuard`): **1. Public route** `@PublicRoute()` short-circuits everything. **2. Own profile** Reading or writing your own `user` record is always allowed. **3. Record ownership** On `repository/create|update|delete`, if `data.author` matches the caller, allow. **4. Per-record roles** If the record carries `requiredRole.update` / `.delete`, the caller needs one of those content permissions. **5. Decorator requirements** `@Permissions(...)` and `@Roles(...)` on the handler. `RootSystem` bypasses the role check. **6. Token validity** Finally the Passport JWT strategy runs. ## Next [Your first request](/docs/getting-started/quickstart) puts all five together against a live org. --- # Glossary > The vocabulary that shows up in Appmint source and these docs. Source: https://docs.appmint.io/getting-started/glossary.html **AppEngine** — the backend. One NestJS process serving every tenant and every client. **BaseModel** — the envelope every stored record shares. Platform fields at the top level (`pk`, `sk`, `version`, `state`, `author`…), your fields under `data`. Defined in `@jaclight/dbsdk`. **BaseModelDTO** — a list response: `data` holds the records, alongside `total`, `page`, `pageSize`, `hasNext`. **Content Player** — the Site Features row (`content-player`) that plays Content Studio posts — blog posts, galleries, courses, applications — page by page at `/content-player//`. **Content Studio** — the admin screen (DAM › Content Studio) where posts and multi-page documents are written, published and reviewed. **Collection** — a user-defined datatype. Stored as a `collection` record describing a schema; the Collection Builder in Studio creates them. **Customer** — an end user of a tenant's site or app. Datatype `customer`. Distinct from a *user*. **Datatype** — the type of a record and the name of its MongoDB collection. One of 261 values in the `DataType` enum, or a custom collection name. **dbsdk** (`@jaclight/dbsdk`) — the shared package holding types, enums, default schemas and a typed AppEngine client. Imported by both the backend and the web frontends, so model changes propagate to both. **Hub** — a physical box running **hub-agent** inside a venue, bridging USB hardware to the cloud. Registered as a `device_hub` record. **Org / orgid** — the tenant. Resolves to a dedicated MongoDB database via the org record's `data.database`. **pk / sk** — partition key and sort key. **`sk` is the record id** used in URLs; `pk` groups records. **Preview** — staff-only views of unpublished work on the site: `/__preview/page//` for a page, `/__preview/post/` for a post. **PublicRoute** — the `@PublicRoute()` decorator. Bypasses `JwtAuthGuard` entirely. 145 handlers carry it. **Site Features** — per-site rows (Payment, Forms, Blog, Content Player…) that make the site answer a built-in address; a row that takes a page uses that page as the template around it. **Root org / shared org** — platform-level orgs from the `ROOT_ORG` and `SHARED_ORG` env vars. The shared org backs multi-domain hosting where one deployment serves many customer domains. **Service account** — a user with the `System` role. Acts on behalf of a customer by sending the customer's token in `x-client-authorization`. **State** (`ModelState`) — a record's lifecycle position: `draft`, `new`, `pending`, `inprogress`, `reviewed`, `approved`, `published`, `completed`, `hold`, `rejected`, `cancelled`, `archived`, `deleted`. **Subschema** — a variant inside a datatype, in the `subschema` field. Addressed in URLs as `datatype-subschema`; the repository splits on the hyphen. **Studio** — the WebsiteMint web admin, collectively: Studio Builder plus Business App. **User** — an operator of a tenant: staff, admin, builder. Datatype `user`. --- # Architecture > What runs where, what each dependency is for, and how a request travels through AppEngine. Source: https://docs.appmint.io/platform/architecture.html Appmint is a modular monolith with thin clients. One deployable backend, one shared model package, and clients that hold almost no business logic. ## The backend process `appengine` boots a single NestJS application that serves five transports off one HTTP server: | Transport | Where | |---|---| | REST | 123 controllers | | GraphQL | Apollo driver, schema auto-generated, playground on | | Socket.IO | six gateways, Redis adapter for cross-instance fan-out | | Raw WebSocket | `/ws/hub` — the hub-agent gateway, attached to the same server | | Server-Sent Events | streaming AI and voice responses | Swagger UI is mounted at `/documentation`, built at boot with `deepScanRoutes`. ### Feature modules `app.module.ts` imports 45 feature modules. Grouped by what they are for: | Group | Modules | |---|---| | Core data | `RepositoryModule`, `DynamicQueryModule`, `HistoryModule`, `SyncModule` | | Identity | `UsersModule`, `ClientAccountModule`, `OrgManagementModule` | | Content & sites | `SiteModule`, `ContentStudioModule`, `NoticeModule`, `ToolsModule` | | Commerce | `StorefrontModule`, `SalesChannelModule`, `FinanceModule`, `AffiliateModule` | | CRM & marketing | `CRMModule`, `DataEnrichmentModule`, `BroadcastModule`, `AnalyticsModule` (analytics dashboards, the `/stats` engagement engine and Studio overviews) | | Operations | `BusinessMadeModule`, `WorkflowModule`, `CheckinModule`, `LogisticsModule`, `StaffPortalModule` | | Communications | `ChatModule`, `PhoneModule`, `VoiceModule`, `CommentsModule`, `NotesModule` | | Social | `CommunityModule`, `EventsModule` | | Money | `BankingModule` | | Storage marketplace | `StowboModule` | | Hardware | `DeviceIntegrationsModule`, `PrintModule` | | AI | `AIModule`, `DiscoveryModule` | | Platform ops | `MonitoringModule`, `UsageModule`, `K8sManagementModule`, `ConnectModule`, `UpstreamModule` | `DiscoveryModule` is worth singling out: it serves self-describing API documentation so AI agents can introspect the platform at runtime. ## The request pipeline Order matters here, and several behaviors only make sense once you know it. **1. Express middleware** `helmet` → `cookieParser` → `compression` → rate limit → `express.json({ limit: '4mb' })`. Rate limiting is 15-minute windows, `RATE_LIMIT_MAX` requests (default 10,000), keyed on IP with `trust proxy` set to exactly `TRUST_PROXY_HOPS` (default 2 — Cloudflare, then Traefik) so `X-Forwarded-For` cannot be spoofed. `/monitoring/health` is exempt. **2. CurrentUserMiddleware** Mounted on `*`. Drops scanner probes, resolves `orgid`, resolves the identity from API key / bearer token / cookie, and pre-loads the target record into `request.currentData` for permission checks. See [Identity and access](/docs/platform/identity-and-access). **3. JwtAuthGuard** Layered authorization — public route, own profile, record ownership, per-record roles, decorator roles and permissions, then token validation. **4. ValidationPipe** Global, with `whitelist: true`, `forbidNonWhitelisted: true` and `transform: true`. **Unknown body properties are rejected, not ignored** — a client sending an extra field gets a `400`. **5. Controller and service** The handler runs. Domain services talk to the repository layer. **6. ResponseTimeInterceptor** Global, stamps timing on the way out. > [!WARNING] > **CORS runs after the rate limiter** > > The rate limiter is an `app.use()` middleware and Nest applies CORS during `init()`, which is later. A short-circuited `429` therefore skipped the CORS headers and browsers reported a generic network error instead. The limiter's handler now sets `Access-Control-Allow-Origin` itself. Keep that in mind if you add any other early short-circuit. ## Data stores | Store | Role | |---|---| | **MongoDB** | Primary. One database per organization; the connection is resolved from the org record and cached per process. | | **Redis** | Cache, Socket.IO adapter, BullMQ backing store. | | **Elasticsearch / OpenSearch** | Full-text and faceted search. The repository falls back to Mongo queries when a search index is not in play. | | **S3-compatible object storage** | Files, via flydrive. Uploads return a signed URL plus generated size variants (`xs`, `sm`, `md`). | ## Background work Three Bull queues, all on Redis: | Queue | Carries | |---|---| | `datatype-queue` | Per-datatype record processing | | `schedule-queue` | Per-org schedule records and automation timers | | Sync queue (`SYNC_QUEUE`) | Platform-level syncs — deliberately separate, so a slow Facebook walk cannot delay org schedules. It also overrides Bull's default 30-second lock, which was handing long jobs to a second worker mid-flight. | Separate workers handle ads, billing, escalation and notifications. `@nestjs/schedule` provides cron; `@nestjs/event-emitter` carries in-process domain events. ## Realtime Six Socket.IO gateways: | Gateway | Purpose | |---|---| | `chat.gateway` | Customer and operator chat | | `community-chat.gateway` | Community group messaging | | `ai-voice.gateway` | CRM AI assistant voice | | `realtime-voice.gateway`, `simple-voice.gateway` | OpenAI Realtime voice streaming | | `device-events.gateway` | Hardware events fanned out to subscribed UIs | All share the Redis adapter, so any instance can deliver to any connected client. Separately, `HubGatewayService` attaches a plain `ws` server to the same HTTP listener, scoped to paths starting `/ws/hub`. It is attached in a `try/catch` — if it throws, the API still boots and logs `hub-gateway: not attached`. ## Resilience `main.ts` installs process-level handlers for `unhandledRejection` and `uncaughtException` that log and keep serving. This exists because a fire-and-forget outbound call — a Twilio request whose upstream 401s — would otherwise take the whole server down. It is a safety net, not a substitute for local error handling. HTTP keep-alive is 65 s with a 66 s headers timeout, deliberately above a typical 60 s load-balancer idle timeout so connections are closed by the LB rather than accumulating. ## The clients Every client is deliberately thin. Business rules live on the server, so the same behavior applies whether a record is changed from Studio, the mobile app, a storefront or your own code. The shared contract is `@jaclight/dbsdk`: `DataType`, `RoleType`, `BaseModel`, permission tables and default schemas, imported by the backend *and* the web clients. Change a model there and both sides move together. --- # Multi-tenancy > How an orgid becomes a database, how domains resolve to tenants, and where the isolation boundary actually is. Source: https://docs.appmint.io/platform/multi-tenancy.html ## Database per organization AppEngine does not filter by tenant column. It switches databases. ```ts async getDatabase(orgId): Promise { let dbName = this.siteToDB[orgId]; if (!dbName) { const orgModel = await this.getOrg(orgId); if (!orgModel) throw new NotFoundException('Organization or Site Not Found: ' + orgId); dbName = orgModel.data.database; this.siteToDB[orgId] = dbName; } if (!this.dbconns[dbName]) { this.dbconns[dbName] = (await this.ensureClient()).db(dbName); } return this.dbconns[dbName]; } ``` The org record carries `data.database`. Two in-process caches — `siteToDB` (org → database name) and `dbconns` (database name → connection) — mean the lookup happens once per org per process. Consequences worth stating plainly: - **Isolation is structural.** A query that forgets a tenant filter still cannot cross tenants, because it is running against a different database. - **An unknown `orgid` is a 404**, not an empty result. - **The caches are per-process and never invalidated.** Changing an org's `database` field requires a restart to take effect. - **Collections are per-tenant.** `customer` in org A and `customer` in org B are unrelated collections with independent indexes. ## Resolving the orgid `CurrentUserMiddleware` looks in four places, in order: 1. `orgid` header 2. `orgid` query parameter 3. `orgid` body field 4. `orgid` / `orgId` cookie If an array arrives, the first element wins. With nothing found, the request fails immediately: ```json { "code": "missing_orgid", "message": "Organization ID is required. Pass orgid as a header, query param, or body field." } ``` ### Routes exempt from the requirement | Path | Why | |---|---| | `/health`, `/readiness` (and `?…` forms) | A health check that needs a tenant header is not a health check — Kubernetes cannot send one. | | `/favicon.ico`, `/icons/manifest-icon-192.png` | Browser chrome. | | `/connect/oauth2callback/*` | The provider redirects here without headers; falls back to `SHARED_ORG`. | | `/connect/webhook/*` | The org is parsed out of the URL path itself. | | `/org-management/business-made-register`, `/org-management/check-org-name/*` | Pre-signup — the caller has no org yet. Falls back to `ROOT_ORG`, then `SHARED_ORG`, then `appmint`. The controller enforces an Origin allow-list instead. | ## Domain-based tenancy One deployment can serve many customer domains. That is the *shared org* pattern. Send three things: ```http domainAsOrg: true shared-org-id: x-client-host: customer-domain.com ``` The middleware then resolves the real tenant: **1. An explicit override wins** If `x-client-orgid` is present, that value is used directly. **2. Otherwise resolve the host** `getOrgIdByDomainName(x-client-host)` maps the domain to an org, and `orgid` is rewritten to it. **3. Identity still resolves against the shared org** With `domainAsOrg` set, user and customer lookups run against `shared-org-id`, not the resolved tenant. The session lives in the shared org; the data lives in the tenant's. Failure here is logged and swallowed — the request continues with the original `orgid` rather than erroring. Silent fallthrough is worth knowing about when a domain mapping looks like it is being ignored. The same mapping is available directly: ```http GET /repository/org-by-hostname/:hostname jwt ``` ## Platform-level orgs Two env vars name orgs with special standing: | Var | Role | |---|---| | `ROOT_ORG` | The platform's own org. Returned in every sign-in response as `rootOrg`. | | `SHARED_ORG` | Backs multi-domain hosting and receives OAuth callbacks that arrive without a tenant. Returned as `sharedOrg`. | The `RootSystem`, `RootAdmin`, `RootUser` and `RootPowerUser` roles are the cross-org roles that operate at this level. `RootSystem` bypasses role checks in `JwtAuthGuard` outright. ## Managing organizations ```http POST /repository/org/create jwt GET /repository/org/:orgid jwt POST /repository/org/update/:orgid jwt DELETE /repository/org/delete/:orgid jwt GET /repository/org/user/:email jwt POST /repository/org/query/:datatype jwt ``` `GET /repository/org/user/:email` answers "which orgs does this person belong to" — the account switcher depends on it. `OrgManagementModule` (`/org-management/*`) handles signup, provisioning and org-level settings. ## Scanner noise Before any of the above, the middleware answers automated vulnerability probes with a bare `404` and logs nothing: - **By path** — extensions this app never serves (`.php`, `.env`, `.sql`, `.bak`…) and well-known prefixes (`/wp-`, `/phpmyadmin`, `/.git`, `/.aws`, `/vendor/`…). - **By query string** — PHP-CGI argument injection (`allow_url_include`, `auto_prepend_file`, `php://input`), ThinkPHP and pearcmd probes, where the path itself is innocent. It matches on `request.originalUrl`, not `request.path` — with the middleware mounted on `*`, Express rewrites `req.url` to `/` on every request, so a `req.path` test would match nothing at all. --- # Identity and access > Users, customers, API keys and service accounts — and the exact order authorization is decided in. Source: https://docs.appmint.io/platform/identity-and-access.html ## Two kinds of identity AppEngine models an operator and an end user as different record types, not as one user table with a flag. | | **User** | **Customer** | |---|---|---| | Datatype | `user` | `customer` | | Represents | Staff, admins, builders — operators of the tenant | An end user of the tenant's site or app | | Sign-in | `POST /profile/signin` | `POST /profile/customer/signin` | | Sign-up | invitation flow (`/profile/user/invite/*`) | `POST /profile/customer/signup` | | Refresh | `POST /profile/user/refresh` | `POST /profile/customer/refresh` | | JWT marker | no `datatype`, or `user` | `datatype: "customer"` | | Populated on the request | `currentUser` | `currentCustomer` *and* `currentUser` | `CurrentUserMiddleware` branches on `tokenInfo.datatype`. A customer token sets both `currentCustomer` and `currentUser` — the latter so downstream code that only reads `currentUser` still works. When the caller is a non-`System` user, `currentCustomer` is set to that same user. ## Credential types ### Bearer token ```http Authorization: Bearer orgid: acme ``` The JWT payload is the signed record itself (`jwtService.sign(user)`), carrying `_id`, `datatype`, and `data.roles` / `data.permissions` — minus the record's `notes` and `data.permissions.menu`, which stay on the profile the sign-in returns (see [Menu access](#menu-access)) so they do not grow every request header. Signed with `jwtConstants.audience` and `.issuer`; lifetimes come from `JWT_EXPIRES_IN` and `JWT_REFRESH_EXPIRES_IN`. On every request the middleware **re-fetches the record from the org database** and copies `permissions` and `roles` from the token onto it. So a deleted user is rejected with `401 user_not_found`, but a role edited after issue does not take effect until the token is refreshed. ### Cookies With no `Authorization` header, the middleware falls back to a `token` or `jwt` cookie (and an `orgid` / `orgId` cookie for the tenant), synthesizing `Authorization: Bearer ` for the rest of the pipeline. This is what lets browser clients work without touching the header. ### API key ```http apiKey: orgid: acme ``` `x-api-key` works too. `authenticateApiKey(orgId, apiKey, {})` resolves the key to its owning user, and the middleware injects that user's bearer token. Keys carry the owner's roles; `ConfigAdmin` or `ContentAdmin` sets `data.admin = true`, and `System` sets `data.system = true`. Manage keys under `/api-key/*`. Keys can be blocklisted: ```http POST /profile/blacklist/apikey/add jwt DELETE /profile/blacklist/apikey/delete/:apiKey jwt ``` ### Service account acting for a customer A `System` user can act on a customer's behalf by sending both tokens: ```http Authorization: Bearer x-client-authorization: Bearer orgid: acme ``` The middleware decodes the client header, loads that customer, and sets `currentCustomer`. Then: - On `PUT /repository/create`, the customer's `sk` is stamped as `author`. - On `POST`/`PUT`/`DELETE` to `repository/create|update|delete`, `JwtAuthGuard` evaluates permissions against the **customer**, not the system user. > [!CAUTION] > **System writes require the client header** > > If a `System`-role user hits a repository write without `x-client-authorization`, the guard throws `401 — No customer found - x-client-authorization header is missing`. This is deliberate: a service account is not allowed to write as itself. ## Roles Fifteen values in `RoleType`: | Role | Scope | |---|---| | `Guest` | Unauthenticated or anonymous | | `Customer` | End user | | `User` | Basic operator | | `Owner` | Tenant owner | | `Publisher` | May publish content | | `Reviewer` | May review and approve | | `PowerUser` | Elevated operator | | `ContentAdmin` | Full content administration — sets `data.admin` | | `ConfigAdmin` | Configuration administration — sets `data.admin` | | `System` | Service account — sets `data.system` | | `AI` | An [AI employee](/docs/platform/ai-employees)'s user — held on top of the groups it is given | | `RootUser`, `RootPowerUser`, `RootAdmin`, `RootSystem` | Cross-org platform roles | Each expands into three things: a `content` permission set, a `component` permission set, and menu include/exclude lists that Studio, Business Made and the mobile app use to build navigation (see [Menu access](#menu-access)). **Content permissions** (`PermissionTypeContent`): `read`, `create`, `update`, `delete`, `review`, `approve`. **Component permissions** (`PermissionTypeComponent`): `add`, `view`, `remove`, `configure`. Roles compose. At sign-in and refresh the server resolves every role the user holds — directly and through groups — and unions their `content` and `component` sets. A role saved as a `userrole` record in the org uses that record's permissions; a built-in with no saved record uses the SDK preset; a deleted or unknown custom role grants nothing. ### Menu access Menus are the one part of a role that is **not** merged. The profile returned by sign-in and refresh carries one entry per resolved role under `data.permissions.menu`: ```json { "data": { "roles": ["Owner", "Publisher", "Warehouse"], "permissions": { "content": ["read", "create", "update", "delete", "review", "approve"], "component": ["add", "view", "remove", "configure"], "menu": { "Owner": { "menuInclude": [], "menuExclude": [], "default": "all" }, "Publisher": { "menuInclude": [], "menuExclude": [], "default": "maker" }, "Warehouse": { "menuInclude": ["/inventory", "/order", "/mobile/businessmade/pos"], "menuExclude": [] } } } } } ``` - `menuInclude` / `menuExclude` are the lists saved on the role's `userrole` record (empty when nobody has saved one). - `default` is the built-in role's menu tier, for when nobody has edited its menu: `all` for `Owner`, `System`, `RootAdmin`, `RootSystem` and `RootUser`; `admin` for `ConfigAdmin`; `manager` for `PowerUser`, `ContentAdmin` and `RootPowerUser`; `maker` for `Publisher` and `Reviewer`; `everyone` for `User`; `none` for `Guest` and `Customer`. Tiers widen — `maker` sees everything `everyone` sees, and so on. The table is `ROLE_MENU_DEFAULTS` in the SDK (`roleMenuDefault(name)`). - A built-in with neither a saved record nor a tier (`AI`) has no entry. Entries stay per role so that one role's exclude cannot remove what another role includes. Each client turns the entries into its own screens: a tier is expanded by the app against the tier each of its screens declares — the Studio sidebar, the Business Made sidebar, and the mobile app's `MOBILE_APP_MENU` (paths `/mobile//`, edited on the **Mobile app** tab of Role & Permission › Menu access). In the Studio: - The allowed set is the union of every role's includes; a role whose menu is "no access" (`menuExclude: ["all"]` with nothing included) wins over anything another role adds. - The Owner and root roles, and any user in the root or shared org, see every screen. - Web and mobile paths are independent: a role narrowed only on the Mobile app tab has not restricted the web menu. `data.permissions.menu` is **not** in the JWT; read it from the sign-in or refresh response. Menus decide what is shown, not what is allowed — every route still enforces its own permissions. ### AI employees are users An AI employee is a `user` like any operator: it signs in with the same flow — password, then the org's second factor — and what it may do is decided by its groups. Its user always holds the **AI** role and sits in the **AI** group (every new organization gets that group with its other role groups; older ones get it when their first AI employee is made). `AI` grants nothing beyond a plain user by itself; it marks the identity, so an AI employee can work only on its own queue through the AI employee API. See [AI employees](/docs/platform/ai-employees). ## How a request is authorized `JwtAuthGuard.canActivate` runs these checks in order and returns on the first that passes. **1. Public route** `@PublicRoute()` on the handler or class returns `true` immediately — every sign-in, sign-up, OAuth callback and webhook carries it. `@AuthenticatedRoute()` re-closes one route inside a public controller. **2. Pick the identity** Normally `currentUser`. But on a `POST`/`PUT`/`DELETE` to `repository/create|update|delete` by a `System`-role user, the **customer** from `x-client-authorization` is used instead — and its absence is a `401`. Then three refusals, before anything can grant access: - **Staff only** — on a handler or controller marked `@StaffOnly()`, the site's app identity (`data.system`, or the `System` role or group) and any non-`user` caller get `403` *This needs someone signed in to the business*. Operator routes a storefront must never reach use it — Content Studio's review and enrollment routes among them. - **Locked account** — a `user` whose `data.lockout` is set gets `403`, before any ownership shortcut. - **Community records** — a customer may not write `community_*` records through the generic repository writes (`403`); community changes go through the community endpoints. **3. Own-profile access** On a URL containing `/user/profile` or `/profile/user`, if the target record's `sk` equals the caller's `sk`, allow. **4. Record ownership** On repository writes, if `currentData.author` matches the caller's `sk`, `username` or `email`, allow. `CurrentUserMiddleware` pre-loaded `currentData` precisely so this check can happen. **5. Per-record required roles** If the record carries `requiredRole.update` (or `.delete`) and the caller holds one of those content permissions, allow. This is row-level security, stored on the record. **6. Decorator permissions** `@RequirePermissions(...)` — the caller needs at least one listed content permission, or the request is denied. **7. Decorator roles** `@Roles(...)` — the caller needs at least one. Two special cases: `RootSystem` in the required list passes if the user's *groups* include `RootSystem`; and a `user`-datatype caller passes automatically when `User` is in the list. > [!WARNING] > `@Roles(User)` alone also admits the site's **app token** — the identity a storefront's server proxy sends for every anonymous visitor. For an operator-only route, add `@StaffOnly()`. **8. Token validation** Only now does the Passport JWT strategy actually verify the token. > [!WARNING] > **Ownership is checked before roles** > > Steps 3–5 can grant access without any role check running. A record's `author` can always update it, regardless of the `@Roles` on the handler. That is intentional, but it means handler decorators are a floor for non-owners, not a ceiling for everyone. ## Sign-in outcomes `POST /profile/signin` returns `200` in four different situations. Only one of them is a session. ```json { "user": { … }, "orgId": "acme", "rootOrg": "…", "sharedOrg": "…", "token": "…", "refreshToken": "…" } ``` ```json { "requiresPasswordChange": true, "message": "…", "userId": "…", "email": "…" } ``` ```json { "requiresTwoFactor": true, "challengeToken": "…", "twoFactorMethod": "email|sms|authenticator", "message": "…" } ``` ```json { "requiresTwoFactor": true, "isNewDevice": true, "challengeToken": "…", "twoFactorMethod": "email", "message": "New device detected…" } ``` ## Device binding and 2FA Every sign-in fingerprints the device (`generateDeviceFingerprint` → `generateDeviceId`) and checks it against the user's known devices. - A **blocked** device is `403`, before any credential check matters. - An **unknown** device registers itself and — unless `alertOnNewDeviceLogin` is `false` — sends an alert email. - If the org sets `enableNewDeviceAuthentication`, an unknown device forces an email challenge even when 2FA is otherwise off. 2FA triggers when the org sets `enableTwoFactorForUsers` **or** the user enabled it themselves. Methods are `email`, `sms` and `authenticator`; for the first two, the code is sent before the response returns. Org-level switches live under `securitySettings`; per-user state under `/profile/security/*`. Every attempt, successful or not, is written to login history and recorded as a `user` activity. ## Other sign-in paths | Path | Endpoint | |---|---| | POS passcode / NFC card | `POST /profile/signin/passcode` — `employeeId` plus an optional 6-digit `pin` and `cardUid`. Whether the pin is required depends on the org's passcode-vs-instant setting. | | Magic link | `GET /profile/magic-link`, `POST /profile/magic-link/redirect`, and `/profile/user/magic-link` variants | | Email code | `GET /profile/code/:email` | | Google | `GET /profile/google` → `/profile/google/redirect`; token exchange at `POST /profile/customer/google/token` | | Facebook | `GET /profile/facebook/url`, `/profile/facebook`, `/profile/facebook/redirect`, `POST /profile/facebook/token` | | GitHub | `GET /profile/github/url`, `/profile/github`, `/profile/github/redirect`, `POST /profile/github/token` | | Guest | `POST /profile/guest/auth` | | Global | `POST /profile/global-login` | | Shared site token | `GET /profile/user/shared-site-auth/:token` | Microsoft and generic link-based sign-in are also supported. > [!NOTE] > **Every auth route is mounted twice** > > `@Controller(['profile', 'user'])` means `/profile/signin` and `/user/signin` are the same handler. Where the method decorator also uses an array — `@Post(['/user/signin', '/signin'])` — you get four URLs. Pick one prefix per client and stay consistent. ## Blocklists `BlacklistMiddleware` rejects blocklisted values and API keys. ```http GET /profile/blacklist/get jwt POST /profile/blacklist/add jwt DELETE /profile/blacklist/delete/:value jwt ``` --- # AI employees > Hire an AI employee, give it access, connect it to the system that runs it, confirm it is really running, give it work and answer what it asks — and what that system needs to know. Source: https://docs.appmint.io/platform/ai-employees.html An AI employee is a member of staff that is an AI. It has its own user — it signs in, holds groups like anyone, and appears in **User Management** with an *AI employee* badge. Its brain runs outside the platform, on a provider; here you manage it: who it is, what it may do, the work it gets, and the approvals it asks for. > [!NOTE] > The **AI Agent** assistant (the bar and panel you type questions into) is a different thing — it helps you. An AI employee works on its own, as itself. ## Before you start - You hold **Owner** or **ConfigAdmin**. AI Employees is under **AI, IVR, Automation › AI Employees** in the sidebar. - Decide who it reports to. Its supervisor gets a direct conversation with it in **Workspace** and answers its approvals. - Settings first, if this is your first one: **AI Employees › Settings** holds what every AI employee is told about your company — about, tone, what never to do, knowledge, escalation. Anything you leave empty falls back to the platform's defaults. ## Hire one **1. Choose a starting point.** Select **New AI employee** to start with a blank role. If your **Templates** tab contains a suitable role, use its **Hire** action instead. The current platform offers **Receptionist**, **Sales follow-up**, **Support triage**, **Accounts receivable**, **Bookkeeping assistant** and **Social media** templates. Starting blank lets you define a different role. **2. Fill in the form.** **Name** is required. Enter a **Job title** and **Job description** that explain its responsibilities. **Handle** is optional; leaving it blank derives one from the name. **Reports to** is optional. Choose a supervisor when you want that reporting relationship, then review working hours and daily budget. Under **Approvals**, choose what it must ask a person before doing: deleting, messaging many people, moving money (with an amount it may go up to), changing users and permissions. **3. Save.** It is created with its own user (`@.ai-employee.local`), placed in the **AI** group with the **AI** role, and, when a supervisor is selected, a direct Workspace conversation is opened between them. Its page opens on **Connection**. ## First exercise: create a draft project coordinator Use a draft to review the employee's identity and permissions before connecting a worker. This exercise ends with a saved employee you can reopen; it does not run an autonomous task. 1. Open **AI, IVR, Automation › AI Employees** and select **New AI employee**. 2. Enter **Tutorial Project Coordinator** for **Name**, **Training project coordinator** for **Job title**, and a handle you have not already used, such as `tutorial-coordinator-20260924`. 3. In **Job description**, enter: “Fictional training draft for reviewing AI employee setup. Remain inactive. Do not contact customers, send messages, change records, or execute work.” This describes the exercise; permissions and activation controls enforce what the employee can actually do. 4. Leave **Reports to** unselected for this draft. Review the working-hours and timezone fields. The reviewed organisation inherited around-the-clock hours and UTC; choose the appropriate timezone before any future recurring work. 5. Set the daily budget to **0** for this inactive exercise. Leave concurrent jobs at **1**, attempts at **2**, and all four approval categories requiring your OK. A zero budget is not a substitute for keeping the employee inactive. 6. Select **Create** once. The employee opens on **Connection**. ![Completed draft form before selecting Create](/images/ai-employees/draft-before-create.png) Check the result before going further: the name should match your entry, the status should read **Draft**, and the connection badge should read **Never connected**. In **Setup**, only **Created here** is complete; **Given access** is next. The reviewed draft inherited **session-manager** and showed **not provisioned**. The creation form did not contain a provider selector. ![Saved draft with the six setup milestones and no active connection](/images/ai-employees/draft-created-setup.png) Open **Overview** and inspect **Access**. **No groups yet** refers to additional permission groups: the employee still has its mandatory **AI** identity. Our saved draft's user was locked, its only group and role were AI, and its work queue was empty. Reload the page and reopen the employee to check that it persisted; do not create another copy because it has not connected yet. ![Draft access showing no additional permission groups](/images/ai-employees/draft-access-locked.png) Creation can also establish the private **AI team** workspace. That workspace is a collaboration area, not proof that a worker is running. **Provision**, **Switch on**, **Chat** and **Give work** belong to the next stage; they were not used in this draft exercise. ## Give it access **4. Choose its groups.** Under **Access** on its **Overview**, click **Change access**, tick the groups it should belong to — the same groups people get — and **Save access**. It is always in the AI group as well. Without at least one group it can do nothing, and **Switch on** says so. ## Make sure it is really running Creating it here is only our side. The **Setup** card shows how far it has got: created · given access · provisioned on its provider · switched on · signed in and said hello · finished its first job. **5. Provision it.** Click **Provision** to create the agent on its provider. The provider is given its sign-in at the same time, and the agent reads what it is told (its instructions, the company, its notes) from the API every time it signs in and before every job — nothing is copied by hand, and a change you make reaches it at once. **Check provider** asks the provider whether the agent is there and running. An employee on the **External** provider is run by a system you set up yourself: under **Connection**, click **Issue sign-in** and give that system the email, password and authenticator secret shown (once). **Issue a new sign-in** replaces them; the old ones stop working at once. > [!WARNING] > Check which provider is selected. **Stub** records simulated provisioning and does no real work. **Session manager** uses the implemented runtime provisioning/status contract; **External** is for a runtime you connect yourself. A provisioned record alone does not mean a worker has signed in or completed a job. **6. Prepare the draft for its first task.** If you followed the inactive exercise above, select **Edit** before activating it. Replace the “Remain inactive” job description with the bounded responsibility you now authorise. Set a permitted **Daily budget** for the runtime and check **Working hours** and **Timezone**; the original zero budget prevents work acquisition. For the fictional course task, a budget of **1** was used. Keep **Jobs at once** at **1** and the approval rules requiring a person. Select **Save**, reopen **Profile**, and review the persisted values, access and existing queue. **Switch it on, then wait for it to say hello.** Its sign-in is locked until you click **Switch on** (and again whenever you **Pause** it). The first time its system signs in, it says hello and names what runs it (provider, agent id, model, version). The badge by its name then reads **Online** — and afterwards **Idle** or **Offline** by how recently it was heard from. **Never connected** means its system has not signed in yet. A provider process can be running while the employee is paused; read the employee's current status separately from **Check provider**. ## Give it work and follow it **7. Pausing.** **Pause** locks its sign-in again; any token it holds stops working on the next call. **Switch on** resumes it. **8. Give it work.** **Give work** puts a job in its queue. Work also reaches it when someone assigns it a task, ticket or lead, or writes to it in Workspace or Chat. ## It keeps working on its own An AI employee does not wait to be told. The platform checks in with every switched-on employee regularly, so it is never left sitting idle: if it is busy it carries on; if not, it looks at its role and its instructions and gets on with the next useful thing. You do not set or see these check-ins — they stop when you **Pause** it. **Reports, meetings and recurring work are instructions.** Write them under **AI Employees › Instructions**, in plain words, like you would tell a person — for example *Send me a report at 5 pm every weekday*, *Post your update in the AI team workspace at 9 am*, or *Every Monday, chase invoices more than 30 days overdue*. Change them any time; it reads them fresh for every job. Figures in its reports come from the platform, not from its memory. **It keeps its own notes.** An AI employee writes a note for itself when it learns or decides something — what worked, what did not, what it will change — on its own user. Its latest notes come back to it with its briefing, so it carries them from one job to the next. Read them on its page under **Notes**. To tell it something, use **Chat** or Workspace; a note you add yourself is not read by it. ## The AI team workspace The first AI employee you hire creates a private **AI team** workspace in **Workspace**, with the AI and you in it. Every employee you hire after that joins it, with whoever hired it; removing an employee takes it out. It is an ordinary workspace — add your staff, rooms and meetings as you like — and it is where you work with your AI team: - **Goals** — write one as an agenda and make the AI its lead; it breaks the goal into tasks and the agenda's progress follows them. - **Tasks** — assign a task to an AI employee there and it lands in its queue. - **Meetings** — hold them there; at the meeting time it posts its update in the meeting. - **Reports and updates** — it posts them there. Reply in the thread to redirect it. - **Approvals** — what it asks to do appears as a card with **Approve** and **Reject**. It is the same request as under **AI Employees › Approvals**; answer it in either place. Point your AI team at another workspace, or make a new one, under **AI Employees › Settings**. **9. Read what it did.** Click a job. **What it did** lists every step it reported — its thinking, what it did (with the detail folded under **Details**), what it checked, what it saw, and any problem — then its result or error, and what it cost. ## Answer what it asks When it wants to do something on its approvals list, the job stops and waits. **Approvals** (with a count) lists everything waiting; the job itself shows *Waiting for your OK*. Add a note if you like and click **Approve** or **Reject** — the job goes back to it with your answer. An AI employee can never approve anything itself. ## For the system that runs it Everything is in its brief, live at `GET /ai-employees/me/briefing` with its own token. In short: ```http POST /profile/user/signin public POST /profile/security/challenge/verify public POST /ai-employees/me/hello jwt GET /ai-employees/me/briefing jwt POST /ai-employees/worker/{handle}/lease jwt POST /ai-employees/worker/work/{jobId}/step jwt POST /ai-employees/worker/work/{jobId}/approval jwt POST /ai-employees/worker/work/{jobId}/complete jwt POST /ai-employees/worker/work/{jobId}/fail jwt GET /ai-employees/worker/{handle}/summary jwt ?since=ISO — its work since then, counted by the server POST /notes/user/{userId} jwt a note for itself, on its own user GET /notes/user/{userId} jwt ``` A job's `source` says where it came from: `assigned`, `direct`, `message` — or `ping`, the platform's regular check-in. A ping carries no instructions of its own; what the employee does with one comes from the system instructions in its briefing. The briefing also carries `now` (the time in its timezone) and `notes` (its own recent notes). Everything else it does through the same API people use, as itself; what it may touch is decided by its groups. It talks to people in Workspace — its conversations are in its briefing. ## Common problems - **Switch on says *Give it access first*.** It has no group beyond AI. Use **Change access**. - **Nothing happens to its jobs.** Check **Setup**, its provider, recent connection, working hours/timezone, remaining daily budget and approval requests. A saved queued job cannot run while the employee is paused, outside its allowed hours or out of budget. - **Its system cannot sign in.** The sign-in was re-issued, or it is paused (a paused employee's login is locked). Issue a new sign-in and hand it over again. - **A job sits in *Needs approval*.** Answer it under **Approvals**. ## Full course [Full course: hire and supervise an AI employee](/courses-appmint/appmint-hire-and-supervise-ai-employees) — worked example, actual screenshots, permission checks, runtime controls, approvals and troubleshooting. ## Related - [Identity and access](/docs/platform/identity-and-access) - [CRM](/docs/studio-manager/crm/overview) - API reference for the calls above: the **AI Employees** and **Workspace** sections of the AppEngine documentation. --- # Data model > BaseModel, the fields you get for free on every record, and how collections and subschemas work. Source: https://docs.appmint.io/platform/data-model.html ## One envelope for everything There is no per-feature schema at the storage layer. Every record — a product, an employee, a community post, a bank transfer — is a `BaseModel` where `T` is the feature-specific payload. ```ts interface BaseModel { pk: string; sk: string; name: string; data?: T; datatype?: DataType | string; subschema?: string; version: number; state?: ModelState; createdate?: Date; modifydate?: Date; publishedDate?: Date; author?: string; created_by?: string; modified_by?: string; owner?: { datatype?: DataType; id?: string; name?: string; email?: string }; requiredRole?: RequiredRoleModel; workflow?: TaskModel; rules?: any[]; notes?: { author: string; comment: string; date: Date }[]; stats?: BaseModelStats; share?: BaseModelShare; post?: PostSubModel; style?: StyleSubModel; search?: string; create_hash?: string; client?: string; isNew?: boolean; } ``` ### Keys | Field | Type | Description | |---|---|---| | `sk` **required** | `string` | The record id. This is what `/repository/get/:datatype/:id` takes, what `author` and `owner.id` point at, and what the guard compares for ownership. **Not** `_id`. | | `pk` **required** | `string` | Partition key. Groups related records. | | `name` **required** | `string` | Human-readable label. Present on every record regardless of datatype. | | `datatype` | `DataType | string` | Which collection this lives in. A custom collection name is equally valid. | | `subschema` | `string` | A variant within the datatype. Addressed in URLs as `datatype-subschema`. | The system-owned field list is exported as `baseModelSystemFields` — useful when you need to separate platform fields from a user's own. ## What every record gets for free Because these live on `BaseModel` rather than on individual features, they work identically for all 261 datatypes. ### Lifecycle `state` moves through `ModelState`: `draft` → `new` → `pending` → `inprogress` → `reviewed` → `approved` → `published` → `completed` with `hold`, `rejected`, `cancelled`, `archived` and `deleted` as off-ramps. Driven by: ```http GET /repository/request-approval/:datatype/:id jwt POST /repository/approve/:datatype/:id jwt POST /repository/reject/:id jwt POST /repository/publish/:datatype/:id jwt POST /repository/unpublish/:datatype/:id jwt ``` ### Versioning and history `version` increments on write; prior revisions are retrievable and restorable. ```http GET /repository/history/:datatype/:id jwt POST /repository/history/:restore jwt ``` Deletes are recoverable — deleted records land in the `trash` datatype. ```http POST /repository/trash-restore jwt ``` ### Row-level permissions `requiredRole` names the content permissions needed per operation: ```ts { read?: string[]; create?: string[]; update?: string[]; delete?: string[]; review?: string[]; approve?: string[] } ``` `JwtAuthGuard` reads `requiredRole.update` and `requiredRole.delete` directly. Set them and a record enforces its own access rules, independent of the handler's decorators. ### Engagement `stats` is on every record, so any datatype can be liked, viewed or rated without new storage: ```ts { likes?, dislikes?, views?, shares?, bookmarks?, follows?, averageRating?, ratingCount?, reactions?: { author, reaction, reacted_at }[], reactionSummary?: { [type: string]: number }, last_viewed?, last_activity? } ``` The stats controller (`/stats/*`, part of `AnalyticsModule`) is the generic engine that maintains it. ### Sharing `share` is a tokenized public link with optional passcode, expiry and notification: ```ts { token: string; status: 'active' | 'revoked'; passcodeHash?: string; expiresAt?: string; notify?: { channel: 'sms' | 'email' | 'whatsapp'; to: string; sentAt?: string }[]; createdBy?: string; createdAt?: string; openedAt?: string } ``` `openedAt` records first open — enough to tell whether a shared quote was ever looked at. ### Publishing metadata `post` carries the content-publishing block used by pages, posts and any shareable record: `title`, `summary`, `allowShare`, `allowComment`, `allowRating`, `showRelated`, `categories` (tree-selected from the `category` collection), `tags` (from `tag`), and `images`. `images` is a file reference with pre-generated size variants: ```ts { path, url, contentType, isPublic, meta: { width, height, size, xs: { path, url }, sm: { path, url }, md: { path, url } } } ``` Uploads produce those variants automatically — see [Files](/docs/appengine/data/files). ### Styling `style` lets a record carry its own presentation: a `theme` (name, `darkMode` of `auto`/`dark`/`light`, property/value settings), plus raw `classes`, `css`, `javascript`, and arrays of `styleLinks` / `scriptLinks` validated against `^https?://`. ## Schema definition Schemas are JSON Schema with `x-` extensions that drive UI generation — the same document validates the data and renders the form. | Extension | Purpose | |---|---| | `x-control` | Which control to render (`ControlType.selectMany`, `.file`, `.code`…) | | `x-control-variant` | Variant — `textarea`, `chip`, `combo`, `tree`, `css`, `javascript` | | `x-render` | How to render the value when displaying | | `dataSource` | Where options come from: `{ source: 'collection', collection, value, label, children }` or `{ source: 'function', value }` | | `hidden`, `hideIn`, `readOnly` | Visibility, optionally scoped to a context such as `generator` | | `collapsible`, `group`, `layout`, `styleClass`, `styling` | Layout hints | This is why the Studio form builder needs no separate UI metadata — it renders these documents directly. ## Collections A **collection** is a user-defined datatype, stored as a `collection` record holding its schema. ```http GET /repository/collections/:name?/:subName? jwt POST /repository/collections/fix jwt ``` `collections/fix` reconciles a collection with its schema — creating the MongoDB collection and its indexes if they are missing. `createCollection` also has a time-series variant for metric-shaped data. Once defined, a custom collection is addressed exactly like a built-in datatype. ## Query responses List endpoints return `BaseModelDTO`: ```ts interface BaseModelDTO { data?: BaseModel[]; total?: number; datatype?: DataType | string; page?: number; pageSize?: number; lastPage?: number; lastItem?: number; hasNext?: boolean; sort?: any; sortType?: SortType; // asc = 1, desc = -1 modelState?: ModelState | ModelState[]; fromCache?: boolean; error?: { message: string; code: number; stalk: any }; } ``` `DataOptions` — the request-side half — additionally accepts `refresh`, `enrich`, `random`, and field controls `includeFields`, `excludeFields`, `maskFields`. > [!NOTE] > **enrich resolves references** > > With `enrich`, the repository follows references and inlines related records (`repository.enrichment.service.ts`) rather than returning bare ids. --- # Datatype reference > All 261 built-in datatypes, grouped by the part of the platform that owns them. Source: https://docs.appmint.io/platform/datatypes.html Every record in AppEngine is a datatype, and every datatype is a MongoDB collection inside the tenant's database. The list below is generated from the `DataType` enum in `@jaclight/dbsdk` — the same enum the backend and the web clients both import. Any datatype in this list can be addressed through the generic repository routes: ```http GET /repository/get/{datatype}/{sk} POST /repository/search/{datatype} PUT /repository/create POST /repository/update/{sk} DELETE /repository/delete/{datatype}/{sk} ``` Custom collections created in the Collection Builder work identically — the datatype is just the collection name. > [!NOTE] > **Subschemas** > > A datatype can carry variants in its `subschema` field. In a URL these are written `datatype-subschema`; the repository splits on the first hyphen and filters by `subschema`. ## Core platform 77 datatypes `activity` · `collection` · `page` · `category` · `tag` · `tag_group` · `comment` · `site` · `navigation` · `file` · `fileinfo` · `messagetemplate` · `passwordpolicy` · `email_account_health` · `script` · `workflow_definition` · `task` · `email_broadcast` · `access_card` · `email_account` · `ai_assistant` · `creative` · `creative_studio` · `dev_environment` · `permission` · `usage` · `setting` · `post` · `post_progress` · `ivr_routing` · `sms_routing` · `subschema` · `lead_pipeline` · `config` · `application` · `dashboard` · `ticket` · `call` · `lead` · `message` · `schedule` · `trash` · `log` · `company` · `address` · `merchant_customer` · `call_log` · `crm_form` · `signed_document` · `form_submission` · `phone` · `promotion` · `subscriber` · `location` · `flexdata` · `translation` · `event` · `reservation_definition` · `reservation` · `service_point` · `published` · `history` · `auraflow` · `campaign` · `audience` · `audience_criteria` · `apikey` · `domain_registration` · `service_pricing` · `payout` · `wallet` · `wallet_transaction` · `client_app` · `interest_rate_tier` · `broadcast_delivery` · `two_factor_backup` · `payout_batch` ## HR, payroll and books 57 datatypes `bm_employee` · `bm_timesheet` · `bm_payroll_schedule` · `bm_pay_period` · `bm_payroll_run` · `bm_schedule` · `bm_pay_stub` · `bm_payroll_profile` · `bm_tax_form` · `bm_work_order` · `bm_earning_type` · `bm_deduction_type` · `bm_employee_deduction` · `bm_tax_rule` · `bm_job_posting` · `bm_applicant` · `bm_interview` · `bm_offer` · `bm_benefit_plan` · `bm_benefit_enrollment` · `bm_leave_type` · `bm_leave_balance` · `bm_leave_request` · `bm_leave_policy` · `bm_goal` · `bm_performance_review` · `bm_pip` · `bm_course` · `bm_certification` · `bm_learning_path` · `bm_course_enrollment` · `bm_salary_grade` · `bm_compensation_change` · `bm_bonus` · `bm_department` · `bm_position` · `bm_org_chart` · `bm_employee_document` · `bm_policy` · `bm_policy_acknowledgement` · `bm_offboarding` · `bm_exit_interview` · `bm_bill` · `bm_bill_payment` · `bm_vendor` · `bm_period_close` · `bm_budget` · `bm_efile_submission` · `bm_availability` · `bm_calendar_day` · `bm_time_policy` · `bm_clock_event` · `bm_requirement_rule` · `bm_requirement_status` · `bm_journey_template` · `bm_journey` · `bm_signoff` ## Storefront & commerce 32 datatypes `sf_price_list` · `sf_attribute` · `sf_delivery` · `sf_gift_card` · `sf_invoice` · `sf_order` · `sf_product` · `sf_receipt` · `sf_refund` · `sf_shipping` · `sf_shipping_config` · `sf_rental_item` · `sf_rental` · `sf_rental_config` · `sf_subscription` · `sf_subscription_plan` · `sf_transaction` · `sf_wishlist` · `sf_brand` · `sf_collection` · `sf_cart` · `sf_return` · `sf_discount` · `sf_customer_tier` · `sf_marketing` · `sf_inventory` · `sf_inventory_intake` · `sf_inventory_transfer` · `sf_tax_rate` · `sf_payment_link` · `sf_checkout_session` · `sf_payment_button` ## Community & social 17 datatypes `community_connection` · `community_message` · `community_meeting` · `community_block` · `community_page` · `community_page_member` · `community_post` · `community_comment` · `community_reaction` · `community_story` · `community_follow` · `community_group_chat` · `community_bookmark` · `community_notification` · `community_hashtag` · `community_announcement` · `community_badge` ## Identity & access 13 datatypes `user` · `userrole` · `usergroup` · `user_invitation` · `customer_group` · `customer` · `customer_kyc` · `user_device` · `user_security` · `access_request` · `customer_association` · `customer_invitation` · `account_directory` ## Events & ticketing 8 datatypes `event_booking` · `event_ticket` · `event_ticket_type` · `event_badge_template` · `event_session` · `event_participant` · `event_checkin` · `event_credential` ## Stowbo — space custody 8 datatypes `stowbo_addon` · `stowbo_listing` · `stowbo_unit` · `stowbo_booking` · `stowbo_cart` · `stowbo_booking_item` · `stowbo_fee` · `stowbo_pickup_delegation` ## Investments & treasury 5 datatypes `money_market_account` · `investment_account` · `treasury_holding` · `certificate_of_deposit` · `investment_transaction` ## Chat & messaging 4 datatypes `chat` · `chat_message` · `chat_config` · `chat_group` ## Logistics & delivery 4 datatypes `delivery_config` · `delivery_zone` · `delivery_agent` · `delivery_job` ## Banking 6 datatypes `bank_account` · `bank_account_holder` · `bank_transfer` · `bank_card` · `bank_connection` · `bank_transaction` ## Sites & domains 3 datatypes `site_notice` · `site_index` · `site_domain` ## Automation 3 datatypes `automation` · `automation_execution` · `automation_log` ## Affiliate & referral 3 datatypes `affiliate_program` · `affiliate` · `affiliate_referral` ## Ledger & accounting 3 datatypes `ledger_account` · `ledger_entry` · `journal_entry` ## Lending 3 datatypes `loan` · `loan_application` · `loan_payment` ## Dashboards & data viz 2 datatypes `dataviz` · `dataviz_item` ## Benefits 2 datatypes `benefit` · `benefit_enrollment` ## Web analytics 2 datatypes `web_visit` · `web_activity` ## Social publishing 2 datatypes `social_activity` · `social_post` ## AI employees 3 datatypes `ai_employee` · `ai_employee_work` · `ai_employee_config` ## Workspace 2 datatypes `workspace` · `workspace_item` ## Devices & hardware 2 datatypes `device_config` · `device_hub` --- # The interface tour, step by step > Every stop on the Studio Manager interface tour, written out — what each area is, when you would use it, and where to read more. The tour's "Learn more" links land here. Source: https://docs.appmint.io/help/tour.html The interface tour walks through every part of the Studio Manager console, one area at a time. It starts from **Support › Interface tour** in the console header, and you can move with **Next** or the arrow keys, jump a chapter with **Skip section**, and stop with **End**. Most stops point at a heading or entry in the left menu, opening it first. When a stop points at an app your organization does not run, the card shows in the middle of the screen instead of waiting for it. This page is the same tour with more room: each stop says what the thing is, when you would reach for it, and what happens when you use it, with links to the full documentation. ## Chapter: Getting around ### Welcome — let me show you the whole thing The tour covers every area of the product and takes a few minutes. Use **Next** or the arrow keys to move on, **Skip section** to jump to the next chapter, and **End** to stop. You can restart it at any time from **Support** in the header. Nothing in the tour changes your data. Read more: [Studio Manager](/studio-manager/overview) · [Welcome to Appmint](/getting-started/welcome) ### The left menu is the product Every area of the console is a heading in the left menu: App Root, Build Studio, DAM, Storefront, CRM, AI & Automation, Events, Logistics, Finance, Community, Database, Configuration and Account. Each heading opens to show what is inside it, and each area opens on its own dashboard, so you see its current state before you drill in. The menu is shaped by your roles. Two people in the same organization can see different menus; hiding an area does not change what the server lets them do. Read more: [Studio Manager › The console at a glance](/studio-manager/overview#the-console-at-a-glance) · [How navigation adapts](/studio-manager/overview#how-navigation-adapts) ### Your dashboard, and Get started **Home** shows your site, your plan and your credit balance. The **Get started** card at the top stays there and tracks what is set up and what is still open — building your website, connecting a domain, turning on services — with a button to do each one. Read more: [Quickstart](/getting-started/quickstart) · [App Root › Setup wizard](/studio-manager/app-root/overview#setup-wizard) ### App Root — the things that span everything **App Root** holds what is not specific to one area: the **Inbox**, **Analytic Center**, **Dashboard Builder**, **Activity Feed**, **Domains**, **Workspace**, sites and development environments, services, and the **Setup Wizard**. Read more: [App Root](/studio-manager/app-root/overview) ### Inbox — everything people send you One inbox for email, SMS, chat, social messages and calls, threaded by conversation and attached to the customer who sent them. You can reply, reassign and mark messages from here. For most people this is where the day starts; the same inbox is in the mobile app. Read more: [App Root › Inbox](/studio-manager/app-root/overview#inbox) · [CRM › Communications](/studio-manager/crm/overview#communications) ### Analytics Traffic, sales and customer numbers across the whole business. The **Dashboard Builder** beneath it lets you put together your own view from your own data. Read more: [App Root › Dashboards](/studio-manager/app-root/overview#dashboards) ## Chapter: Your website ### Build Studio — where your website is made **Build Studio** is where you design and edit pages, forms, presentations and graphics. Opening a page gives you a canvas, a properties panel and an AI bar you can describe changes to. What you drop on the canvas is bound to your real records as you build, so a product list shows your actual catalog. Read more: [Build Studio](/studio-manager/build-studio/overview) ### New Web Page Starts a blank page on the canvas. Drag sections in, or describe what you want in the AI bar at the bottom and let it build the page for you. Read more: [Build Studio › Building a page](/studio-manager/build-studio/overview#building-a-page) ### Templates Complete, real pages you can copy into your own site — the fastest way to a site that looks finished. A template is copied into your organization with its own name and address, so changing your copy never affects anyone else's. Read more: [Build Studio › Templates](/studio-manager/build-studio/overview#templates) ### Saved Pages Every page you have made. Open one to keep editing it; nothing you build is lost behind a wizard. The Build Studio dashboard also lists what was edited recently, with preview and edit buttons. Read more: [The Build Studio dashboard](/studio-manager/build-studio/overview#the-build-studio-dashboard) · [Publishing](/studio-manager/build-studio/overview#publishing) ### Manage Root Page The layout every other page sits inside: your header, footer and site-wide styling. Change it once and every page follows. Read more: [Build Studio](/studio-manager/build-studio/overview) ### Domains Point a domain you already own at your site, or buy a new one. Until you do, your site lives on the address you were given when it was created. Read more: [App Root › Domains, sites and environments](/studio-manager/app-root/overview#domains-sites-and-environments) ## Chapter: Content & media ### DAM — your content and files **DAM** holds everything editorial: images and documents, blog posts, categories, tags, navigation menus, site notices and message templates. Anything your pages show, they pull from here. Read more: [DAM](/studio-manager/dam/overview) ### Content Studio Write and manage posts, articles and multi-page documents such as courses and trainings — separate from the design, so the same content can appear in several places. You can preview, publish and set who can open each page. Read more: [Content Studio](/studio-manager/dam/content-studio) ### Message Templates Reusable, branded emails and texts: order confirmations, receipts, reminders. Set one up once and every automation, and the Comm Center, can send it. Read more: [DAM](/studio-manager/dam/overview) ## Chapter: Selling ### Storefront — what you sell **Storefront** holds products, orders, invoices, payments, stock, shipping and discounts. There is one catalog and one stock position, so a sale at the till, an order on the website and a marketplace listing all draw from the same inventory. Read more: [Storefront](/studio-manager/storefront/overview) ### Products Add what you sell — physical goods, services, subscriptions or rentals — with name, photos, price and stock. Products can show on your website and sync out to other channels. Read more: [Storefront › Catalog](/studio-manager/storefront/overview#catalog) ### Orders Every sale, wherever it came from: your site, the till, a marketplace or an invoice you sent. Open one to fulfil it, refund it or message the customer. Read more: [Storefront › Orders](/studio-manager/storefront/overview#orders) ### Invoices Bill a customer directly. They get a live payment page by email and text, and you see the invoice settle here. Read more: [Invoices, quotes and payment links](/studio-manager/storefront/overview#invoices-quotes-and-payment-links) ### Inventory & shipping Stock levels per location, and shipping labels across carriers. Both stay in step with orders on their own. Read more: [Storefront › Inventory](/studio-manager/storefront/overview#inventory) · [Tax and shipping](/studio-manager/storefront/overview#tax-and-shipping) ### Sales Channels Push your products out to the marketplaces and shops you sell on, and keep them in sync from one place. Read more: [Selling elsewhere](/studio-manager/storefront/overview#selling-elsewhere) ## Chapter: Customers & messaging ### CRM — the people side **CRM** holds contacts, leads, conversations, campaigns, phone, social, forms and tickets. Everything you know about a customer hangs off their one record, so a lead who buys keeps the same history. Read more: [CRM](/studio-manager/crm/overview) ### Customers Everyone who has bought, booked or got in touch, with their orders, messages and notes on one screen. Read more: [CRM › Customer activity](/studio-manager/crm/overview#customer-activity) ### Comm Center Write and send an email, SMS or social post from one composer, to one person or a whole segment. Read more: [CRM › Campaigns and audiences](/studio-manager/crm/overview#campaigns-and-audiences) ### Social Media Connect your social accounts, schedule posts, and answer comments and direct messages from the same inbox as everything else. Read more: [CRM](/studio-manager/crm/overview) ### Leads People who are interested but have not bought yet, on a pipeline you can shape to your own stages, with scoring and automatic assignment to whoever should follow up. Read more: [CRM › Leads](/studio-manager/crm/overview#leads) ### Forms Contact forms, booking requests, applications. Every submission becomes a real record you can act on, and can start an automation. Read more: [CRM › Forms](/studio-manager/crm/overview#forms) · [Build Studio › Forms](/studio-manager/build-studio/overview#forms) ## Chapter: AI & automation ### AI, IVR and Automation Where you stop doing things by hand: assistants that answer customers, automations that fire on events, and phone menus that route calls. Read more: [AI, IVR & Automation](/studio-manager/ai-automation/overview) ### AI Assistant Build an assistant that knows your business and can answer customers on chat, email or the phone. You choose the tools it may use and what starts it, and you can test it before it goes live. Read more: [CRM › The AI assistant](/studio-manager/crm/overview#the-ai-assistant) · [Phone › The AI receptionist](/studio-manager/phone/overview#the-ai-receptionist) ### Automation "When this happens, do that." New order → send a receipt. Form filled → create a lead and text the owner. An automation is a trigger, optional conditions and actions, with no code. Read more: [AI, IVR & Automation › Automation](/studio-manager/ai-automation/overview#automation) ### IVR — your phone menu Decide what callers hear and where their call goes: to a person, a queue, voicemail, or an AI that can actually help them. Read more: [Phone, SMS & IVR](/studio-manager/phone/overview) · [IVR actions](/studio-manager/phone/ivr-actions) ## Chapter: Money ### Finance Wallets, payments and payouts — the money moving through your business, as opposed to the orders that caused it. Read more: [Finance](/studio-manager/finance/overview) ### Your plan and billing What you are paying Appmint, your credit balance and your usage. Services such as phone, email and AI draw down credit as you use them. Read more: [Account](/studio-manager/account/overview) · [How charging works](/billing/overview) · [Plans and pricing](/billing/plans) ## Chapter: Your data ### Database — your own data shapes The product ships with collections for products, customers and the rest. Here you can add your own fields, or whole new collections, and they get screens and forms automatically. Read more: [Database](/studio-manager/database/overview) ### Import and export Bring your existing customers, products or bookings in from a spreadsheet, and take everything out again whenever you want, as CSV or JSON. Read more: [Database › Import and export](/studio-manager/database/overview#import-and-export) ## Chapter: Setup & admin ### Configuration Settings, business locations, users and roles, integrations, phone and email — the dials you set once and rarely touch again. Read more: [Configuration](/studio-manager/configuration/overview) ### Users, groups and roles Invite your team and decide exactly what each person can see and do. Groups carry roles, so you set access once per kind of job rather than per person. Read more: [Configuration › Users, groups and roles](/studio-manager/configuration/overview#users-groups-and-roles) ### Business locations Your shops, offices or service areas. Stock, staff, tills and delivery all hang off these. Read more: [Configuration › Locations](/studio-manager/configuration/overview#locations) ### Phone & SMS Get a real business number, take calls in the browser and text customers. The phone service is provisioned for you, so there is no phone company to deal with. Read more: [Phone, SMS & IVR › Getting a number](/studio-manager/phone/overview#getting-a-number) ### Setup Wizard The checklist for switching services on: payments, phone, email, social, shipping and AI. Most of them are run for you — you just turn them on. Read more: [App Root › Setup wizard](/studio-manager/app-root/overview#setup-wizard) · [Service agreements](/billing/agreements) ## Chapter: When you get stuck ### What's this? — point at anything Click **What's this?** in the header, then click any button or panel and it tells you what that thing does. Clicks are caught while it is on, so pointing at a button is safe. Use it the moment something looks unfamiliar. Read more: [What's this?](/studio-manager/overview#what-s-this) · [Everything What's this? explains](/help/whats-this) ### The AI agent Describe what you want in plain words: the AI agent can build pages, draft content, find records and take you to the right screen. Read more: [Studio Manager › AI quick bars](/studio-manager/overview#ai-quick-bars) ### Help, how-tos and support **How do I…?** in the Support menu gives you a step-by-step walk through the real screens for one task. Below it are the documentation, a support ticket form and live chat. Read more: [Support menu](/studio-manager/overview#support-menu) · [All the how-tos](/help/how-to) ### That's everything You can restart this tour, or ask for a how-to on one specific task, from **Support** at any time. A good next stop is your website: the **Get started** card on the dashboard starts it. Read more: [Quickstart](/getting-started/quickstart) · [Build Studio](/studio-manager/build-studio/overview) --- # What's this? — the parts of the console > Everything What's this? can explain in Studio Manager, one section each — the header controls, the dashboard card and every menu area and entry — with more detail than the pop-up card and links to the full pages. Source: https://docs.appmint.io/help/whats-this.html **What's this?** is the question-mark control in the Studio Manager header. Turn it on, click anything, and a card tells you what that thing is. While it is on, clicks are caught rather than acted on, so pointing at a button is always safe. The card's **Learn more** link opens the matching section on this page. Each section below covers one part of the console: what it is, when you would use it and what happens when you do. Where there is a how-to for it, the section names it; you can run those from **Support › How do I…?**. ## The header ### What's this? Click it, then click anything on screen and you are told what it does. Click it again, or press **Escape**, to turn it off. Where the card has a related how-to it offers to run it, and where there is no written explanation, **Ask the AI** hands the control's label and the current screen to the AI agent. Read more: [Studio Manager › What's this?](/studio-manager/overview#what-s-this) ### Support The help menu. **How do I…?** searches a library of short how-tos and runs the one you pick as a guided tour over the real screens; when nothing matches, the question goes to the AI agent. **Interface tour** walks the whole console. **Documentation** opens these docs. **Submit Ticket** raises a support ticket in your own organization, and **Live Chat** opens a chat with Appmint support. Read more: [Support menu](/studio-manager/overview#support-menu) · [The how-tos](/help/how-to) · [The interface tour](/help/tour) ### AI Agent Opens the AI agent. Describe what you want in plain words and it can build pages, draft content, find records and take you to the right screen. AI features need AI switched on for the organization. How-to: **Turn on AI**. Read more: [Studio Manager › AI quick bars](/studio-manager/overview#ai-quick-bars) · [Console header and help](/studio-manager/overview#console-header-and-help) ### Phone Your business phone, in the browser. It rings here when someone calls your number, and you can dial out from it. Green means the line is connected. You need a number assigned to you first, and dialling out is a separate switch from taking calls. How-to: **Get a business phone number**. Read more: [Assigning numbers to staff](/studio-manager/phone/overview#assigning-numbers-to-staff) · [Outbound calling is a separate switch](/studio-manager/phone/overview#outbound-calling-is-a-separate-switch) ### Live chat The chat desk for the chat widget on your website. Connect here to take conversations, open the desk, or go offline. Visitor conversations come into your inbox like any other message. Read more: [Console header and help](/studio-manager/overview#console-header-and-help) · [Appmint Chat](/appmint-chat/overview) ### Notifications The bell. Its badge counts what is new for you, and it opens a panel with four tabs: **Waiting on you** (approvals assigned to you, which you can approve or decline from the row), **Mine** (decisions and changes that concern you), **Notices** (everything sent to the organization — failed sends, low credit, jobs that finished) and **Alerts** (this session's pop-up messages). Read more: [Studio Manager › Notifications](/studio-manager/overview#notifications) ### Language Changes the language of this admin interface — English, Español or Français — at once, and remembers it in this browser. It does not translate your website; that is **Translation**, under Configuration. Read more: [Studio Manager › Language](/studio-manager/overview#language) · [DAM › Translation](/studio-manager/dam/overview#translation) ### Close all Closes every open window and panel, then goes back to the app you were using before this one — or, if there is none, to the current section's dashboard. Useful when the screen has filled up. Read more: [Studio Manager › Close all](/studio-manager/overview#close-all) ### Report a bug Sends Appmint a bug report for something that is broken, rather than confusing (for confusing, use What's this?). You give a title and description; the page address, errors the browser logged and your browser details are attached for you. It is filed with Appmint, not in your own tickets. Read more: [Studio Manager › Report a bug](/studio-manager/overview#report-a-bug) ### Share workspace Meant for inviting someone into what you are looking at so you can work on it together. Sharing is not built yet: the button is in the header, but clicking it does nothing. Read more: [Studio Manager › Share workspace](/studio-manager/overview#share-workspace) ## The dashboard and the menu ### Your dashboard, and Get started **Home** shows your site, your plan and your credit balance. The **Get started** card at the top tracks what is set up and what is still open — **Build your website** starts a short guided flow, and the Active Site panel has a box to connect a domain. The **Credit Balance** tile buys credit directly. How-tos: **Build my website**, **Connect my domain**, **Check my plan, credits and bills**. Read more: [Quickstart](/getting-started/quickstart) · [How charging works](/billing/overview) ### The left menu Every area of the console is a heading here. Click a heading to open it and see what is inside. The menu follows your roles, so people with different jobs can see different menus; the server still enforces what each person may actually do. Read more: [The console at a glance](/studio-manager/overview#the-console-at-a-glance) · [How navigation adapts](/studio-manager/overview#how-navigation-adapts) ## App Root ### App Root The things that span everything: Inbox, Analytic Center, Dashboard Builder, Activity Feed, Domains, Workspace, sites and development environments, services and the Setup Wizard. Read more: [App Root](/studio-manager/app-root/overview) ### Inbox Email, SMS, chat, social messages and calls in one place, threaded by conversation and attached to the customer they came from. Replies to your social posts land here too. Read more: [App Root › Inbox](/studio-manager/app-root/overview#inbox) ### Analytics Traffic, sales and customer numbers across the whole business, with the Dashboard Builder beneath it for your own views. Read more: [App Root › Dashboards](/studio-manager/app-root/overview#dashboards) ### Domains Connect a domain you own, or buy a new one; every domain for your site is managed here. After you add a domain you are shown the DNS records to set at your registrar. Until then your site lives on the address you were given. How-to: **Connect my domain**. Read more: [Domains, sites and environments](/studio-manager/app-root/overview#domains-sites-and-environments) ### Setup Wizard The checklist for switching services on — payments (**Get paid**), phone, email and SMS, social media, shipping and AI. Most are run for you and only need turning on and agreeing to the terms; payments and social use your own accounts, so you connect those. How-tos: **Build my website**, **Take a payment / send an invoice**, **Email or text my customers**, **Get a business phone number**, **Post to social media**, **Turn on AI**. Read more: [App Root › Setup wizard](/studio-manager/app-root/overview#setup-wizard) · [Service agreements](/billing/agreements) ## Build Studio ### Build Studio Where your website is made: pages, forms, presentations and graphics on a canvas with a properties panel and an AI bar. Whatever you start from — a template, a blank page or an AI draft — opens here for editing. How-tos: **Build my website**, **Add another page to my site**. Read more: [Build Studio](/studio-manager/build-studio/overview) ### New Web Page Starts a blank page on the canvas. Drag in sections, or describe what you want in the AI bar and let it build. Read more: [Building a page](/studio-manager/build-studio/overview#building-a-page) ### Templates Complete pages — About, Contact, Services and more — that you copy into your own site. Faster, and better-looking, than a blank page. Read more: [Build Studio › Templates](/studio-manager/build-studio/overview#templates) ### Saved Pages Every page you have made. Open one to keep editing it. Read more: [The Build Studio dashboard](/studio-manager/build-studio/overview#the-build-studio-dashboard) ### Manage Root Page The layout everything else sits inside — header, footer and site-wide styling. Change it once and every page follows. Read more: [Build Studio](/studio-manager/build-studio/overview) ## DAM ### DAM Your content and files: images, documents, posts, categories, navigation menus, site notices and message templates. Anything your pages show, they pull from here. Read more: [DAM](/studio-manager/dam/overview) ### Content Studio Write and manage posts, articles and multi-page documents, separate from the design so the same content can appear in several places. Read more: [Content Studio](/studio-manager/dam/content-studio) ### Message Templates Branded emails and texts you set up once — confirmations, receipts, reminders — and send in one click. Automations use them too. Read more: [DAM](/studio-manager/dam/overview) ### Navigation Where your site menu is built. A new page is not in your navigation until you add it here. Menus are records, so a change applies everywhere the menu is shown. How-to: **Add another page to my site**. Read more: [DAM › Navigation](/studio-manager/dam/overview#navigation) ## Storefront ### Storefront What you sell and every order: products, orders, invoices, payments, stock, shipping, returns and discounts, all against one catalog and one stock position. How-to: **Add a product to sell**. Read more: [Storefront](/studio-manager/storefront/overview) ### Products Goods, services, subscriptions or rentals — name, photos, price and stock. Products can show on your website and sync out to other channels. Read more: [Storefront › Catalog](/studio-manager/storefront/overview#catalog) ### Orders Every sale, wherever it came from. Open one to see the customer, the items and the money, and to fulfil it, refund it or message the buyer. How-to: **Handle an order that came in**. Read more: [Storefront › Orders](/studio-manager/storefront/overview#orders) ### Invoices Bill a customer directly. Add the customer and the lines and send it; they get a live payment page by email and text, and you see it settle here. How-to: **Take a payment / send an invoice**. Read more: [Invoices, quotes and payment links](/studio-manager/storefront/overview#invoices-quotes-and-payment-links) ### Inventory Stock levels, tracked per location, so you can see what is where. Stock moves with orders on its own. How-to: **Set up my shop or office locations**. Read more: [Storefront › Inventory](/studio-manager/storefront/overview#inventory) ### Shipping Buy a shipping label across carriers; the tracking goes back onto the order and out to the customer. Read more: [Tax and shipping](/studio-manager/storefront/overview#tax-and-shipping) · [Logistics](/studio-manager/logistics/overview) ### Returns Handles return requests. Refunds themselves are issued from the order, against the actual payment transactions. Read more: [Storefront › Returns](/studio-manager/storefront/overview#returns) ### Sales Channels Controls which shops and marketplaces your products sync out to, from one place. A product with no channel set goes everywhere. Read more: [Selling elsewhere](/studio-manager/storefront/overview#selling-elsewhere) ## CRM ### CRM The people side: contacts, leads, conversations, campaigns, phone, social, forms and tickets, all hanging off one customer record. Read more: [CRM](/studio-manager/crm/overview) ### Customers Everyone who has bought, booked or got in touch, with their orders, messages and notes on one screen. To bring an existing list in, use **Import, Export Data** under Database. How-to: **Add or find a customer**. Read more: [CRM › Customer activity](/studio-manager/crm/overview#customer-activity) ### Comm Center One composer for email, SMS and social posts, to one person or a whole segment. Sending needs email and SMS switched on in the Setup Wizard first. How-to: **Email or text my customers**. Read more: [Campaigns and audiences](/studio-manager/crm/overview#campaigns-and-audiences) · [CRM › Communications](/studio-manager/crm/overview#communications) ### Social Media Write a post once, send it to several networks and schedule it on a calendar. Comments and direct messages come back to your inbox, attached to the person who sent them. How-to: **Post to social media**. Read more: [CRM](/studio-manager/crm/overview) ### Leads People who are interested but have not bought yet, on a pipeline you shape to your own stages, with scoring and automatic assignment. Forms, inbound calls and messages all create leads. How-to: **Track people who have not bought yet**. Read more: [CRM › Leads](/studio-manager/crm/overview#leads) ### Forms Contact forms, booking requests and applications. Every submission becomes a real record — often a lead — that you can act on or hand to an automation. Read more: [CRM › Forms](/studio-manager/crm/overview#forms) ## AI & automation ### AI, IVR and Automation Assistants that answer customers, automations that fire on events, and phone menus that route calls. Read more: [AI, IVR & Automation](/studio-manager/ai-automation/overview) ### AI Assistant Build an assistant that knows your business and answers customers on chat, email or the phone. How-to: **Turn on AI**. Read more: [CRM › The AI assistant](/studio-manager/crm/overview#the-ai-assistant) ### Automation "When this happens, do that." New order → send a receipt; new lead → text or email them at once and remind you if nobody replies. No code. How-to: **Track people who have not bought yet**. Read more: [AI, IVR & Automation › Automation](/studio-manager/ai-automation/overview#automation) ### IVR Your phone menu: what callers hear and where the call goes — a person, a queue, voicemail or an AI that can help. How-to: **Get a business phone number**. Read more: [Phone, SMS & IVR](/studio-manager/phone/overview) · [IVR actions](/studio-manager/phone/ivr-actions) ## Money and account ### Finance Wallets, wallet transactions, payments and payouts — the money moving through the business, as opposed to the orders that caused it. Read more: [Finance](/studio-manager/finance/overview) ### Billing Your plan, your invoices and your credit balance. Phone, email and AI draw down credit as you use them. How-to: **Check my plan, credits and bills**. Read more: [Account](/studio-manager/account/overview) · [How charging works](/billing/overview) ### Usage Where your credit actually went, by service and over time. Look here before assuming something is wrong with a bill. Read more: [Account › Usage](/studio-manager/account/overview#usage) · [Service rates](/billing/service-rates) ## Database ### Database The collections that hold your data. Add your own fields to the built-in ones, or whole new collections, and they get screens and forms automatically. Read more: [Database](/studio-manager/database/overview) ### Import, Export Data Bring customers, products or bookings in from a spreadsheet, and export any collection as CSV or JSON. After a large import, re-index the collection so the records show up in search. Read more: [Database › Import and export](/studio-manager/database/overview#import-and-export) ## Configuration ### Configuration Settings, business locations, users and roles, integrations, phone and email — set once, rarely touched again. Read more: [Configuration](/studio-manager/configuration/overview) ### User, Group Invite people by email and put them in the groups that match what they do. Groups carry roles, so access is set per kind of job. How-to: **Invite my team**. Read more: [Users, groups and roles](/studio-manager/configuration/overview#users-groups-and-roles) ### Role & Permission Controls exactly which screens and actions each role gets, and which menu entries its people see. How-to: **Invite my team**. Read more: [Users, groups and roles](/studio-manager/configuration/overview#users-groups-and-roles) · [How navigation adapts](/studio-manager/overview#how-navigation-adapts) ### Business Locations Each place you operate from. Stock, staff, tills and delivery all hang off these. How-to: **Set up my shop or office locations**. Read more: [Configuration › Locations](/studio-manager/configuration/overview#locations) ### Phone & SMS Search for and activate a real business number, take calls in the browser and text customers. Call logs live here too. How-to: **Get a business phone number**. Read more: [Phone, SMS & IVR › Getting a number](/studio-manager/phone/overview#getting-a-number) --- # How do I…? — the guided how-tos > Every how-to from Support › How do I…? in Studio Manager, written out step by step, with what happens at each step and where to read more. Source: https://docs.appmint.io/help/how-to.html **Support › How do I…?** in the Studio Manager header searches a library of short how-tos. Pick one and it runs as a guided tour over the real screens, so you end up on the right screen with the right control lit up. This page is the same library written down: the steps as the console shows them, what happens when you do them, and the full documentation for each job. The how-tos are grouped as they are in the menu: **Website**, **Selling**, **Customers**, **Messaging**, **Money & plan** and **Setup**. ## Website ### Build my website Pick a design or let AI build it, then edit and publish. 1. **Start from Get started.** The **Get started** card on your dashboard has **Build your website**, which opens a short guided flow — the fastest route. 2. **Name it, then choose how to start.** Give the site a name and one line about the business. Then either pick a ready-made design, or describe what you do and let AI build the page for you. (The **Setup Wizard** under App Root is lit up here.) 3. **Edit it in Build Studio.** Whatever you started from opens in Build Studio. Click any text or image to change it, drag sections around, or keep talking to the AI bar at the bottom. 4. **Put it on your own domain.** **Domains**, under App Root, is where you connect a domain you own or buy a new one. Until then your site lives on the address you were given. Read more: [Quickstart](/getting-started/quickstart) · [Build Studio](/studio-manager/build-studio/overview) · [Publishing](/studio-manager/build-studio/overview#publishing) ### Add another page to my site About, Contact, Services — same three steps as the first one. 1. **Open Build Studio.** Everything about pages lives under this heading. 2. **Start from a template.** **Templates** has real About, Contact and Services pages you can copy in — faster and better-looking than a blank page. 3. **Or start blank.** **New Web Page** gives you an empty canvas; then describe what you want in the AI bar and it will build it. 4. **Link it from your menu.** A new page is not in your site navigation until you add it. **Navigation**, under DAM, is where your site menu is built. Read more: [Build Studio › Templates](/studio-manager/build-studio/overview#templates) · [DAM › Navigation](/studio-manager/dam/overview#navigation) ### Connect my domain Point a domain you own at your site, or buy one. 1. **Open Domains.** Under App Root. Every domain for your site is managed here. 2. **Or connect it from the dashboard.** The Active Site panel on your dashboard has a domain box: type the domain and press **Connect**. You are shown the DNS records to add at your registrar; once they are in place the domain points at your site. Read more: [Domains, sites and environments](/studio-manager/app-root/overview#domains-sites-and-environments) ## Selling ### Add a product to sell List something, price it, and show it on your site. 1. **Open Storefront.** Everything you sell and every order lives under this heading. 2. **Products.** Add what you sell — goods, services, subscriptions or rentals — with a name, photos, price and stock. 3. **Decide where it shows.** **Sales Channels** controls which shops and marketplaces a product syncs out to. Leave it empty and it goes everywhere. 4. **Make sure you can take money.** Payments land in your own account, so you connect your own processor: **Setup Wizard › Get paid** does it in a couple of minutes. Read more: [Storefront › Catalog](/studio-manager/storefront/overview#catalog) · [Selling elsewhere](/studio-manager/storefront/overview#selling-elsewhere) · [Storefront › Payments](/studio-manager/storefront/overview#payments) ### Take a payment / send an invoice Bill a customer and get paid online. 1. **Connect a payment processor first.** **Setup Wizard › Get paid** — Stripe or PayPal, on your own account, so the money goes straight to you. 2. **Create an invoice.** **Storefront › Invoices**. Add the customer and the lines and send it; the customer gets a live payment page by email and text. 3. **Watch it settle.** **Payments** shows what has actually been collected, and **Orders** shows what it was for. Read more: [Invoices, quotes and payment links](/studio-manager/storefront/overview#invoices-quotes-and-payment-links) · [Storefront › Payments](/studio-manager/storefront/overview#payments) ### Handle an order that came in Find it, fulfil it, refund it, or message the buyer. 1. **Orders.** Every sale, wherever it came from. Open one to see the customer, the items and the money. 2. **Shipping.** Buy a label across carriers, and the tracking goes back onto the order and out to the customer. 3. **Returns and refunds.** **Returns** handles the request; refunds are issued from the order itself, against the actual transactions. Read more: [Storefront › Orders](/studio-manager/storefront/overview#orders) · [Tax and shipping](/studio-manager/storefront/overview#tax-and-shipping) · [Storefront › Returns](/studio-manager/storefront/overview#returns) ## Customers ### Add or find a customer One record with their orders, messages and notes. 1. **Customers.** Under CRM. Everyone who has bought, booked or got in touch, with their whole history on one screen. 2. **Bringing a list in.** **Import, Export Data** under Database takes a spreadsheet of your existing customers and creates them properly. Read more: [CRM › Customer activity](/studio-manager/crm/overview#customer-activity) · [Database › Import and export](/studio-manager/database/overview#import-and-export) ### Track people who have not bought yet Leads, pipelines, scoring and follow-up. 1. **Leads.** A pipeline you shape to your own stages, with scoring and automatic assignment to whoever should chase it. 2. **Where leads come from.** Forms on your site, inbound calls and messages all create leads. **Forms** is where you build the form itself. 3. **Chase them automatically.** **Automation** can text or email a new lead the moment it arrives, and remind you if nobody has replied. Read more: [CRM › Leads](/studio-manager/crm/overview#leads) · [CRM › Forms](/studio-manager/crm/overview#forms) · [Automation](/studio-manager/ai-automation/overview#automation) ## Messaging ### Email or text my customers One person or a whole list, from one composer. 1. **Turn on sending first.** **Setup Wizard › Email & SMS**. You can send through Appmint, or connect your own provider. 2. **Comm Center.** Write and send an email, SMS or social post from one place — to one person or a segment. 3. **Reuse what you write.** **Message Templates** under DAM keeps branded emails and texts you can send in one click, and automations can use them too. Read more: [Campaigns and audiences](/studio-manager/crm/overview#campaigns-and-audiences) · [DAM](/studio-manager/dam/overview) · [Service agreements](/billing/agreements) ### Get a business phone number Take calls and texts without a phone company. 1. **Turn phone service on.** **Setup Wizard › Business phone**. The service is provisioned for you; you agree to the terms and it is on. 2. **Pick your number.** **Phone & SMS** under Configuration is where you search for and activate a real number, and where call logs live. 3. **Decide what callers hear.** **IVR** builds your phone menu — press 1 for sales, ring these people, or hand the call to an AI that can actually help. 4. **Answer from your browser.** The phone control in the header rings when a call comes in; no handset needed. Read more: [Phone, SMS & IVR](/studio-manager/phone/overview) · [Buying one, step by step](/studio-manager/phone/overview#buying-one-step-by-step) · [IVR actions](/studio-manager/phone/ivr-actions) ### Post to social media Connect accounts, schedule posts, answer comments. 1. **Connect your accounts.** **Setup Wizard › Social media**. These are your own accounts, so you sign in to each one once. 2. **Social Media.** Under CRM. Write a post once, send it to several networks, and schedule it on a calendar. 3. **Replies come to your inbox.** Comments and direct messages land in the same inbox as email and SMS, attached to the person who sent them. Read more: [CRM](/studio-manager/crm/overview) · [App Root › Inbox](/studio-manager/app-root/overview#inbox) ## Money & plan ### Check my plan, credits and bills What you are paying, and what your usage is costing. 1. **Billing.** Your plan, your invoices and your credit balance. Services like phone, email and AI draw down credit as you use them. 2. **Usage.** Where the credit actually went — by service, over time. Check here before you assume something is wrong. 3. **Top up from the dashboard.** The **Credit Balance** tile on your dashboard buys credit directly. Read more: [Account](/studio-manager/account/overview) · [How charging works](/billing/overview) · [Service rates](/billing/service-rates) · [Plans and pricing](/billing/plans) ## Setup ### Invite my team Add people and control what they can see. 1. **Users and groups.** **Configuration › User, Group**. Invite by email and put people in the groups that match what they do. 2. **Decide what they can do.** **Role & Permission** controls exactly which screens and actions each group gets. Read more: [Users, groups and roles](/studio-manager/configuration/overview#users-groups-and-roles) · [How navigation adapts](/studio-manager/overview#how-navigation-adapts) ### Turn on AI Use Appmint's AI, or bring your own key. 1. **Switch it on.** **Setup Wizard › AI**. Built-in AI needs no account of your own — agree to the terms and it works everywhere in the product. 2. **Use it anywhere.** The **AI agent** in the header can build pages, draft content, find records and take you to the right screen. 3. **Give customers their own assistant.** **AI Assistant** builds one that knows your business and can answer on chat, email or the phone. Read more: [Studio Manager › AI quick bars](/studio-manager/overview#ai-quick-bars) · [CRM › The AI assistant](/studio-manager/crm/overview#the-ai-assistant) · [Service agreements](/billing/agreements) ### Set up my shop or office locations Stock, staff and delivery all hang off these. 1. **Business locations.** **Configuration › Business Locations**. Add each place you operate from. 2. **Stock follows locations.** Inventory is tracked per location, so you can see what is where. Read more: [Configuration › Locations](/studio-manager/configuration/overview#locations) · [Storefront › Inventory](/studio-manager/storefront/overview#inventory) · [Multi-location](/studio-manager/overview#multi-location) --- # Studio Manager > The console you sign in to — what each area does, and how the whole surface fits together. Source: https://docs.appmint.io/studio-manager/overview.html Studio Manager is the console. Everything you configure, build, sell, staff and report on happens here, and it all acts on the same records the API, the mobile app and the event platform use. This page is the map. Each area below has its own section. ## The console at a glance Studio Manager's sidebar has thirteen areas, plus trash. | Area | What lives there | |---|---| | **[App Root](/docs/studio-manager/app-root/overview)** | Inbox, analytics, dashboard builder, activity feed, domains, sites and dev environments, services, service pricing, setup wizard | | **[Build Studio](/docs/studio-manager/build-studio/overview)** | Web pages, forms, presentations, graphic design, root page, templates | | **[DAM](/docs/studio-manager/dam/overview)** | [Content Studio](/docs/studio-manager/dam/content-studio) — posts, multi-page documents and courses — plus categories, navigation, tags, comments, documents, site notices, message templates | | **[Storefront](/docs/studio-manager/storefront/overview)** | Products, orders, subscriptions, invoices, shipping, pricing, returns, discounts, gift cards, cart, inventory, rental, attributes, brands, sales channels | | **[CRM](/docs/studio-manager/crm/overview)** | Leads, marketing and ads, social, broadcast, promotions, merchant accounts, affiliates, phone and SMS, email accounts, tickets, events, reservations, forms, calendar, customer activity, chat | | **[AI, IVR & Automation](/docs/studio-manager/ai-automation/overview)** | AI assistant, IVR and phone system, automation, workflow, schedules | | **[Events](/docs/studio-manager/events/overview)** | Events, sessions, ticket types, tickets, bookings, badge templates, speakers, exhibitors, check-ins, leads | | **[Logistics](/docs/studio-manager/logistics/overview)** | Delivery zones, agents and jobs | | **[Finance](/docs/studio-manager/finance/overview)** | Wallets, payouts, wallet transactions, payments | | **[Community](/docs/studio-manager/community/overview)** | Pages, feed, stories, connections, messages, groups, meetings, announcements, badges, hashtags, moderation, notifications | | **[Database](/docs/studio-manager/database/overview)** | Collections, sub-schemas, import and export, data explorer | | **[Configuration](/docs/studio-manager/configuration/overview)** | Settings, business locations, users and groups, roles and permissions, password policy, integration config, scripts, translation, blacklist | | **[Account](/docs/studio-manager/account/overview)** | Profile, billing, API keys, usage, upgrade, logs | Each area opens on its own dashboard, so entering a section gives you its current state before you drill into a specific screen. The figures are computed on the server (`GET /studio-overview/:section` — see [Platform operations](/docs/appengine/modules/platform-operations#analytics-stats-and-overviews)). ## How navigation adapts The console does not show everyone the same menu. What appears is decided by the signed-in user's roles. Each role carries a menu include and exclude list alongside its permissions, so a warehouse user and a finance user sign into the same console and see different consoles. This is configuration, not separate builds — see [Configuration](/docs/studio-manager/configuration/overview). > [!NOTE] > **Hidden is not the same as blocked** > Menus adapt for usability, but access is enforced on the server. Hiding an area does not grant a shortcut around its permissions, and revealing one does not bypass them. ## AI quick bars Screens with an AI helper — Build Studio, Creative Studio, Content Studio posts, collections, leads and the lead prospector, marketing, broadcast, message templates, compose, automation, IVR and data visualisation — show it as a quick bar floating at the bottom right, clear of the approvals tab on the right edge. Minimise a bar and it becomes a slim tab on the right edge, like the approvals tab, with a dot while the assistant is still working. The choice is remembered per bar, so a bar you put away stays away on the next visit. ## Console header and help The strip across the top of the console holds the controls that do not belong to any one area. Left to right: | Control | What it does | |---|---| | **Close all** (✕, far left) | Closes every open window and panel, then goes back to the app you used before this one — see [Close all](#close-all) | | Screen controls | A few screens add their own controls here: Build Studio its editing tools, Inbox its channels, Workflow its tabs | | **Language** | The admin interface's language — see [Language](#language) | | **Phone** | The browser softphone — see [Phone, SMS & IVR](/docs/studio-manager/phone/overview#assigning-numbers-to-staff) | | **Live chat** | Connect to the chat desk, open it, or go offline — see [the agent desk](/docs/appmint-chat/agent-desk) | | **AI employees** | Which AI employees are online, with one click to chat with one or open it. Hidden for anyone who cannot manage AI employees | | **AI Agent** | Opens the AI agent panel | | **What's this?** | Point-and-explain help — see [What's this?](#what-s-this) | | **Support** | The help menu — see [Support menu](#support-menu) | | **Report a Bug** | Sends a bug report to Appmint — see [Report a bug](#report-a-bug) | | **Notifications** (bell) | Approvals waiting on you, notices and alerts — see [Notifications](#notifications) | | **Share workspace** | Not active yet — see [Share workspace](#share-workspace) | | **Sign out** (power icon) | Ends the session | ### Support menu **Support** opens a short menu: - **How do I…?** — search a library of how-tos ("Connect my domain", "Add a product to sell", "Invite my team"…). Picking one runs a guided tour over the real screens, so it ends with you on the right screen with the right control lit up. When nothing in the library matches, the question goes to the AI agent instead. - **Interface tour** — a walkthrough of the whole console: the sidebar, each area, and the controls in this header. - **Documentation** — opens this documentation in a new tab. - **Submit Ticket** — a form with a subject, priority (low, medium or high), description and attachments; subject and description are required. The ticket is created as a support ticket in your own organization's [tickets](/docs/studio-manager/crm/overview#support-tickets). - **Live Chat** — opens a chat with Appmint support. ### What's this? Click **What's this?** and the cursor becomes a question mark. Hover to see what you are about to ask about, then click anything on screen to get an explanation of it. While it is on, clicks are captured rather than acted on — pointing at **Delete** explains Delete instead of deleting. Where there is no written explanation for a control, **Ask the AI** hands its label and the current screen to the AI agent. Click the button again, or press Escape, to stop. ### Notifications The bell's red badge counts what is new for you: unread notices, your own unread lines and approvals waiting on you. It opens a panel with tabs: | Tab | What is in it | |---|---| | **Waiting on you** | Approval tasks assigned to you. Approve or decline straight from the row, or open it to add a note, hand it to someone else, or escalate it | | **Mine** | Your own lines, newest first — decisions, assignments and access changes that concern you | | **Notices** | Everything sent to the organization, by the platform or by your own admins, filterable by All, Unread or Platform. A notice you dismissed can be read again here, and opening an unread one marks it read | | **Alerts** | This session's pop-up messages; **Clear** empties the list. They are gone on reload | The same panel also opens as a window of its own, from the bell on the setup dashboard. ### Close all When the screen has filled up, **Close all** clears it in one click. It closes every open window and panel first, then steps back to the app you were using before the current one; if there is none, it opens the current section's dashboard, and failing that App Root. ### Report a bug **Report a Bug** is for something that is broken, as opposed to confusing — use What's this? for that. The form asks for a title and a description (both required), and optionally error details and files such as screenshots. The page address is filled in for you, and errors the browser logged while you worked are captured and shown in the form. Browser, operating system, screen and window size are attached automatically. The report is filed as a high-priority bug ticket with Appmint, not in your own organization's tickets. ### Share workspace The share-workspace button is in the header, but sharing is not built yet: clicking it does nothing. ## Language The language picker in the header shows the language currently in use — English, Español or Français — and switching it applies at once and is remembered in the browser. ## Multi-location Organizations operating in more than one place switch location context in the console. Inventory, staff, schedules, service points and reporting all scope to the selected location. Locations are defined under Configuration, and most operational areas respect the current selection rather than requiring separate setup per site. ## Trash Deletes across the console are recoverable. Removed records move to trash and can be restored, rather than disappearing immediately. ## The two halves Studio Manager spans two surfaces that share one login and one dataset: **The builder** — Build Studio (pages and forms), DAM (Content Studio posts and courses, and the taxonomy around them) and Database. This is where the customer-facing side and the data behind it are made. **The business console** — storefront, CRM, events, logistics, finance, community and configuration. This is where the business is run. You do not choose between them. Which areas appear depends on what your organization has enabled and what your role allows. ## Related ```cards cols: 3 items: - title: Appmint Mobile href: /docs/appmint-mobile/overview icon: smartphone body: The same operations, on a phone. - title: Vibe Studio href: /docs/vibe-studio/overview icon: paint body: Generate an app, then refine it visually. - title: AppEngine API href: /docs/appengine/overview icon: code body: Everything the console does, over the API. ``` --- # App Root > Inbox, analytics, dashboards, activity feed, domains, sites, services and the setup wizard. Source: https://docs.appmint.io/studio-manager/app-root/overview.html App Root is the top of the console — the cross-cutting things that are not specific to any one area. ## What lives here | Area | What it does | |---|---| | **Inbox** | Every conversation across channels in one place | | **Analytic Center** | Analytics across the organization | | **Dashboard Builder** | Build your own dashboards from your data | | **Activity Feed** | What has been happening, as it happens | | **Domains** | Custom domains and their mapping | | **Setup Wizard** | Guided first-run configuration | | **Workspace** | Shared working space and its items | | **Site & Dev Environments** | Sites, deployments and development environments | | **Services** | Running services and their state | | **Service Pricing** | What services cost, and how they are priced | ## Inbox One inbox for chat, SMS, email and platform notifications, threaded by conversation. Messages can be replied to, reassigned and marked, and the same inbox is available in [Appmint Mobile](/docs/appmint-mobile/users/crm). ## Dashboards The Analytic Center gives prepared analytics; the Dashboard Builder lets you assemble your own from any data in the platform, rather than being limited to a fixed set of reports. ## Domains, sites and environments A site can carry its own custom domain. Development environments let you work on a site without touching the live one, and can be given their own hosting and domain for review before promoting. ## Setup wizard A new organization starts empty. The setup wizard walks through the initial configuration — locations, base data and the modules you intend to use — rather than leaving you to find each screen. ## Related ```cards cols: 3 items: - title: Build Studio href: /docs/studio-manager/build-studio/overview icon: paint body: Where pages and forms are made. - title: Configuration href: /docs/studio-manager/configuration/overview icon: shield body: Users, roles, locations and integrations. - title: Account href: /docs/studio-manager/account/overview icon: user body: Billing, API keys and usage. ``` --- # Build Studio > Build pages, forms, presentations and graphics — bound to live data — then publish. Source: https://docs.appmint.io/studio-manager/build-studio/overview.html The builder is where the customer-facing side is made — pages, forms and layouts, dragged onto a canvas and bound to real records rather than placeholder text. ## Building a page Drag components onto the canvas, arrange them, and bind them to data. A product list shows your actual catalog; a detail page resolves real relationships; a form writes a real record. Because binding happens at build time, there is no second phase where you connect the design to a backend. It was connected as you built it. Double-click text on the canvas to edit it in place. The caret lands where you double-clicked (the word under it stays selected), and you can drag across words to select them — dragging text no longer starts moving the element while you are editing. ## Forms Forms are built the same way as pages and submit into the platform. A contact form becomes a lead, an application form becomes a record, and a booking form becomes a reservation — without wiring anything up. Form definitions and their submissions are stored, so responses are queryable rather than only emailed. ## Responsive design Layouts adapt across breakpoints from the same design. You are not maintaining a desktop page and a separate mobile one. ## SEO and metadata Page titles, descriptions, social preview images, categories and tags are part of the page rather than a plugin. Records carry a publishing block covering the same ground, so a product or post exposes proper metadata wherever it is rendered. Posts from [Content Studio](/docs/studio-manager/dam/content-studio) play on your site in the [Content Player](/docs/building/content-player) (Site Features › Content Player), inside the page you pick for it. ## Publishing Pages have a lifecycle rather than a save button — draft, review, approve, publish — with versioning behind it. Earlier versions can be restored, and unpublishing removes a page from the live site without deleting it. Sites can carry a custom domain, and a page can be previewed exactly as it will appear published: **Preview** opens `/__preview/page//` on the active site, for signed-in staff. Content Studio previews posts at `/__preview/post/`. ## The Build Studio dashboard The Build Studio section opens on its dashboard. Each row under **Recently edited** shows what the page is, where and when it was changed, and its status, with two icon buttons: **Preview** (the eye) opens the page on the site as a visitor sees it, and **Edit** (the pencil) opens it in Build Studio. ## Graphic design Creative Studio, the graphics editor, re-measures text whenever a web font finishes loading — a slow font, one picked in the panel, or one an AI edit brought in — so text is never laid out with a fallback font's metrics. ## Templates Pages can be created from templates, including templates shared across the platform, which are copied into your organization with their own name and slug rather than referenced. ## Related ```cards cols: 3 items: - title: Database href: /docs/studio-manager/database/overview icon: db body: Define the data pages bind to. - title: DAM href: /docs/studio-manager/dam/overview icon: file body: Content Studio (posts and courses), documents, navigation and templates. - title: Vibe Studio href: /docs/vibe-studio/overview icon: paint body: Generate a page from a description, then refine it here. ``` --- # Runtime attributes > The data-* attributes that make a published page live — binding, lists, actions, visibility and formatting. Source: https://docs.appmint.io/studio-manager/build-studio/runtime-attributes.html Pages built in Build Studio are plain HTML until the runtime attaches. It scans for `data-*` attributes, wires them up, and keeps them in sync as data changes. No build step and no framework. You can hand-write these attributes into a page, and the property panel writes the same ones when you configure an element visually. ## The full attribute set | Attribute | Purpose | |---|---| | `data-bind` | Set `textContent` from a path or expression | | `data-bind-html` | Set sanitized `innerHTML` | | `data-bind-attr-` | Set an HTML attribute — `data-bind-attr-src="data.image"` | | `data-bind-class` | Set `className` | | `data-bind-style-` | Set one CSS property — camelCase, e.g. `data-bind-style-backgroundImage` | | `data-show` / `data-hide` | Conditional visibility | | `data-format` | Format a bound value | | `data-format-opt-` | An option for that format | | `data-source` | Fetch data and set a context for children | | `data-source-payload` | Arguments for the source call | | `data-bind-each` | Marks the child that is repeated per array item | | `data-action` | Run a call on an event | | `data-payload` | Arguments for the action | | `data-trigger` | Which event fires it | | `data-confirm` | Ask before running | | `data-on-success` | Effect chain after success | | `data-on-error` | Effect chain after failure | ## Binding values ```html
``` An attribute binding that resolves to `null`, `undefined` or `false` removes the attribute rather than setting it empty. > [!NOTE] > **`data-bind-html` is sanitized** > It sets `innerHTML` through a sanitizer rather than assigning raw markup. ## Lists A list is two attributes working together: a **container** that fetches, and one **child** marked as the row template. ```html

``` `data-bind-each` takes no value. The element carrying it is cloned once per item in the returned array, and paths inside it resolve against that row. Without a `data-bind-each` child, `data-source` still sets a context — which is what you want when the source returns a single object rather than a list. ## Where a path resolves The runtime walks contexts in order and takes the first that has the path: 1. Row context — the current item, inside a `data-bind-each` template. 2. Source context — data from the nearest `data-source` ancestor. 3. Reactive state — `appmint.state`, which holds the cart mirror, the signed-in customer, and anything you set yourself. Nothing matching renders empty rather than erroring. ## Actions ```html ``` ### Triggers `data-trigger` overrides the default event: `click`, `submit`, `change` or `inview`. Defaults are sensible — buttons and links fire on `click`, forms on `submit`, inputs and selects on `change`. `inview` fires when the element scrolls into view, which is how you lazy-load a section. ### Payload forms `data-payload` accepts five shapes: | Form | Meaning | |---|---| | *(omitted)* | No arguments | | `form` | Serialize the containing form | | `this` | The current row or context object | | `this.` | One property of it | | `{"k":"v"}` | A JSON object, with `{{path}}` interpolation inside string values | Anything else is passed as a literal. ### Confirmation ```html ``` The action does not run unless confirmed. The message interpolates like any other binding. ## Visibility ```html
Welcome back
Your cart is empty
``` ## What you can reference Anything the runtime knows about: | Prefix | What it holds | |---|---| | `cart.*` | `count`, `total`, `items`, `isOpen` — mirrored from the cart store | | `customer.*` | The signed-in customer, and `isSignedIn` | | `data.*` | The current row or source context | | *(anything else)* | Keys you set yourself with `state.set` | ## Related ```cards cols: 3 items: - title: Actions reference href: /docs/studio-manager/build-studio/actions-reference icon: code body: All 101 methods you can call. - title: Effects and formats href: /docs/studio-manager/build-studio/effects-and-formats icon: zap body: What happens after an action, and how values render. - title: Custom elements href: /docs/studio-manager/build-studio/custom-elements icon: grid body: Tabs, dialogs, drawers, sliders and more. ``` --- # Actions reference > Every method callable from data-action, data-source or the JavaScript API. Source: https://docs.appmint.io/studio-manager/build-studio/actions-reference.html Runtime methods are grouped into namespaces and addressed as `namespace.method`. The same catalog backs three things: `data-action`, `data-source`, and `window.appmint` in a page script. **18 namespaces.** The tables below list the methods most pages use; the full catalog — 211 methods — is what `appmint.manifest()` returns at runtime. ## How to read this **Use as** tells you where a method belongs: | Column value | Where it goes | |---|---| | **Action** | `data-action` — runs on an event | | **List** | `data-source` — fetches and sets a context | | **Bind** | `data-bind` — a live value, read-only | | **—** | No panel hint; usable from a script | A ● marks the methods surfaced first in the property panel — the common ones. ### `cart` — Storefront cart. | Method | Use as | Required params | Description | |---|---|---|---| | `cart.current` ● | List | — | Get the live cart. | | `cart.add` ● | Action | `id` | Add a product to the cart. | | `cart.remove` ● | Action | — | Remove an item. | | `cart.update` ● | Action | `id`, `quantity` | Set / increment / decrement quantity. | | `cart.clear` | Action | — | Empty the cart. | | `cart.count` ● | Bind | — | Total item count (read-only — bind via data-bind="cart.count"). | | `cart.total` ● | Bind | — | Subtotal (read-only — bind via data-bind="cart.total"). | | `cart.toggle` ● | Action | — | Toggle the cart drawer. | | `cart.open` | Action | — | Open the cart drawer. | | `cart.close` | Action | — | Close the cart drawer. | ### `product` — Catalog browse / search. | Method | Use as | Required params | Description | |---|---|---|---| | `product.list` ● | List | — | List products. | | `product.get` ● | Action | — | Get one product. | | `product.categories` | List | — | Category tree. | | `product.brands` | List | — | Brand list. | | `product.collections` | List | — | Curated collections. | | `product.related` | — | — | Related products. | | `product.attributes` | — | — | Facet attributes. | ### `order` — Customer orders. | Method | Use as | Required params | Description | |---|---|---|---| | `order.list` ● | List | — | List the customer | | `order.get` ● | Action | — | Get an order by id. | | `order.cancel` | Action | `orderNumber` | Cancel an order. | | `order.guestLookup` | Action | — | Guest order tracking. | | `order.refund` | — | — | Request a refund. | | `order.getByNumber` | — | — | Get by order number. | | `order.myOrders` | — | — | All my orders. | ### `storefront` — Checkout, shipping, payments. | Method | Use as | Required params | Description | |---|---|---|---| | `storefront.paymentGateways` ● | List | — | Available payment gateways. | | `storefront.checkout` ● | Action | — | Checkout the active cart. | | `storefront.buyNow` ● | Action | — | Express single-product checkout. | | `storefront.config` ● | List | — | Public storefront config. | | `storefront.pricing` ● | — | — | Calculate price for a product/variant. | | `storefront.cartPricing` ● | — | — | Recalculate full cart pricing. | | `storefront.applyCoupon` ● | Action | — | Apply a coupon code. | | `storefront.validateCoupon` ● | Action | — | Validate a coupon. | | `storefront.activePromotions` ● | List | — | Currently-running promotions. | | `storefront.shippingRates` ● | List | `destination` | Shipping rates for a basket + destination. | | `storefront.verifyAddress` ● | Action | `street`, `city`, `state`, `zip`, `country` | Validate a shipping address. | | `storefront.saveCart` ● | Action | — | Persist cart server-side. | | `storefront.productSearch` ● | List | — | Full-text product search. | ### `rental` — Equipment / item rental. | Method | Use as | Required params | Description | |---|---|---|---| | `rental.catalog` ● | List | — | Browse rental items. | | `rental.get` ● | Action | — | Get a rental item. | | `rental.availability` ● | Action | — | Item availability. | | `rental.quote` ● | Action | `sku`, `startDate`, `endDate` | Price for a date range. | | `rental.book` ● | Action | — | Create a booking. | | `rental.list` | List | — | My rentals. | | `rental.cancel` | Action | — | Cancel a rental. | ### `customer` — Authentication & customer dashboard. | Method | Use as | Required params | Description | |---|---|---|---| | `customer.current` ● | Bind | — | Currently signed-in customer. | | `customer.isSignedIn` ● | Bind | — | Sync sign-in check. | | `customer.signIn` ● | Action | — | Sign in with email + password. | | `customer.signUp` ● | Action | `firstName`, `lastName` | Create an account. | | `customer.signOut` ● | Action | — | Sign out. | | `customer.resetPassword` | Action | — | Send reset email. | | `customer.addresses` | List | — | Address book. | | `customer.wishlist` | List | — | Get wishlist. | | `customer.wishlistAdd` | Action | — | Add to wishlist. | | `customer.wishlistRemove` | Action | — | Remove from wishlist. | ### `ticket` — Support tickets. | Method | Use as | Required params | Description | |---|---|---|---| | `ticket.list` ● | List | — | Customer tickets. | | `ticket.get` ● | — | — | Get one ticket. | | `ticket.create` ● | Action | `collection`, `subject`, `message` | Open a ticket. | | `ticket.guestCreate` ● | Action | `collection`, `subject`, `message` | Open a ticket as a guest. | | `ticket.guestLookup` | Action | — | Look up by email + number. | | `ticket.collections` ● | List | — | Ticket categories. | ### `reservation` — Bookings / reservations. | Method | Use as | Required params | Description | |---|---|---|---| | `reservation.list` ● | List | — | My reservations. | | `reservation.get` ● | — | — | Get one. | | `reservation.create` ● | Action | `reservationDefinitionId`, `serviceDate`, `startTime`, `customerName` | Make a reservation. | | `reservation.availableSlots` ● | List | `reservationDefinitionId`, `serviceDate` | Open slots. | | `reservation.cancel` | Action | — | Cancel. | | `reservation.definitions` ● | List | — | Reservation services. | ### `events` — Public events. | Method | Use as | Required params | Description | |---|---|---|---| | `events.list` ● | List | — | Browse events. | | `events.get` ● | — | — | Get an event. | | `events.ticketTypes` | List | — | Ticket types/prices. | | `events.register` ● | Action | `eventId`, `ticketTypeId` | Free registration. | | `events.purchaseTickets` ● | Action | — | Buy tickets. | ### `repository` — READ-ONLY generic data access. | Method | Use as | Required params | Description | |---|---|---|---| | `repository.find` ● | List | — | Filter query against any datatype. | | `repository.findOne` ● | — | — | Get by id. | | `repository.search` ● | List | — | Search. | ### `media` — Audio / video / animation. | Method | Use as | Required params | Description | |---|---|---|---| | `media.play` ● | Action | — | Play. | | `media.pause` ● | Action | — | Pause. | | `media.stop` | Action | — | Stop. | | `media.volume` | Action | `level` | Set volume 0..1. | | `media.seek` | Action | `seconds` | Seek to seconds. | | `media.toggleMute` | Action | — | Mute / unmute. | | `media.loadTrack` | Action | `url` | Load + play in BottomAudioPlayer. | | `media.animate` | Action | `target`, `action` | Toggle CSS animation. | ### `ui` — UI controls. | Method | Use as | Required params | Description | |---|---|---|---| | `ui.drawer` ● | Action | `id` | Open / close / toggle a wm-drawer. | | `ui.dialog` ● | Action | `id` | Open / close / toggle a wm-dialog. | | `ui.modal` | Action | `id` | Open / close / toggle a modal. | | `ui.scrollTo` | Action | `target` | Scroll to an element. | | `ui.notify` ● | Action | `message` | Show a toast. | ### `state` — In-memory reactive store. | Method | Use as | Required params | Description | |---|---|---|---| | `state.get` | — | — | Read at path. | | `state.set` ● | Action | — | Write at path. | | `state.inc` ● | Action | — | Increment number. | | `state.dec` ● | Action | — | Decrement number. | | `state.toggle` ● | Action | — | Toggle boolean. | ### `files` — File management. | Method | Use as | Required params | Description | |---|---|---|---| | `files.list` ● | List | — | List my files. | | `files.upload` ● | — | — | Upload a file. | | `files.delete` | Action | — | Delete a file. | ### `site` — Site / org info. | Method | Use as | Required params | Description | |---|---|---|---| | `site.current` ● | — | — | Current site. | | `site.org` | — | — | Org info. | ### `page` — Page lookup. | Method | Use as | Required params | Description | |---|---|---|---| | `page.getBySlug` | — | — | Get a page by slug. | ### `form` — Form helpers. | Method | Use as | Required params | Description | |---|---|---|---| | `form.serialize` | — | — | Serialize form to object. | | `form.validate` | — | — | Validate. | | `form.reset` | — | — | Reset to defaults. | ### `contentPlayer` — Posts played page by page (courses, applications, trainings). The post, page and access code default to the address — `///?code=…` — so a page needs only the action. See [Content Player](/docs/building/content-player). | Method | Use as | Required params | Description | |---|---|---|---| | `contentPlayer.mine` ● | List | — | My programs: status, % and next page. Signed in. | | `contentPlayer.outline` ● | List | — | The post, its outline with each page's status, and my progress. | | `contentPlayer.enroll` ● | Action | — | Start. Signed in. | | `contentPlayer.item` ● | List | — | One page: content, prev / next, my saved answer. | | `contentPlayer.complete` ● | Action | — | Finish this page (waits for review when it has reviewers). | | `contentPlayer.progress` | — | `percent` or `score` | Report watched % or a score. | | `contentPlayer.answer` ● | Action | form | Send the page's answer (`data-payload="form"`); files upload first. | | `contentPlayer.saveDraft` | Action | form | Keep the answer as a draft. | ## Calling from a script Every method is also on `window.appmint`: ```js (async () => { if (appmint.customer.isSignedIn()) { const me = await appmint.customer.current(); appmint.ui.notify('Welcome back, ' + (me?.firstName || 'friend'), { kind: 'success' }); } const count = await appmint.cart.count(); appmint.state.set('cart.count', count); })(); ``` No build step and no framework — the API is on the page. > [!NOTE] > **`repository` is read-only** > `repository.find`, `findOne` and `search` reach any datatype the visitor is allowed to read. There is no write counterpart in the runtime — writes go through a specific action such as `cart.add` or `ticket.create`. ## Related ```cards cols: 3 items: - title: Runtime attributes href: /docs/studio-manager/build-studio/runtime-attributes icon: code body: How to wire these into a page. - title: Effects and formats href: /docs/studio-manager/build-studio/effects-and-formats icon: zap body: What runs after an action succeeds or fails. - title: AppEngine API href: /docs/appengine/overview icon: layers body: The server endpoints behind these methods. ``` --- # Effects and formats > What runs after an action, and how bound values are rendered. Source: https://docs.appmint.io/studio-manager/build-studio/effects-and-formats.html Two small vocabularies do a lot of work: **effects** decide what happens after an action, **formats** decide how a bound value renders. ## Effects An effect chain runs after an action resolves. `data-on-success` runs on success, `data-on-error` on failure. Chains are pipe-separated and run left to right: ```html data-on-success="notify:Saved | close-dialog | refresh" ``` Each entry is `verb` or `verb:argument`. | Effect | Syntax | What it does | |---|---|---| | `navigate` | `navigate:` | Go to a URL | | `notify` | `notify:` | Show an info toast | | `notify-error` | `notify-error:` | Show an error toast | | `close-dialog` | `close-dialog` | Close the nearest open dialog or drawer | | `open-dialog` | `open-dialog:#dialogId` | Open a dialog by selector | | `reload` | `reload` | Hard reload the page | | `refresh` | `refresh` or `refresh:#id` | Re-run every `data-source` on the page, or just one | | `set-state` | `set-state:path=value` | Write into `appmint.state` | | `reset-form` | `reset-form` | Reset the form the action came from | | `focus` | `focus:#sel` | Focus an element | `refresh` is usually what you want after a write — it re-runs the sources so lists reflect the change without a page reload. ### Worked example ```html
``` Submitting serializes the form, creates the ticket, toasts, clears the form, and refreshes only the ticket list. ## Formats `data-format` renders a bound value for display. The underlying value is untouched. | Format | Renders as | |---|---| | `currency` | Currency — takes a `currency` option, ISO code, default `USD` | | `number` | Locale-formatted number | | `percent` | Percentage | | `date` | Locale date | | `datetime` | Locale date and time | | `parentheses` | Wraps positive numbers in parentheses | | `uppercase` | UPPERCASE | | `lowercase` | lowercase | | `capitalize` | Capitalized | ### Format options Options are passed as separate attributes, named `data-format-opt-